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

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:
2026-06-26 13:59:39 +00:00
parent 0a6c9d8852
commit 5bf787eb2b
20 changed files with 2907 additions and 53 deletions
+2
View File
@@ -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() },
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionStats } from "@/sections/Stats";
export const Route = createFileRoute("/stats")({ component: SectionStats });
+268
View File
@@ -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>
);
}
+108
View File
@@ -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}
/>
);
};
+399
View File
@@ -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>
);