forked from unom/punktfunk
Arm streaming-perf-stats capture from the web console, play, stop, and review the run as graphs; finished captures are saved to disk as browsable/exportable recordings. Covers both the native punktfunk/1 path and GameStream. - stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve, shared with the mgmt API + both streaming loops, mirroring NativePairing). The hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids; poison-resilient locks. - native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing ~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput, loss/FEC deltas — with no new per-frame work beyond the cheap atomic check. FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's percentiles (without zeroing the Windows-relay path's fps/encode). - mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live; recordings list/get/delete); api/openapi.json regenerated, in sync. - web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live graphs while armed, recordings table (view / download-JSON / delete), and a detail view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput + health. Charts adapt to either path's stage set. Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet on-glass validated against a live session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
2.9 KiB
TypeScript
109 lines
2.9 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { type FC, useState } from "react";
|
|
import {
|
|
getStatsCaptureStatusQueryKey,
|
|
getStatsRecordingsListQueryKey,
|
|
statsRecordingGet,
|
|
useStatsCaptureLive,
|
|
useStatsCaptureStart,
|
|
useStatsCaptureStatus,
|
|
useStatsCaptureStop,
|
|
useStatsRecordingDelete,
|
|
useStatsRecordingGet,
|
|
useStatsRecordingsList,
|
|
} from "@/api/gen/stats/stats";
|
|
import { useLocale } from "@/lib/i18n";
|
|
import { m } from "@/paraglide/messages";
|
|
import { StatsView } from "./view";
|
|
|
|
export const SectionStats: FC = () => {
|
|
useLocale();
|
|
const qc = useQueryClient();
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
// Poll the capture status (drives the control card + whether the live chart shows).
|
|
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
|
|
const armed = status.data?.armed ?? false;
|
|
|
|
// Live in-progress capture — only fetched while armed (404s when idle).
|
|
const live = useStatsCaptureLive({
|
|
query: { refetchInterval: 2_000, enabled: armed },
|
|
});
|
|
|
|
const recordings = useStatsRecordingsList();
|
|
|
|
// Selected recording detail — only fetched once a row is chosen.
|
|
const detail = useStatsRecordingGet(selectedId ?? "", {
|
|
query: { enabled: !!selectedId },
|
|
});
|
|
|
|
const start = useStatsCaptureStart();
|
|
const stop = useStatsCaptureStop();
|
|
const del = useStatsRecordingDelete();
|
|
|
|
const refreshStatus = () =>
|
|
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
|
|
const refreshRecordings = () =>
|
|
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
|
|
|
|
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
|
|
const onStop = () =>
|
|
stop.mutate(undefined, {
|
|
onSuccess: () => {
|
|
refreshStatus();
|
|
refreshRecordings();
|
|
},
|
|
});
|
|
|
|
const onDelete = (id: string) => {
|
|
if (!confirm(m.stats_delete_confirm())) return;
|
|
del.mutate(
|
|
{ id },
|
|
{
|
|
onSuccess: () => {
|
|
if (selectedId === id) setSelectedId(null);
|
|
refreshRecordings();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
// Export the full Capture JSON via a one-off GET → blob download.
|
|
const onDownload = async (id: string) => {
|
|
try {
|
|
const cap = await statsRecordingGet(id);
|
|
const blob = new Blob([JSON.stringify(cap, null, 2)], {
|
|
type: "application/json",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${id}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
// Best-effort export; the recording GET surfaces its own errors via the detail view.
|
|
}
|
|
};
|
|
|
|
return (
|
|
<StatsView
|
|
status={status}
|
|
live={live}
|
|
recordings={recordings}
|
|
detail={detail}
|
|
selectedId={selectedId}
|
|
onStart={onStart}
|
|
onStop={onStop}
|
|
onSelect={setSelectedId}
|
|
onDownload={onDownload}
|
|
onDelete={onDelete}
|
|
isStarting={start.isPending}
|
|
isStopping={stop.isPending}
|
|
isDeleting={del.isPending}
|
|
/>
|
|
);
|
|
};
|