feat(host): web-console performance capture — record stream stats, graph them
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m13s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m51s
apple / screenshots (push) Successful in 5m1s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m10s
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m13s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m51s
apple / screenshots (push) Successful in 5m1s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m10s
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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
Activity,
|
||||
GaugeCircle,
|
||||
KeyRound,
|
||||
LibraryBig,
|
||||
Server,
|
||||
@@ -21,6 +22,7 @@ const NAV = [
|
||||
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
|
||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionStats } from "@/sections/Stats";
|
||||
|
||||
export const Route = createFileRoute("/stats")({ component: SectionStats });
|
||||
@@ -0,0 +1,268 @@
|
||||
// Recharts visualisations for a captured stats series. Everything here is rendered
|
||||
// CLIENT-ONLY (behind <ChartFrame>'s mounted guard): recharts' ResponsiveContainer
|
||||
// measures its parent via ResizeObserver, which has no width during SSR and would
|
||||
// otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample
|
||||
// carries — native (capture/submit/encode/send) and gamestream
|
||||
// (capture/encode/packetize/send) both stack sensibly.
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { StatsSample } from "@/api/gen/model/statsSample";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
const CHART_H = 240;
|
||||
|
||||
const axisTick = { fontSize: 11, fill: "var(--muted-foreground)" } as const;
|
||||
const gridStroke = "var(--border)";
|
||||
const tooltipStyle = {
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--foreground)",
|
||||
} as const;
|
||||
const legendStyle = { fontSize: 12 } as const;
|
||||
|
||||
// Known stages get a stable hue; anything else falls back to the palette by
|
||||
// appearance order, so an unexpected stage name still renders a distinct band.
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
capture: "#6c5bf3",
|
||||
submit: "#22a2f2",
|
||||
encode: "#f2a922",
|
||||
packetize: "#1fb6a8",
|
||||
send: "#f25c8a",
|
||||
};
|
||||
const PALETTE = [
|
||||
"#6c5bf3",
|
||||
"#22a2f2",
|
||||
"#f2a922",
|
||||
"#1fb6a8",
|
||||
"#f25c8a",
|
||||
"#9b6cf3",
|
||||
];
|
||||
|
||||
/** True only after the first client-side effect — gates recharts off the server render. */
|
||||
function useMounted(): boolean {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
/** Reserves the chart's height during SSR / before mount, then swaps in the responsive chart. */
|
||||
function ChartFrame({ children }: { children: ReactElement }) {
|
||||
const mounted = useMounted();
|
||||
if (!mounted) return <div style={{ height: CHART_H }} aria-hidden />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_H}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stage names across all samples, in first-seen (pipeline) order. */
|
||||
function stageNames(samples: StatsSample[]): string[] {
|
||||
const seen: string[] = [];
|
||||
for (const s of samples)
|
||||
for (const st of s.stages) if (!seen.includes(st.name)) seen.push(st.name);
|
||||
return seen;
|
||||
}
|
||||
|
||||
function colorFor(name: string, i: number): string {
|
||||
return STAGE_COLORS[name] ?? PALETTE[i % PALETTE.length] ?? "#6c5bf3";
|
||||
}
|
||||
|
||||
/** Latency stacked-area (µs) — the "where does the time go" view. With `toggle`, a
|
||||
* p50/p99 switch flips every stage band between its median and tail. */
|
||||
export function LatencyChart({
|
||||
samples,
|
||||
toggle,
|
||||
}: {
|
||||
samples: StatsSample[];
|
||||
toggle?: boolean;
|
||||
}) {
|
||||
const [p99, setP99] = useState(false);
|
||||
const names = stageNames(samples);
|
||||
const rows = samples.map((s) => {
|
||||
const row: Record<string, number> = { t: Math.round(s.t_ms / 1000) };
|
||||
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
|
||||
for (const n of names) {
|
||||
const st = byName.get(n);
|
||||
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{toggle && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setP99((v) => !v)}>
|
||||
{p99 ? m.stats_p99() : m.stats_p50()}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ChartFrame>
|
||||
<AreaChart
|
||||
data={rows}
|
||||
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
width={52}
|
||||
unit="µs"
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Legend wrapperStyle={legendStyle} />
|
||||
{names.map((n, i) => (
|
||||
<Area
|
||||
key={n}
|
||||
type="monotone"
|
||||
dataKey={n}
|
||||
stackId="lat"
|
||||
stroke={colorFor(n, i)}
|
||||
fill={colorFor(n, i)}
|
||||
fillOpacity={0.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** New vs repeat fps (left axis) + tx goodput Mb/s (right axis). */
|
||||
export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
|
||||
const rows = samples.map((s) => ({
|
||||
t: Math.round(s.t_ms / 1000),
|
||||
fps: s.fps,
|
||||
repeat: s.repeat_fps,
|
||||
mbps: s.mbps,
|
||||
}));
|
||||
return (
|
||||
<ChartFrame>
|
||||
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<YAxis
|
||||
yAxisId="fps"
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
width={40}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="mbps"
|
||||
orientation="right"
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Legend wrapperStyle={legendStyle} />
|
||||
<Line
|
||||
yAxisId="fps"
|
||||
type="monotone"
|
||||
dataKey="fps"
|
||||
name={m.stats_fps_new()}
|
||||
stroke="#6c5bf3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="fps"
|
||||
type="monotone"
|
||||
dataKey="repeat"
|
||||
name={m.stats_fps_repeat()}
|
||||
stroke="#f2a922"
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="mbps"
|
||||
type="monotone"
|
||||
dataKey="mbps"
|
||||
name={m.stats_mbps()}
|
||||
stroke="#1fb6a8"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. */
|
||||
export function HealthChart({ samples }: { samples: StatsSample[] }) {
|
||||
const rows = samples.map((s) => ({
|
||||
t: Math.round(s.t_ms / 1000),
|
||||
frames: s.frames_dropped,
|
||||
packets: s.packets_dropped,
|
||||
send: s.send_dropped,
|
||||
fec: s.fec_recovered,
|
||||
}));
|
||||
return (
|
||||
<ChartFrame>
|
||||
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
|
||||
<YAxis
|
||||
tick={axisTick}
|
||||
stroke={gridStroke}
|
||||
width={40}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Legend wrapperStyle={legendStyle} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="frames"
|
||||
name={m.stats_frames_dropped()}
|
||||
stroke="#f25c8a"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="packets"
|
||||
name={m.stats_packets_dropped()}
|
||||
stroke="#f2a922"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="send"
|
||||
name={m.stats_send_dropped()}
|
||||
stroke="#22a2f2"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fec"
|
||||
name={m.stats_fec_recovered()}
|
||||
stroke="#1fb6a8"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Circle, Download, Eye, Square, Trash2, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
|
||||
import type { StatsStatus } from "@/api/gen/model/statsStatus";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Section } from "@/components/section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
|
||||
|
||||
/** ms → `m:ss`. */
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtTimestamp(unixMs: number): string {
|
||||
if (!unixMs) return "—";
|
||||
return new Date(unixMs).toLocaleString();
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
if (kind === "gamestream") return m.stats_kind_gamestream();
|
||||
if (kind === "native") return m.stats_kind_native();
|
||||
return kind;
|
||||
}
|
||||
|
||||
export interface StatsViewProps {
|
||||
status: Loadable<StatsStatus>;
|
||||
live: Loadable<Capture>;
|
||||
recordings: Loadable<CaptureMeta[]>;
|
||||
detail: Loadable<Capture>;
|
||||
selectedId: string | null;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
onDownload: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export const StatsView: FC<StatsViewProps> = (props) => {
|
||||
const armed = props.status.data?.armed ?? false;
|
||||
return (
|
||||
<Section>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.stats_title()}</h1>
|
||||
<p className="text-sm text-muted-foreground">{m.stats_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
<CaptureControlCard
|
||||
status={props.status}
|
||||
onStart={props.onStart}
|
||||
onStop={props.onStop}
|
||||
isStarting={props.isStarting}
|
||||
isStopping={props.isStopping}
|
||||
/>
|
||||
|
||||
{armed && <LiveCard live={props.live} />}
|
||||
|
||||
<RecordingsCard
|
||||
recordings={props.recordings}
|
||||
selectedId={props.selectedId}
|
||||
onSelect={props.onSelect}
|
||||
onDownload={props.onDownload}
|
||||
onDelete={props.onDelete}
|
||||
isDeleting={props.isDeleting}
|
||||
/>
|
||||
|
||||
{props.selectedId && (
|
||||
<DetailCard
|
||||
detail={props.detail}
|
||||
onClose={() => props.onSelect(null)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
|
||||
const CaptureControlCard: FC<{
|
||||
status: Loadable<StatsStatus>;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
}> = ({ status, onStart, onStop, isStarting, isStopping }) => {
|
||||
const s = status.data;
|
||||
const armed = s?.armed ?? false;
|
||||
const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0;
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-3">
|
||||
<span>{m.stats_capture_title()}</span>
|
||||
{armed ? (
|
||||
<Badge variant="destructive" className="gap-1.5">
|
||||
<Circle className="size-2.5 animate-pulse fill-current" />
|
||||
{m.stats_recording()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{m.stats_idle()}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.stats_capture_desc()}
|
||||
</p>
|
||||
{armed && s && (
|
||||
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
|
||||
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
|
||||
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
|
||||
{s.kind && (
|
||||
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{armed ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isStopping}
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square className="size-4" />
|
||||
{m.stats_stop()}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={isStarting} onClick={onStart}>
|
||||
<Circle className="size-4 fill-current" />
|
||||
{m.stats_start()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
const Stat: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div className="flex flex-col">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Live graphs while a capture is armed: latency stack + throughput. */
|
||||
const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||
const samples = live.data?.samples ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.stats_live_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{samples.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{m.stats_live_waiting()}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ChartBlock
|
||||
title={m.stats_latency_title()}
|
||||
desc={m.stats_latency_desc()}
|
||||
>
|
||||
<LatencyChart samples={samples} />
|
||||
</ChartBlock>
|
||||
<ChartBlock title={m.stats_throughput_title()}>
|
||||
<ThroughputChart samples={samples} />
|
||||
</ChartBlock>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** Saved recordings, with View / Download / Delete row actions. */
|
||||
const RecordingsCard: FC<{
|
||||
recordings: Loadable<CaptureMeta[]>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onDownload: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isDeleting: boolean;
|
||||
}> = ({
|
||||
recordings,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onDownload,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}) => {
|
||||
const rows = recordings.data ?? [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
|
||||
<QueryState
|
||||
isLoading={recordings.isLoading}
|
||||
error={recordings.error}
|
||||
refetch={recordings.refetch}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||
{m.stats_recordings_empty()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.stats_col_time()}</TableHead>
|
||||
<TableHead>{m.stats_col_kind()}</TableHead>
|
||||
<TableHead>{m.stats_col_resolution()}</TableHead>
|
||||
<TableHead>{m.stats_col_codec()}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{m.stats_col_duration()}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{m.stats_col_samples()}
|
||||
</TableHead>
|
||||
<TableHead className="w-32" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow
|
||||
key={r.id}
|
||||
data-state={selectedId === r.id ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap font-medium">
|
||||
{fmtTimestamp(r.started_unix_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
r.kind === "gamestream" ? "secondary" : "default"
|
||||
}
|
||||
>
|
||||
{kindLabel(r.kind)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{r.width}×{r.height}@{r.fps}
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{r.codec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmtDuration(r.duration_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{r.sample_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.stats_view()}
|
||||
title={m.stats_view()}
|
||||
onClick={() =>
|
||||
onSelect(selectedId === r.id ? null : r.id)
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.stats_download()}
|
||||
title={m.stats_download()}
|
||||
onClick={() => onDownload(r.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.stats_delete()}
|
||||
title={m.stats_delete()}
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(r.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
|
||||
const DetailCard: FC<{ detail: Loadable<Capture>; onClose: () => void }> = ({
|
||||
detail,
|
||||
onClose,
|
||||
}) => {
|
||||
const cap = detail.data;
|
||||
const samples = cap?.samples ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{m.stats_detail_title()}
|
||||
{cap && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
|
||||
{cap.meta.codec.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.stats_close()}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QueryState
|
||||
isLoading={detail.isLoading}
|
||||
error={detail.error}
|
||||
refetch={detail.refetch}
|
||||
>
|
||||
{samples.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{m.stats_no_samples()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<ChartBlock
|
||||
title={m.stats_latency_title()}
|
||||
desc={m.stats_latency_desc()}
|
||||
>
|
||||
<LatencyChart samples={samples} toggle />
|
||||
</ChartBlock>
|
||||
<ChartBlock title={m.stats_throughput_title()}>
|
||||
<ThroughputChart samples={samples} />
|
||||
</ChartBlock>
|
||||
<ChartBlock title={m.stats_health_title()}>
|
||||
<HealthChart samples={samples} />
|
||||
</ChartBlock>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartBlock: FC<{
|
||||
title: string;
|
||||
desc?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, desc, children }) => (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user