import { type FC, useMemo } from "react"; import { ApiError } from "@/api/fetcher"; import type { Capture } from "@/api/gen/model/capture"; import { useStatsCaptureLive, useStatsCaptureStatus, } from "@/api/gen/stats/stats"; import { QueryState } from "@/components/query-state"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Loadable } from "@/lib/query"; import { m } from "@/paraglide/messages"; import { HealthChart, LatencyChart, ThroughputChart } from "./charts"; import { ChartBlock } from "./helpers"; /** * Container: the live graphs. Self-gates on the capture being armed — it shares the status query * (same key) with the control card, and only fetches the in-progress capture while armed (it 404s * when idle). Renders nothing when no capture is running. */ export const LiveSection: FC = () => { const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } }); const armed = status.data?.armed ?? false; const live = useStatsCaptureLive({ query: { refetchInterval: 2_000, enabled: armed }, }); if (!armed) return null; return ; }; /** * How many samples the live charts plot. * * The live endpoint returns the capture SO FAR, which grows without bound — a capture left running * over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail * is also the only part anyone watches live (the full series is what the saved recording is for), * so plot a bounded window and leave the rest to the detail view. */ const LIVE_WINDOW = 600; /** Live graphs while a capture is armed: latency stack + throughput. */ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { const all = live.data?.samples; // Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so // an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart // rebuild either (the charts memoise on exactly this). const samples = useMemo( () => all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []), [all], ); // A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as // "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting". const error = live.error instanceof ApiError && live.error.status === 404 ? null : live.error; return ( {m.stats_live_title()} {samples.length === 0 ? (

{m.stats_live_waiting()}

) : ( <> {/* Loss/recovery was only ever visible AFTER stopping and reopening the saved recording — which is backwards: dropped frames and FEC recovery are what you watch a live capture FOR. The `kind` note keeps the GameStream caveat (only `frames` is instrumented there). */} {(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (

{m.stats_live_window({ count: LIVE_WINDOW })}

)} )}
); };