From 5bf787eb2b6db2f314f5103eb24ea0ffe71da1b5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 13:59:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(host):=20web-console=20performance=20captu?= =?UTF-8?q?re=20=E2=80=94=20record=20stream=20stats,=20graph=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- CLAUDE.md | 14 +- api/openapi.json | 528 +++++++++++++++++ crates/punktfunk-host/src/gamestream/mod.rs | 27 +- .../punktfunk-host/src/gamestream/nvhttp.rs | 5 +- crates/punktfunk-host/src/gamestream/rtsp.rs | 1 + .../punktfunk-host/src/gamestream/stream.rs | 162 ++++- crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/mgmt.rs | 216 ++++++- crates/punktfunk-host/src/punktfunk1.rs | 258 +++++++- crates/punktfunk-host/src/stats_recorder.rs | 553 ++++++++++++++++++ design/stats-capture-plan.md | 246 ++++++++ web/bun.lock | 73 +++ web/messages/de.json | 47 +- web/messages/en.json | 47 +- web/package.json | 1 + web/src/components/app-shell.tsx | 2 + web/src/routes/stats.tsx | 4 + web/src/sections/Stats/charts.tsx | 268 +++++++++ web/src/sections/Stats/index.tsx | 108 ++++ web/src/sections/Stats/view.tsx | 399 +++++++++++++ 20 files changed, 2907 insertions(+), 53 deletions(-) create mode 100644 crates/punktfunk-host/src/stats_recorder.rs create mode 100644 design/stats-capture-plan.md create mode 100644 web/src/routes/stats.tsx create mode 100644 web/src/sections/Stats/charts.tsx create mode 100644 web/src/sections/Stats/index.tsx create mode 100644 web/src/sections/Stats/view.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 4c7c3d2..c7e2d11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble back-channel; validated live — pad created/destroyed with the session). Management REST API + - checked-in OpenAPI doc (`mgmt.rs`). + checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`, + design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats + recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency + breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc` ring (the hot-path + gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the + native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation + boundary, and finished captures are saved as on-disk recordings + (`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page + (recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.* - **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM** @@ -275,7 +283,7 @@ crates/punktfunk-host/ zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan) inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense) encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264) - capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs + capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) @@ -283,7 +291,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/decky/ Steam Deck Decky plugin crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends -web/ TanStack web console over the mgmt API (status · devices · pairing) +web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) tools/{loss-harness,latency-probe}/ measurement (plan §10) scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/ diff --git a/api/openapi.json b/api/openapi.json index fed50b7..83cf450 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -978,6 +978,309 @@ } } }, + "/api/v1/stats/capture/live": { + "get": { + "tags": [ + "stats" + ], + "summary": "Live in-progress capture", + "description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.", + "operationId": "statsCaptureLive", + "responses": { + "200": { + "description": "The in-progress capture (meta + samples so far)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capture" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No capture is currently recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/start": { + "post": { + "tags": [ + "stats" + ], + "summary": "Start a stats capture", + "description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.", + "operationId": "statsCaptureStart", + "responses": { + "200": { + "description": "Capture armed (or already running)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/status": { + "get": { + "tags": [ + "stats" + ], + "summary": "Stats capture status", + "description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.", + "operationId": "statsCaptureStatus", + "responses": { + "200": { + "description": "In-progress capture status (idle when not armed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/stop": { + "post": { + "tags": [ + "stats" + ], + "summary": "Stop the stats capture", + "description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.", + "operationId": "statsCaptureStop", + "responses": { + "200": { + "description": "Capture stopped and saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureMeta" + } + } + } + }, + "204": { + "description": "Nothing was recording" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not write the recording to disk", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/recordings": { + "get": { + "tags": [ + "stats" + ], + "summary": "List saved recordings", + "description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.", + "operationId": "statsRecordingsList", + "responses": { + "200": { + "description": "Saved capture summaries, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaptureMeta" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/recordings/{id}": { + "get": { + "tags": [ + "stats" + ], + "summary": "Get a saved recording", + "description": "The full capture (meta + samples) for `id`, for graphing or download.", + "operationId": "statsRecordingGet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The recording id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The full capture", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capture" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No recording with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "The recording file is unreadable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "stats" + ], + "summary": "Delete a saved recording", + "description": "Removes the recording `id` from disk. `404` if there is no such recording.", + "operationId": "statsRecordingDelete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The recording id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Recording deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No recording with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not delete the recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/status": { "get": { "tags": [ @@ -1125,6 +1428,89 @@ } } }, + "Capture": { + "type": "object", + "description": "A full capture: summary + the sample time-series. The wire + on-disk shape.", + "required": [ + "meta", + "samples" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/CaptureMeta" + }, + "samples": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatsSample" + } + } + } + }, + "CaptureMeta": { + "type": "object", + "description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].", + "required": [ + "id", + "started_unix_ms", + "duration_ms", + "kind", + "width", + "height", + "fps", + "codec", + "client", + "sample_count" + ], + "properties": { + "client": { + "type": "string", + "description": "Short label / fingerprint prefix, or `\"\"` if unknown." + }, + "codec": { + "type": "string", + "description": "`\"h264\" | \"hevc\" | \"av1\"`." + }, + "duration_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "id": { + "type": "string", + "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." + }, + "kind": { + "type": "string", + "description": "`\"native\" | \"gamestream\"`." + }, + "sample_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "started_unix_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "CustomEntry": { "type": "object", "description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.", @@ -1595,6 +1981,144 @@ } } }, + "StageTiming": { + "type": "object", + "description": "One pipeline stage's latency in an aggregation window (microseconds).", + "required": [ + "name", + "p50_us", + "p99_us" + ], + "properties": { + "name": { + "type": "string", + "description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)." + }, + "p50_us": { + "type": "number", + "format": "float" + }, + "p99_us": { + "type": "number", + "format": "float" + } + } + }, + "StatsSample": { + "type": "object", + "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).", + "required": [ + "t_ms", + "session_id", + "stages", + "fps", + "repeat_fps", + "mbps", + "bitrate_kbps", + "frames_dropped", + "packets_dropped", + "send_dropped", + "fec_recovered" + ], + "properties": { + "bitrate_kbps": { + "type": "integer", + "format": "int32", + "description": "Configured target bitrate.", + "minimum": 0 + }, + "fec_recovered": { + "type": "integer", + "format": "int32", + "description": "FEC shards recovered this window (delta).", + "minimum": 0 + }, + "fps": { + "type": "number", + "format": "float", + "description": "Genuine NEW frames/s from the source." + }, + "frames_dropped": { + "type": "integer", + "format": "int32", + "description": "Frames dropped this window (delta).", + "minimum": 0 + }, + "mbps": { + "type": "number", + "format": "float", + "description": "Transmit goodput (Mb/s)." + }, + "packets_dropped": { + "type": "integer", + "format": "int32", + "description": "Packets dropped this window (receiver-side / reassembler, where known).", + "minimum": 0 + }, + "repeat_fps": { + "type": "number", + "format": "float", + "description": "Re-encoded holds/s (source-starvation indicator)." + }, + "send_dropped": { + "type": "integer", + "format": "int32", + "description": "Host send-buffer overflow / EAGAIN this window (delta).", + "minimum": 0 + }, + "session_id": { + "type": "integer", + "format": "int32", + "description": "Disambiguates concurrent sessions (usually constant).", + "minimum": 0 + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StageTiming" + }, + "description": "Ordered pipeline stages for this path." + }, + "t_ms": { + "type": "integer", + "format": "int64", + "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", + "minimum": 0 + } + } + }, + "StatsStatus": { + "type": "object", + "description": "Snapshot of the in-progress capture for the management API.", + "required": [ + "armed", + "sample_count", + "started_unix_ms", + "kind" + ], + "properties": { + "armed": { + "type": "boolean", + "description": "Capture currently running." + }, + "kind": { + "type": "string", + "description": "Path of the in-progress capture (`\"\"` if idle)." + }, + "sample_count": { + "type": "integer", + "format": "int32", + "description": "Samples in the in-progress capture.", + "minimum": 0 + }, + "started_unix_ms": { + "type": "integer", + "format": "int64", + "description": "Unix start time of the in-progress capture (`0` if idle).", + "minimum": 0 + } + } + }, "StreamInfo": { "type": "object", "description": "RTSP-negotiated stream parameters.", @@ -1696,6 +2220,10 @@ { "name": "library", "description": "Game library: installed-store titles (Steam) plus user-curated custom entries" + }, + { + "name": "stats", + "description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing" } ] } diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 083dd5c..b820df2 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -125,12 +125,21 @@ pub struct AppState { /// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is /// sent, dropped + reopened when a session negotiates a different channel count. pub audio_cap: std::sync::Arc>>>, + /// Shared streaming-stats recorder (web-console capture/graph). The GameStream encode loop + /// reads `is_armed()` per frame and emits samples; the same `Arc` is shared with the mgmt API + /// and the native punktfunk/1 loops so one capture spans whichever path is streaming. + pub stats: Arc, } impl AppState { /// Fresh control-plane state: no active session; the pairing allow-list is loaded from - /// disk (pairings persist across restarts). - pub fn new(host: Host, identity: cert::ServerIdentity) -> AppState { + /// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the + /// mgmt API and the streaming loops. + pub fn new( + host: Host, + identity: cert::ServerIdentity, + stats: Arc, + ) -> AppState { AppState { host, identity, @@ -145,6 +154,7 @@ impl AppState { rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)), video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), + stats, } } } @@ -166,7 +176,10 @@ pub fn serve( ) -> Result<()> { let host = Host::detect()?; let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; - let state = Arc::new(AppState::new(host, identity)); + // The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop + // (via `AppState`), and the native punktfunk/1 loops (passed to `punktfunk1::serve`). + let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir()); + let state = Arc::new(AppState::new(host, identity, stats.clone())); // The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony // and the management API) always exists. let np = Arc::new( @@ -206,8 +219,8 @@ pub fn serve( ); tokio::try_join!( nvhttp::run(state.clone()), - crate::mgmt::run(state.clone(), mgmt, Some(np.clone())), - crate::punktfunk1::serve(native_opts, np), + crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), + crate::punktfunk1::serve(native_opts, np, stats.clone()), )?; } else { // Secure default: native punktfunk/1 + management API only (no GameStream surface). @@ -217,8 +230,8 @@ pub fn serve( (GameStream OFF — pass --gamestream for stock-Moonlight compat)" ); tokio::try_join!( - crate::mgmt::run(state.clone(), mgmt, Some(np.clone())), - crate::punktfunk1::serve(native_opts, np), + crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), + crate::punktfunk1::serve(native_opts, np, stats.clone()), )?; } Ok(()) diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 92bb92e..53df421 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -291,7 +291,10 @@ mod tests { https_port: HTTPS_PORT, }; let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity"); - Arc::new(AppState::new(host, identity)) + let stats = crate::stats_recorder::StatsRecorder::new( + std::env::temp_dir().join(format!("pf-nvhttp-stats-{}", std::process::id())), + ); + Arc::new(AppState::new(host, identity, stats)) } fn fp_of(der: &[u8]) -> String { diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 1376d0f..f2087b6 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -234,6 +234,7 @@ fn handle_request(req: &Request, state: &AppState) -> String { state.force_idr.clone(), state.rfi_range.clone(), state.video_cap.clone(), + state.stats.clone(), ); } Some(_) => tracing::info!("RTSP PLAY — stream already running"), diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 7340fe3..2b9d155 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -48,6 +48,7 @@ pub fn start( force_idr: Arc, rfi_range: RfiSlot, video_cap: CapturerSlot, + stats: Arc, ) { let _ = std::thread::Builder::new() .name("punktfunk-video".into()) @@ -60,6 +61,7 @@ pub fn start( &force_idr, &rfi_range, &video_cap, + &stats, ) { tracing::error!(error = %format!("{e:#}"), "video stream failed"); } @@ -68,6 +70,7 @@ pub fn start( }); } +#[allow(clippy::too_many_arguments)] fn run( cfg: StreamConfig, app: Option<&super::apps::AppEntry>, @@ -75,6 +78,9 @@ fn run( force_idr: &AtomicBool, rfi_range: &std::sync::Mutex>, video_cap: &std::sync::Mutex>>, + // Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the + // encode loop); per-frame sample emission is wired by a later pass. + stats: &Arc, ) -> Result<()> { // GameStream capture/encode thread: apply Windows session tuning (no-op off Windows). crate::session_tuning::on_hot_thread(); @@ -100,6 +106,8 @@ fn run( sock.connect(client) .context("connect client video endpoint")?; tracing::info!(%client, "video: client endpoint learned"); + // Short label for web-console stats captures: the client's peer IP. + let client_label = client.ip().to_string(); // Native client-resolution source: create a compositor virtual output sized to the client's // request and capture it (no scaling). Self-contained — deliberately NOT pooled in @@ -163,7 +171,16 @@ fn run( } } } - return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); + return stream_body( + &mut *capturer, + &sock, + cfg, + running, + force_idr, + rfi_range, + stats, + &client_label, + ); } // Reuse the persistent capturer (one screencast session → clean reconnect); create it on @@ -183,7 +200,16 @@ fn run( } }; capturer.set_active(true); - let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); + let result = stream_body( + &mut *capturer, + &sock, + cfg, + running, + force_idr, + rfi_range, + stats, + &client_label, + ); capturer.set_active(false); *video_cap.lock().unwrap() = Some(capturer); result @@ -326,8 +352,20 @@ fn spawn_sender( Ok(()) } +/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the web-console +/// stats sample's per-stage p50/p99. +fn percentile(v: &mut [u32], q: f64) -> u32 { + if v.is_empty() { + return 0; + } + v.sort_unstable(); + let i = ((v.len() as f64 * q) as usize).min(v.len() - 1); + v[i] +} + /// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread /// (see [`spawn_sender`]) so a send spike can never stall capture/encode. +#[allow(clippy::too_many_arguments)] fn stream_body( capturer: &mut dyn Capturer, sock: &UdpSocket, @@ -335,6 +373,11 @@ fn stream_body( running: &Arc, force_idr: &AtomicBool, rfi_range: &std::sync::Mutex>, + // Shared stats recorder. The encode loop reads `stats.is_armed()` per frame to decide whether + // to accumulate the per-stage split, then emits a `StatsSample` at its 1 s aggregation boundary. + stats: &Arc, + // Short client label (peer IP) seeded into the capture meta on the first armed registration. + client_label: &str, ) -> Result<()> { // The first frame establishes the authoritative size/format for the encoder. let mut frame = capturer.next_frame().context("capture first frame")?; @@ -398,6 +441,19 @@ fn stream_body( let perf = crate::config::config().perf; let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) = (0u128, 0u128, 0u128, 0u128, 0usize, 0u32); + // Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors + // for p50/p99, the goodput bytes queued to the sender this window, the previous window's + // dropped-frame count for delta computation, and the registration id cached on the first sample. + let codec_name = match cfg.codec { + Codec::H264 => "h264", + Codec::H265 => "hevc", + Codec::Av1 => "av1", + }; + let mut sid: Option = None; + let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec, Vec, Vec, Vec) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut bytes_win: u64 = 0; + let mut last_dropped_batches: u64 = 0; // Absolute next-frame deadline — the single pacing clock for the loop. let mut next_frame = Instant::now(); // RFI capability is fixed for the session (probed at encoder open). Query it once so the @@ -407,6 +463,9 @@ fn stream_body( while running.load(Ordering::SeqCst) { let tick = Instant::now(); + // Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is + // armed (cheap Relaxed atomic, re-read each frame). + let measure = perf || stats.is_armed(); // Advance to the freshest captured frame if one arrived; otherwise reuse the last. if let Some(f) = capturer.try_latest().context("capture frame")? { frame = f; @@ -447,9 +506,19 @@ fn stream_body( // Hand the frame's packets to the send thread; never block here. A full queue means // the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding. let n = batch.len(); + // Goodput this window = bytes actually queued to the sender (a dropped batch never reaches + // the wire, so it's excluded). Summed only when measuring, to keep the idle path free. + let batch_bytes: u64 = if measure { + batch.iter().map(|p| p.len() as u64).sum() + } else { + 0 + }; if n > 0 { match batch_tx.try_send(batch) { - Ok(()) => sent_batches += 1, + Ok(()) => { + sent_batches += 1; + bytes_win += batch_bytes; + } Err(std::sync::mpsc::TrySendError::Full(_)) => { dropped_batches += 1; if dropped_batches.is_power_of_two() { @@ -461,17 +530,26 @@ fn stream_body( } } } - if perf { + if measure { let t_send = tick.elapsed(); - mx_cap = mx_cap.max(t_cap.as_micros()); - mx_enc = mx_enc.max((t_enc - t_cap).as_micros()); - mx_pkt = mx_pkt.max((t_pkt - t_enc).as_micros()); - mx_send = mx_send.max((t_send - t_pkt).as_micros()); + let cap_us = t_cap.as_micros(); + let enc_us = (t_enc - t_cap).as_micros(); + let pkt_us = (t_pkt - t_enc).as_micros(); + let send_us = (t_send - t_pkt).as_micros(); + mx_cap = mx_cap.max(cap_us); + mx_enc = mx_enc.max(enc_us); + mx_pkt = mx_pkt.max(pkt_us); + mx_send = mx_send.max(send_us); mx_pkts = mx_pkts.max(n); + v_cap.push(cap_us as u32); + v_enc.push(enc_us as u32); + v_pkt.push(pkt_us as u32); + v_send.push(send_us as u32); } fps_count += 1; if fps_t.elapsed() >= Duration::from_secs(1) { + let secs = fps_t.elapsed().as_secs_f64(); if perf { // Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device // copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new @@ -486,12 +564,6 @@ fn stream_body( max_pkts = mx_pkts, "video: streaming (perf)" ); - mx_cap = 0; - mx_enc = 0; - mx_pkt = 0; - mx_send = 0; - mx_pkts = 0; - uniq = 0; } else { tracing::info!( fps = fps_count, @@ -500,6 +572,68 @@ fn stream_body( "video: streaming" ); } + // Web-console capture: build the aggregated sample. The host send side exposes no + // receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay + // 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta. + if stats.is_armed() { + let session_id = *sid.get_or_insert_with(|| { + stats.register_session( + "gamestream", + cfg.width, + cfg.height, + cfg.fps, + codec_name, + client_label, + ) + }); + let sample = crate::stats_recorder::StatsSample { + t_ms: 0, // stamped by push_sample from the capture's monotonic start + session_id, + stages: vec![ + crate::stats_recorder::StageTiming { + name: "capture".into(), + p50_us: percentile(&mut v_cap, 0.50) as f32, + p99_us: percentile(&mut v_cap, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "encode".into(), + p50_us: percentile(&mut v_enc, 0.50) as f32, + p99_us: percentile(&mut v_enc, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "packetize".into(), + p50_us: percentile(&mut v_pkt, 0.50) as f32, + p99_us: percentile(&mut v_pkt, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "send".into(), + p50_us: percentile(&mut v_send, 0.50) as f32, + p99_us: percentile(&mut v_send, 0.99) as f32, + }, + ], + fps: (uniq as f64 / secs) as f32, + repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32, + mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32, + bitrate_kbps: cfg.bitrate_kbps, + frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32, + packets_dropped: 0, + send_dropped: 0, + fec_recovered: 0, + }; + stats.push_sample(session_id, sample); + } + mx_cap = 0; + mx_enc = 0; + mx_pkt = 0; + mx_send = 0; + mx_pkts = 0; + uniq = 0; + v_cap.clear(); + v_enc.clear(); + v_pkt.clear(); + v_send.clear(); + bytes_win = 0; + last_dropped_batches = dropped_batches; fps_count = 0; fps_t = Instant::now(); } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index bf9643d..159180a 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -50,6 +50,7 @@ mod service; mod session_plan; mod session_tuning; mod spike; +mod stats_recorder; mod vdisplay; #[cfg(target_os = "windows")] #[path = "windows/wgc_helper.rs"] diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 09f1a6b..b13e6a9 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -20,6 +20,7 @@ use crate::gamestream::{ tls::{serve_https, PeerCertFingerprint}, AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, }; +use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus}; use anyhow::{Context, Result}; use axum::{ extract::{Path, Request, State}, @@ -66,6 +67,9 @@ struct MgmtState { /// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native` /// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`). native: Option>, + /// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an + /// operator can arm/stop a capture here and review/list/delete saved recordings. + stats: Arc, token: Option, /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. port: u16, @@ -77,6 +81,7 @@ pub async fn run( state: Arc, opts: Options, native: Option>, + stats: Arc, ) -> Result<()> { // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve` // guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated). @@ -100,7 +105,7 @@ pub async fn run( auth = "mTLS (paired cert) or bearer (required)", "management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)" ); - let app = app(state, Some(token), opts.bind.port(), native); + let app = app(state, Some(token), opts.bind.port(), native, stats); serve_https(opts.bind, app, tls).await } @@ -110,10 +115,12 @@ fn app( token: Option, port: u16, native: Option>, + stats: Arc, ) -> Router { let shared = Arc::new(MgmtState { app: state, native, + stats, token, port, }); @@ -158,7 +165,13 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(request_idr)) .routes(routes!(get_library)) .routes(routes!(create_custom_game)) - .routes(routes!(update_custom_game, delete_custom_game)), + .routes(routes!(update_custom_game, delete_custom_game)) + .routes(routes!(stats_capture_start)) + .routes(routes!(stats_capture_stop)) + .routes(routes!(stats_capture_status)) + .routes(routes!(stats_capture_live)) + .routes(routes!(stats_recordings_list)) + .routes(routes!(stats_recording_get, stats_recording_delete)), ) .split_for_parts() } @@ -190,6 +203,7 @@ pub fn openapi_json() -> String { (name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"), (name = "session", description = "Active streaming session control"), (name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"), + (name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"), ) )] struct ApiDoc; @@ -1218,6 +1232,185 @@ async fn delete_custom_game(Path(id): Path) -> Response { } } +// --------------------------------------------------------------------------------------- +// Streaming stats capture (design/stats-capture-plan.md §2) +// --------------------------------------------------------------------------------------- + +/// Start a stats capture +/// +/// Arms a new performance-stats capture. Idempotent: if a capture is already running this returns +/// the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every +/// 1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`. +#[utoipa::path( + post, + path = "/stats/capture/start", + tag = "stats", + operation_id = "statsCaptureStart", + responses( + (status = OK, description = "Capture armed (or already running)", body = StatsStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stats_capture_start(State(st): State>) -> Json { + let status = st.stats.start(); + tracing::info!( + started_unix_ms = status.started_unix_ms, + "management API: stats capture armed" + ); + Json(status) +} + +/// Stop the stats capture +/// +/// Disarms the in-progress capture and writes it to disk atomically, returning its summary. If +/// nothing was recording, returns `204 No Content`. +#[utoipa::path( + post, + path = "/stats/capture/stop", + tag = "stats", + operation_id = "statsCaptureStop", + responses( + (status = OK, description = "Capture stopped and saved", body = CaptureMeta), + (status = NO_CONTENT, description = "Nothing was recording"), + (status = INTERNAL_SERVER_ERROR, description = "Could not write the recording to disk", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stats_capture_stop(State(st): State>) -> Response { + match st.stats.stop() { + Ok(Some(meta)) => { + tracing::info!(id = %meta.id, samples = meta.sample_count, "management API: stats capture saved"); + (StatusCode::OK, Json(meta)).into_response() + } + Ok(None) => StatusCode::NO_CONTENT.into_response(), + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not save capture: {e}"), + ), + } +} + +/// Stats capture status +/// +/// Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to +/// drive the capture-control UI. +#[utoipa::path( + get, + path = "/stats/capture/status", + tag = "stats", + operation_id = "statsCaptureStatus", + responses( + (status = OK, description = "In-progress capture status (idle when not armed)", body = StatsStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stats_capture_status(State(st): State>) -> Json { + Json(st.stats.status()) +} + +/// Live in-progress capture +/// +/// The full sample time-series of the capture currently recording, for live graphing. `404` when +/// nothing is armed. +#[utoipa::path( + get, + path = "/stats/capture/live", + tag = "stats", + operation_id = "statsCaptureLive", + responses( + (status = OK, description = "The in-progress capture (meta + samples so far)", body = Capture), + (status = NOT_FOUND, description = "No capture is currently recording", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stats_capture_live(State(st): State>) -> Response { + match st.stats.live_snapshot() { + Some(capture) => Json(capture).into_response(), + None => api_error(StatusCode::NOT_FOUND, "no capture is currently recording"), + } +} + +/// List saved recordings +/// +/// Every saved capture's summary (the `meta` head only — not the sample body), newest first. +#[utoipa::path( + get, + path = "/stats/recordings", + tag = "stats", + operation_id = "statsRecordingsList", + responses( + (status = OK, description = "Saved capture summaries, newest first", body = [CaptureMeta]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stats_recordings_list(State(st): State>) -> Json> { + Json(st.stats.list()) +} + +/// Get a saved recording +/// +/// The full capture (meta + samples) for `id`, for graphing or download. +#[utoipa::path( + get, + path = "/stats/recordings/{id}", + tag = "stats", + operation_id = "statsRecordingGet", + params(("id" = String, Path, description = "The recording id (its filename stem)")), + responses( + (status = OK, description = "The full capture", body = Capture), + (status = NOT_FOUND, description = "No recording with that id", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "The recording file is unreadable", body = ApiError), + ) +)] +async fn stats_recording_get(State(st): State>, Path(id): Path) -> Response { + match st.stats.load(&id) { + Ok(capture) => Json(capture).into_response(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + api_error(StatusCode::NOT_FOUND, "no recording with that id") + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not read recording: {e}"), + ), + } +} + +/// Delete a saved recording +/// +/// Removes the recording `id` from disk. `404` if there is no such recording. +#[utoipa::path( + delete, + path = "/stats/recordings/{id}", + tag = "stats", + operation_id = "statsRecordingDelete", + params(("id" = String, Path, description = "The recording id (its filename stem)")), + responses( + (status = NO_CONTENT, description = "Recording deleted"), + (status = NOT_FOUND, description = "No recording with that id", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not delete the recording", body = ApiError), + ) +)] +async fn stats_recording_delete( + State(st): State>, + Path(id): Path, +) -> Response { + match st.stats.delete(&id) { + Ok(()) => { + tracing::info!(id, "management API: recording deleted"); + StatusCode::NO_CONTENT.into_response() + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + api_error(StatusCode::NOT_FOUND, "no recording with that id") + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not delete recording: {e}"), + ), + } +} + // --------------------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------------------- @@ -1231,6 +1424,15 @@ mod tests { use std::net::{IpAddr, Ipv4Addr}; use tower::ServiceExt; + /// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir). + fn test_stats() -> Arc { + crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!( + "pf-mgmt-stats-{}-{:p}", + std::process::id(), + &0u8 as *const u8 + ))) + } + fn test_state() -> Arc { let host = Host { hostname: "test-host".into(), @@ -1240,18 +1442,20 @@ mod tests { https_port: HTTPS_PORT, }; let identity = ServerIdentity::ephemeral().expect("ephemeral identity"); - Arc::new(AppState::new(host, identity)) + Arc::new(AppState::new(host, identity, test_stats())) } // The mgmt API now always requires auth, so the router always has a token. A test that passes // `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test // that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`). fn test_app(state: Arc, token: Option<&str>) -> Router { + let stats = state.stats.clone(); app( state, Some(token.unwrap_or("test-secret").to_string()), DEFAULT_PORT, None, + stats, ) } @@ -1261,11 +1465,13 @@ mod tests { ) -> Router { // Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the // rest authenticate via the `send`-attached default bearer. + let stats = state.stats.clone(); app( state, Some("test-secret".to_string()), DEFAULT_PORT, Some(np), + stats, ) } @@ -1580,7 +1786,9 @@ mod tests { bind: "127.0.0.1:0".parse().unwrap(), token: Some(" ".into()), }; - let err = run(test_state(), opts, None).await.unwrap_err(); + let err = run(test_state(), opts, None, test_stats()) + .await + .unwrap_err(); assert!(err.to_string().contains("no token"), "{err}"); } diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 65c065a..debe451 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -79,6 +79,9 @@ pub struct Punktfunk1Options { /// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. use crate::native_pairing::NativePairing; +/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API +/// and the GameStream loop; threaded into each session's `SessionContext`. +use crate::stats_recorder::StatsRecorder; /// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with /// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate). @@ -114,7 +117,11 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> { opts.pairing_pin.clone(), opts.allow_pairing || opts.require_pairing, )?); - rt.block_on(serve(opts, np)) + // Standalone `punktfunk1-host` has no mgmt API to arm capture, so this recorder stays disarmed + // (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one + // recorder across mgmt + both streaming paths instead. + let stats = StatsRecorder::new(crate::stats_recorder::default_dir()); + rt.block_on(serve(opts, np, stats)) } fn fingerprint_hex(fp: &[u8; 32]) -> String { @@ -157,7 +164,11 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { } } -pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc) -> Result<()> { +pub(crate) async fn serve( + opts: Punktfunk1Options, + np: Arc, + stats: Arc, +) -> Result<()> { let identity = crate::gamestream::cert::ServerIdentity::load_or_create() .context("load host identity (~/.config/punktfunk)")?; let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) @@ -276,6 +287,7 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc) -> Re let audio_cap = audio_cap.clone(); let np = np.clone(); let last_pairing = last_pairing.clone(); + let stats = stats.clone(); let inj_tx = injector.sender(); let mic_tx = mic_service.sender(); sessions.spawn(async move { @@ -289,6 +301,7 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc) -> Re &fingerprint, &np, &last_pairing, + stats, ) .await { @@ -479,6 +492,7 @@ async fn serve_session( host_fp: &[u8; 32], np: &NativePairing, last_pairing: &std::sync::Mutex>, + stats: Arc, ) -> Result<()> { let peer = conn.remote_address(); @@ -935,6 +949,12 @@ async fn serve_session( let stop_stream = stop.clone(); let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream + let stats_dp = stats; // data-plane handle to the shared stats recorder + // Short label for web-console stats captures: the client's cert-fingerprint prefix, else its + // peer IP (no fingerprint = anonymous TOFU/--open client). + let client_label = endpoint::peer_fingerprint(&conn) + .map(|fp| fingerprint_hex(&fp)[..12].to_string()) + .unwrap_or_else(|| conn.remote_address().ip().to_string()); let result: Result<()> = async { tokio::task::spawn_blocking(move || -> Result<()> { // Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED @@ -989,6 +1009,8 @@ async fn serve_session( probe_result_tx, fec_target: fec_target_dp, conn: conn_stream, + stats: stats_dp, + client_label, #[cfg(target_os = "windows")] launch: launch_for_dp, }) @@ -1947,6 +1969,21 @@ struct FrameMsg { deadline: std::time::Instant, /// capture→encoded latency (µs), measured on the encode thread, carried for the perf histogram. encode_us: u32, + /// Per-stage µs splits, measured on the capture/encode thread (0 when neither `PUNKTFUNK_PERF` + /// nor a stats capture is armed). The send thread accumulates them for the web-console sample: + /// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture` + /// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage). + cap_us: u32, + submit_us: u32, + wait_us: u32, + /// This frame is a re-encoded hold (the source had no fresh frame): a source-starvation signal + /// the send thread folds into `repeat_fps`. + repeat: bool, + /// Whether the per-stage splits (`cap_us`/`submit_us`/`wait_us`) were actually measured at + /// capture time (`perf` was on or a stats capture was armed). The send thread trusts this + /// instead of re-reading `is_armed()`, so a capture that arms while frames are already in flight + /// doesn't fold their zeroed splits into the first window's percentiles. + was_measured: bool, } /// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are @@ -2020,6 +2057,19 @@ pub(crate) fn boost_thread_priority(critical: bool) { } } +/// Everything the send thread needs to emit web-console stats samples at its 2 s aggregation +/// boundary: the shared recorder (whose `is_armed()` gates emission) plus the negotiated +/// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration. +struct SendStats { + rec: Arc, + width: u32, + height: u32, + fps: u32, + codec: &'static str, + client: String, + bitrate_kbps: u32, +} + #[allow(clippy::too_many_arguments)] fn send_loop( mut session: Session, @@ -2030,6 +2080,7 @@ fn send_loop( perf: bool, burst_cap: usize, fec_target: Arc, + stats: SendStats, ) { boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level) let mut last_perf = std::time::Instant::now(); @@ -2038,6 +2089,16 @@ fn send_loop( let mut encode_us: Vec = Vec::new(); let mut pace_us: Vec = Vec::new(); let (mut paced_frames, mut immediate_frames) = (0u64, 0u64); + // Web-console stats accumulation (active when `perf` OR the recorder is armed): the per-stage + // split carried on each FrameMsg, the new-vs-repeat frame split, the cached registration id, and + // the previous window's loss snapshot for delta computation. + let mut sid: Option = None; + let (mut cap_v, mut submit_v, mut wait_v): (Vec, Vec, Vec) = + (Vec::new(), Vec::new(), Vec::new()); + let (mut new_frames, mut repeat_frames) = (0u64, 0u64); + let mut last_frames_dropped = 0u64; + let mut last_packets_dropped = 0u64; + let mut last_fec_recovered = 0u64; loop { if stop.load(Ordering::SeqCst) { break; @@ -2058,9 +2119,24 @@ fn send_loop( burst_cap, ) { Ok(stat) => { - if perf { + if perf || stats.rec.is_armed() { + // `encode_us`/`pace_us`/fps are valid for every frame (always measured), + // including the Windows relay + tail-drain frames. The cap/submit/wait splits + // are only real when the frame was measured at capture time — a frame captured + // before this capture armed carries zeroed splits, so skip those (an empty + // window → `percentile()` returns 0) rather than pull the percentiles down. encode_us.push(msg.encode_us); pace_us.push(stat.spread_us); + if msg.was_measured { + cap_v.push(msg.cap_us); + submit_v.push(msg.submit_us); + wait_v.push(msg.wait_us); + } + if msg.repeat { + repeat_frames += 1; + } else { + new_frames += 1; + } if stat.paced { paced_frames += 1; } else { @@ -2076,31 +2152,91 @@ fn send_loop( Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done } - if perf && last_perf.elapsed() >= std::time::Duration::from_secs(2) { + if last_perf.elapsed() >= std::time::Duration::from_secs(2) { let s = session.stats(); let secs = last_perf.elapsed().as_secs_f64(); // Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire. let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0; - tracing::info!( - tx_mbps = format!("{tx_mbps:.0}"), - send_dropped = s.packets_send_dropped - last_send_dropped, - send_dropped_total = s.packets_send_dropped, - encode_us_p50 = percentile(&mut encode_us, 0.50), - encode_us_p99 = percentile(&mut encode_us, 0.99), - pace_us_p50 = percentile(&mut pace_us, 0.50), - pace_us_p99 = percentile(&mut pace_us, 0.99), - pace_us_max = pace_us.last().copied().unwrap_or(0), - immediate_frames, - paced_frames, - "perf" - ); + if perf { + tracing::info!( + tx_mbps = format!("{tx_mbps:.0}"), + send_dropped = s.packets_send_dropped - last_send_dropped, + send_dropped_total = s.packets_send_dropped, + encode_us_p50 = percentile(&mut encode_us, 0.50), + encode_us_p99 = percentile(&mut encode_us, 0.99), + pace_us_p50 = percentile(&mut pace_us, 0.50), + pace_us_p99 = percentile(&mut pace_us, 0.99), + pace_us_max = pace_us.last().copied().unwrap_or(0), + immediate_frames, + paced_frames, + "perf" + ); + } + // Web-console capture: this thread owns `session.stats()`, so it emits the COMPLETE + // sample — the cap/submit/encode split carried over from the capture thread plus this + // window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot. + if stats.rec.is_armed() { + let session_id = *sid.get_or_insert_with(|| { + stats.rec.register_session( + "native", + stats.width, + stats.height, + stats.fps, + stats.codec, + &stats.client, + ) + }); + let sample = crate::stats_recorder::StatsSample { + t_ms: 0, // stamped by push_sample from the capture's monotonic start + session_id, + stages: vec![ + crate::stats_recorder::StageTiming { + name: "capture".into(), + p50_us: percentile(&mut cap_v, 0.50) as f32, + p99_us: percentile(&mut cap_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "submit".into(), + p50_us: percentile(&mut submit_v, 0.50) as f32, + p99_us: percentile(&mut submit_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "encode".into(), + p50_us: percentile(&mut wait_v, 0.50) as f32, + p99_us: percentile(&mut wait_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "send".into(), + p50_us: percentile(&mut pace_us, 0.50) as f32, + p99_us: percentile(&mut pace_us, 0.99) as f32, + }, + ], + fps: (new_frames as f64 / secs) as f32, + repeat_fps: (repeat_frames as f64 / secs) as f32, + mbps: tx_mbps as f32, + bitrate_kbps: stats.bitrate_kbps, + frames_dropped: s.frames_dropped.saturating_sub(last_frames_dropped) as u32, + packets_dropped: s.packets_dropped.saturating_sub(last_packets_dropped) as u32, + send_dropped: s.packets_send_dropped.saturating_sub(last_send_dropped) as u32, + fec_recovered: s.fec_recovered_shards.saturating_sub(last_fec_recovered) as u32, + }; + stats.rec.push_sample(session_id, sample); + } last_perf = std::time::Instant::now(); last_bytes = s.bytes_sent; last_send_dropped = s.packets_send_dropped; + last_frames_dropped = s.frames_dropped; + last_packets_dropped = s.packets_dropped; + last_fec_recovered = s.fec_recovered_shards; encode_us.clear(); pace_us.clear(); + cap_v.clear(); + submit_v.clear(); + wait_v.clear(); paced_frames = 0; immediate_frames = 0; + new_frames = 0; + repeat_frames = 0; } } } @@ -2201,6 +2337,13 @@ struct SessionContext { fec_target: Arc, /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). conn: quinn::Connection, + /// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide + /// whether to measure the per-stage split; the send thread builds + pushes the aggregated + /// `StatsSample` at its 2 s boundary. + stats: Arc, + /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on + /// the first armed stats registration. + client_label: String, /// Windows: the store-qualified library id to launch into the interactive user session once /// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the /// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only. @@ -2242,6 +2385,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { probe_result_tx, fec_target, conn, + stats, + client_label, #[cfg(target_os = "windows")] launch, } = ctx; @@ -2310,6 +2455,17 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // The bounded channel applies backpressure (the encode thread blocks if the send falls behind, // so frames slow down rather than a dropped frame freezing the infinite-GOP stream). let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(3); + // The send thread emits the web-console stats sample (it owns `session.stats()`); clone the + // recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate. + let send_stats = SendStats { + rec: stats.clone(), + width: mode.width, + height: mode.height, + fps: mode.refresh_hz, + codec: "hevc", + client: client_label, + bitrate_kbps, + }; let send_thread = std::thread::Builder::new() .name("punktfunk-send".into()) .spawn({ @@ -2324,6 +2480,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { perf, burst_cap, fec_target, + send_stats, ) } }) @@ -2480,18 +2637,31 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { tracing::debug!("forcing keyframe (client decode recovery)"); enc.request_keyframe(); } + // Measure the per-stage split when `PUNKTFUNK_PERF` is set OR a web-console stats capture is + // armed (a cheap Relaxed atomic, re-read each frame). The values feed the existing perf log + // unchanged and ride each FrameMsg to the send thread, which builds the aggregated sample. + let measure = perf || stats.is_armed(); let t_cap = std::time::Instant::now(); let cap_result = capturer.try_latest(); + let cap_us = if measure { + t_cap.elapsed().as_micros() as u32 + } else { + 0 + }; if perf { - st_cap.push(t_cap.elapsed().as_micros() as u32); + st_cap.push(cap_us); } + let mut repeat = false; match cap_result { Ok(Some(f)) => { frame = f; diag_new += 1; capture_rebuilds = 0; // a delivered frame clears the consecutive-loss counter } - Ok(None) => diag_repeat += 1, // no new frame (static desktop / mid-rebuild) — repeat the last + Ok(None) => { + diag_repeat += 1; // no new frame (static desktop / mid-rebuild) — repeat the last + repeat = true; + } // The capture source died (PipeWire/compositor thread ended, virtual output gone). Rather // than tear the whole session down — the client has no reconnect path and would have to // cold-restart the handshake — rebuild the pipeline IN PLACE at the current mode, exactly @@ -2558,8 +2728,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { let capture_ns = now_ns(); let t_submit = std::time::Instant::now(); enc.submit(&frame).context("encoder submit")?; + let submit_us = if measure { + t_submit.elapsed().as_micros() as u32 + } else { + 0 + }; if perf { - st_submit.push(t_submit.elapsed().as_micros() as u32); + st_submit.push(submit_us); } // This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame // up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled. @@ -2573,8 +2748,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { while inflight.len() >= depth { let t_wait = std::time::Instant::now(); let polled = enc.poll().context("encoder poll")?; + let wait_us = if measure { + t_wait.elapsed().as_micros() as u32 + } else { + 0 + }; if perf { - st_wait.push(t_wait.elapsed().as_micros() as u32); + st_wait.push(wait_us); } let au = match polled { Some(au) => au, @@ -2602,6 +2782,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { flags, deadline, encode_us, + cap_us, + submit_us, + wait_us, + repeat, + was_measured: measure, }; // Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it // exited (send failure / stop) — end the encode loop too. @@ -2629,12 +2814,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { FLAG_PIC as u32 }; let encode_us = (now_ns().saturating_sub(cap_ns) / 1000) as u32; + // End-of-stream tail drain: the per-stage split isn't measured here (the capture loop has + // exited), so leave it zero — these last few frames are negligible for the aggregates. let msg = FrameMsg { data: au.data, capture_ns: cap_ns, flags, deadline, encode_us, + cap_us: 0, + submit_us: 0, + wait_us: 0, + repeat: false, + was_measured: false, }; if frame_tx.send(msg).is_err() { break; @@ -2681,6 +2873,8 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> { probe_result_tx, fec_target, conn: _conn, + stats, + client_label, launch, } = ctx; tracing::info!( @@ -2815,7 +3009,18 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> { * 1024; // Same encode|send split as the single-process path: this thread relays AUs, a dedicated send - // thread owns the Session and does FEC+seal+paced-send. + // thread owns the Session and does FEC+seal+paced-send. The relay encodes in the helper process, + // so this path's FrameMsgs carry no cap/submit/encode split (those stages stay 0 in the sample); + // the send thread still emits fps/goodput/pacing/loss from `session.stats()`. + let send_stats = SendStats { + rec: stats, + width: mode.width, + height: mode.height, + fps: effective_hz, + codec: "hevc", + client: client_label, + bitrate_kbps, + }; let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(3); let send_thread = std::thread::Builder::new() .name("punktfunk-send".into()) @@ -2831,6 +3036,7 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> { perf, burst_cap, fec_target, + send_stats, ) } }) @@ -2893,6 +3099,11 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> { flags, deadline: std::time::Instant::now() + interval, encode_us, + cap_us: 0, + submit_us: 0, + wait_us: 0, + repeat: false, + was_measured: false, }; let ok = frame_tx.send(msg).is_ok(); if ok { @@ -3645,6 +3856,9 @@ mod tests { paired_store: None, // unused: the shared `np` IS the store handle }, np_host, + StatsRecorder::new( + std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())), + ), )) }); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/crates/punktfunk-host/src/stats_recorder.rs b/crates/punktfunk-host/src/stats_recorder.rs new file mode 100644 index 0000000..5c7cd67 --- /dev/null +++ b/crates/punktfunk-host/src/stats_recorder.rs @@ -0,0 +1,553 @@ +//! Shared streaming-stats recorder (`design/stats-capture-plan.md` §1). One +//! [`StatsRecorder`] handle is created once in the unified host entry +//! (`gamestream::serve`) alongside [`crate::native_pairing::NativePairing`], and shared with +//! **both** the management API ([`crate::mgmt`]) and the streaming loops (threaded through +//! [`crate::punktfunk1::serve`] → `SessionContext` and into the GameStream encode loop). The +//! operator arms a capture from the web console, plays a session, stops, and reviews the +//! captured time-series as graphs; captures are saved to disk and survive a host restart. +//! +//! Hot-path discipline: [`StatsRecorder::is_armed`] is a cheap `Relaxed` atomic load (re-read +//! per frame); sample construction happens only at the loops' existing ~2 s / ~1 s aggregation +//! boundary, never per frame. Memory is bounded ([`MAX_SAMPLES`]); the on-disk write is atomic +//! (temp + rename); and capture ids are path-traversal-safe. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use utoipa::ToSchema; + +/// Cap on samples kept in one capture: ≈ 3 h at one sample / 2 s. On overflow we stop appending +/// (keeping the oldest — a saved recording must keep its start), never dropping the front and never +/// growing unbounded. +const MAX_SAMPLES: usize = 5400; + +/// One pipeline stage's latency in an aggregation window (microseconds). +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct StageTiming { + /// `"capture" | "submit" | "encode" | "packetize" | "send"` (path-dependent). + pub name: String, + pub p50_us: f32, + pub p99_us: f32, +} + +/// One aggregated sample (~ every 2 s native, ~ every 1 s GameStream). +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct StatsSample { + /// Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]). + pub t_ms: u64, + /// Disambiguates concurrent sessions (usually constant). + pub session_id: u32, + /// Ordered pipeline stages for this path. + pub stages: Vec, + /// Genuine NEW frames/s from the source. + pub fps: f32, + /// Re-encoded holds/s (source-starvation indicator). + pub repeat_fps: f32, + /// Transmit goodput (Mb/s). + pub mbps: f32, + /// Configured target bitrate. + pub bitrate_kbps: u32, + /// Frames dropped this window (delta). + pub frames_dropped: u32, + /// Packets dropped this window (receiver-side / reassembler, where known). + pub packets_dropped: u32, + /// Host send-buffer overflow / EAGAIN this window (delta). + pub send_dropped: u32, + /// FEC shards recovered this window (delta). + pub fec_recovered: u32, +} + +/// Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head +/// of each on-disk recording and listed standalone (without the sample body) by +/// [`StatsRecorder::list`]. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct CaptureMeta { + /// e.g. `"2026-06-26T20-14-03Z_5120x1440"` — also the filename stem. + pub id: String, + pub started_unix_ms: u64, + pub duration_ms: u64, + /// `"native" | "gamestream"`. + pub kind: String, + pub width: u32, + pub height: u32, + pub fps: u32, + /// `"h264" | "hevc" | "av1"`. + pub codec: String, + /// Short label / fingerprint prefix, or `""` if unknown. + pub client: String, + pub sample_count: u32, +} + +/// A full capture: summary + the sample time-series. The wire + on-disk shape. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct Capture { + pub meta: CaptureMeta, + pub samples: Vec, +} + +/// Snapshot of the in-progress capture for the management API. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct StatsStatus { + /// Capture currently running. + pub armed: bool, + /// Samples in the in-progress capture. + pub sample_count: u32, + /// Unix start time of the in-progress capture (`0` if idle). + pub started_unix_ms: u64, + /// Path of the in-progress capture (`""` if idle). + pub kind: String, +} + +/// Mode/codec/client seeded on the first [`StatsRecorder::register_session`] of a capture. +#[derive(Clone)] +struct MetaSeed { + kind: String, + width: u32, + height: u32, + fps: u32, + codec: String, + client: String, +} + +/// The in-progress capture (present iff armed). +struct Live { + /// Monotonic clock origin for sample `t_ms`. + started: Instant, + started_unix_ms: u64, + /// Seeded once, on the first session registration. + meta: Option, + samples: Vec, + /// Set once the sample cap was hit (further samples dropped). Read so it isn't dead. + truncated: bool, +} + +/// Shared streaming-stats recorder: an arm/disarm flag (the hot-path gate), the in-progress +/// capture, and the on-disk capture directory. +pub struct StatsRecorder { + dir: PathBuf, + /// The hot-path gate — a `Relaxed` load per frame; never blocks the frame thread. + armed: AtomicBool, + /// The in-progress capture. Locks recover a poisoned guard (`unwrap_or_else(|e| e.into_inner())`, + /// as in `vdisplay::gamescope`) rather than `unwrap()`: a panic somewhere must never make stats + /// recording crash an otherwise-healthy stream. The critical sections only push/clone/format, so + /// poisoning is near-impossible anyway — this is belt-and-suspenders. + live: Mutex>, + next_sid: AtomicU32, +} + +/// The default captures directory: `~/.config/punktfunk/captures/` (next to `cert.pem`), +/// resolved via the same config-dir helper the rest of the host uses. +pub fn default_dir() -> PathBuf { + crate::gamestream::config_dir().join("captures") +} + +/// `id` charset gate, matching `^[A-Za-z0-9._-]+$` — the exact charset `capture_id` emits (which +/// deliberately uses dashes, not colons, so the stem is a valid Windows filename). We additionally +/// reject `.`/`..` so a path-component sneaks no parent reference even though the charset would allow +/// bare dots. The charset already excludes `/` and `\`, so `dir.join(".json")` is always a single +/// child of `dir`. Defense in depth — the endpoints are bearer-authed. +fn valid_id(id: &str) -> bool { + !id.is_empty() + && id != "." + && id != ".." + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +fn unix_ms_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// A human-readable, filesystem-safe capture id from the start time + mode, e.g. +/// `2026-06-26T20-14-03Z_5120x1440`. Dashes (not colons) in the time so it's a valid Windows +/// filename; matches [`valid_id`]. +fn capture_id(unix_ms: u64, width: u32, height: u32) -> String { + let secs = (unix_ms / 1000) as i64; + let days = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + let (y, mo, d) = civil_from_days(days); + let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); + format!("{y:04}-{mo:02}-{d:02}T{h:02}-{mi:02}-{s:02}Z_{width}x{height}") +} + +/// Civil (Y, M, D) from a count of days since the Unix epoch (Howard Hinnant's `civil_from_days`). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + (if m <= 2 { y + 1 } else { y }, m as u32, d) +} + +impl StatsRecorder { + /// Create the recorder, creating `dir` (owner-private, best-effort) if missing. + pub fn new(dir: PathBuf) -> Arc { + if let Err(e) = crate::gamestream::create_private_dir(&dir) { + tracing::warn!(dir = %dir.display(), error = %e, "could not create stats captures dir"); + } + Arc::new(StatsRecorder { + dir, + armed: AtomicBool::new(false), + live: Mutex::new(None), + next_sid: AtomicU32::new(0), + }) + } + + /// The hot-path gate: cheap `Relaxed` load, called per frame to decide whether to measure. + pub fn is_armed(&self) -> bool { + self.armed.load(Ordering::Relaxed) + } + + /// Arm a new capture. No-op if already armed (returns the current status). + pub fn start(&self) -> StatsStatus { + let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_none() { + *guard = Some(Live { + started: Instant::now(), + started_unix_ms: unix_ms_now(), + meta: None, + samples: Vec::new(), + truncated: false, + }); + // Publish AFTER the live capture exists, so a frame thread that observes `armed` always + // finds a capture to push into. + self.armed.store(true, Ordering::Relaxed); + } + status_of(guard.as_ref()) + } + + /// A streaming loop announces itself when it first records while armed. Seeds the capture's + /// `CaptureMeta` (kind/w/h/fps/codec/client) on the FIRST registration; returns a session id + /// to stamp on the loop's samples. + pub fn register_session( + &self, + kind: &'static str, + w: u32, + h: u32, + fps: u32, + codec: &str, + client: &str, + ) -> u32 { + let sid = self.next_sid.fetch_add(1, Ordering::Relaxed); + let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(live) = guard.as_mut() { + if live.meta.is_none() { + live.meta = Some(MetaSeed { + kind: kind.to_string(), + width: w, + height: h, + fps, + codec: codec.to_string(), + client: client.to_string(), + }); + } + } + sid + } + + /// Append one aggregated sample (called from the loops' existing ~2 s / ~1 s boundary). The + /// `t_ms` is (re)stamped here from the capture's monotonic start, so callers may leave it `0`. + /// Bounded at [`MAX_SAMPLES`]: on overflow we stop appending (oldest kept) and flag truncation. + /// A no-op when nothing is armed (e.g. a `stop()` raced the frame boundary). + pub fn push_sample(&self, session_id: u32, mut sample: StatsSample) { + let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); + let Some(live) = guard.as_mut() else { return }; + if live.samples.len() >= MAX_SAMPLES { + if !live.truncated { + live.truncated = true; + tracing::warn!( + max = MAX_SAMPLES, + "stats capture hit the sample cap — further samples dropped (oldest kept)" + ); + } + return; + } + sample.session_id = session_id; + sample.t_ms = live.started.elapsed().as_millis() as u64; + live.samples.push(sample); + } + + /// Disarm + finalize: write `/.json` atomically (temp + rename) and return its meta. + /// `Ok(None)` if nothing was recording. + pub fn stop(&self) -> std::io::Result> { + // Clear the hot-path gate first so frame threads stop building samples immediately. + self.armed.store(false, Ordering::Relaxed); + let Some(live) = self.live.lock().unwrap_or_else(|e| e.into_inner()).take() else { + return Ok(None); + }; + let meta = meta_of(&live); + let capture = Capture { + meta: meta.clone(), + samples: live.samples, + }; + let bytes = serde_json::to_vec(&capture).map_err(std::io::Error::other)?; + // Atomic replace: write a sibling temp then rename, so a crash mid-write can't leave a half + // file. The id is generated (always `valid_id`), so this only ever names a child of `dir`. + let path = self.dir.join(format!("{}.json", meta.id)); + let tmp = self.dir.join(format!("{}.json.tmp", meta.id)); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, &path)?; + Ok(Some(meta)) + } + + /// The in-progress capture status (idle = `armed: false`, zeroed fields). + pub fn status(&self) -> StatsStatus { + status_of(self.live.lock().unwrap_or_else(|e| e.into_inner()).as_ref()) + } + + /// A clone of the in-progress capture for live graphing (`None` when idle). + pub fn live_snapshot(&self) -> Option { + let guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); + let live = guard.as_ref()?; + Some(Capture { + meta: meta_of(live), + samples: live.samples.clone(), + }) + } + + /// All saved recordings, newest first, parsing each file's `meta` head only (not the samples). + pub fn list(&self) -> Vec { + /// Parse only the `meta` head — serde skips the (large) `samples` array. + #[derive(Deserialize)] + struct MetaOnly { + meta: CaptureMeta, + } + let mut out: Vec = Vec::new(); + let Ok(entries) = std::fs::read_dir(&self.dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + if let Ok(parsed) = serde_json::from_slice::(&bytes) { + out.push(parsed.meta); + } + } + } + out.sort_by_key(|m| std::cmp::Reverse(m.started_unix_ms)); + out + } + + /// Load a saved recording by id. Rejects a path-unsafe id (and a missing file) as `NotFound`. + pub fn load(&self, id: &str) -> std::io::Result { + let path = self.recording_path(id)?; + let bytes = std::fs::read(&path)?; + serde_json::from_slice(&bytes) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + } + + /// Delete a saved recording by id. Rejects a path-unsafe id (and a missing file) as `NotFound`. + pub fn delete(&self, id: &str) -> std::io::Result<()> { + let path = self.recording_path(id)?; + std::fs::remove_file(&path) + } + + /// Resolve `dir/.json` after validating `id`. A rejected id is `NotFound` (defense in + /// depth: never let an attacker-shaped id escape `dir`). + fn recording_path(&self, id: &str) -> std::io::Result { + if !valid_id(id) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "invalid recording id", + )); + } + Ok(self.dir.join(format!("{id}.json"))) + } +} + +/// Build the live `StatsStatus` from the optional in-progress capture. +fn status_of(live: Option<&Live>) -> StatsStatus { + match live { + Some(l) => StatsStatus { + armed: true, + sample_count: l.samples.len() as u32, + started_unix_ms: l.started_unix_ms, + kind: l.meta.as_ref().map(|m| m.kind.clone()).unwrap_or_default(), + }, + None => StatsStatus { + armed: false, + sample_count: 0, + started_unix_ms: 0, + kind: String::new(), + }, + } +} + +/// Compute the `CaptureMeta` for an in-progress or finalizing capture (id derived from the start +/// time + negotiated mode; duration from the monotonic start). +fn meta_of(live: &Live) -> CaptureMeta { + let (kind, width, height, fps, codec, client) = match &live.meta { + Some(m) => ( + m.kind.clone(), + m.width, + m.height, + m.fps, + m.codec.clone(), + m.client.clone(), + ), + None => (String::new(), 0, 0, 0, String::new(), String::new()), + }; + CaptureMeta { + id: capture_id(live.started_unix_ms, width, height), + started_unix_ms: live.started_unix_ms, + duration_ms: live.started.elapsed().as_millis() as u64, + kind, + width, + height, + fps, + codec, + client, + sample_count: live.samples.len() as u32, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> PathBuf { + // A per-call unique dir: a process-wide counter (NOT a timestamp, which collides when tests + // run in parallel within the same millisecond — one test's cleanup would then wipe another's + // dir mid-run). + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let p = std::env::temp_dir().join(format!("pf-stats-{}-{}", std::process::id(), n)); + let _ = std::fs::remove_dir_all(&p); + p + } + + fn sample() -> StatsSample { + StatsSample { + t_ms: 0, + session_id: 0, + stages: vec![StageTiming { + name: "capture".into(), + p50_us: 100.0, + p99_us: 200.0, + }], + fps: 60.0, + repeat_fps: 0.0, + mbps: 25.0, + bitrate_kbps: 20_000, + frames_dropped: 0, + packets_dropped: 0, + send_dropped: 0, + fec_recovered: 0, + } + } + + #[test] + fn arm_record_save_load_delete() { + let dir = temp_dir(); + let rec = StatsRecorder::new(dir.clone()); + assert!(!rec.is_armed()); + assert!(!rec.status().armed); + // A push while idle is a no-op (no live capture). + rec.push_sample(0, sample()); + + let st = rec.start(); + assert!(st.armed); + assert!(rec.is_armed()); + let sid = rec.register_session("native", 5120, 1440, 240, "hevc", "abcd"); + rec.push_sample(sid, sample()); + rec.push_sample(sid, sample()); + assert_eq!(rec.status().sample_count, 2); + assert_eq!(rec.status().kind, "native"); + assert!(rec.live_snapshot().is_some()); + + let meta = rec.stop().unwrap().expect("a capture was recording"); + assert_eq!(meta.sample_count, 2); + assert_eq!(meta.kind, "native"); + assert_eq!(meta.width, 5120); + assert!(meta.id.ends_with("_5120x1440"), "id was {}", meta.id); + assert!(!rec.is_armed()); + assert!(rec.live_snapshot().is_none()); + // Stop with nothing recording → Ok(None). + assert!(rec.stop().unwrap().is_none()); + + // It is listed and loadable. + let list = rec.list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, meta.id); + let loaded = rec.load(&meta.id).unwrap(); + assert_eq!(loaded.samples.len(), 2); + assert_eq!(loaded.meta.codec, "hevc"); + + // Delete removes it; a second delete is NotFound. + rec.delete(&meta.id).unwrap(); + assert!(rec.list().is_empty()); + assert_eq!( + rec.delete(&meta.id).unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_path_traversal_ids() { + let dir = temp_dir(); + let rec = StatsRecorder::new(dir.clone()); + for bad in [ + "../secret", + "..", + ".", + "a/b", + "a\\b", + "", + "/etc/passwd", + "x/../../y", + ] { + assert_eq!( + rec.load(bad).unwrap_err().kind(), + std::io::ErrorKind::NotFound, + "load({bad:?}) must be rejected as NotFound" + ); + assert_eq!( + rec.delete(bad).unwrap_err().kind(), + std::io::ErrorKind::NotFound, + "delete({bad:?}) must be rejected as NotFound" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn samples_are_bounded() { + let dir = temp_dir(); + let rec = StatsRecorder::new(dir.clone()); + rec.start(); + for _ in 0..(MAX_SAMPLES + 50) { + rec.push_sample(0, sample()); + } + assert_eq!(rec.status().sample_count as usize, MAX_SAMPLES); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn start_is_idempotent_while_armed() { + let dir = temp_dir(); + let rec = StatsRecorder::new(dir.clone()); + rec.start(); + rec.register_session("native", 1920, 1080, 60, "hevc", ""); + rec.push_sample(0, sample()); + // A second start must NOT wipe the in-progress capture. + let st = rec.start(); + assert!(st.armed); + assert_eq!(st.sample_count, 1); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/design/stats-capture-plan.md b/design/stats-capture-plan.md new file mode 100644 index 0000000..18adbeb --- /dev/null +++ b/design/stats-capture-plan.md @@ -0,0 +1,246 @@ +# Stats capture & graphing — design + +Goal: let an operator **enable performance-stats capture from the web console**, play a +session, **stop**, and **review the captured time-series as graphs** in the web console. +Captures are **saved to disk** (browse/compare past sessions; survive host restart) and +cover **both** streaming paths: native punktfunk/1 (`virtual_stream`) and GameStream/Moonlight +(`gamestream/stream.rs`). + +This builds on the existing per-stage instrumentation (today gated by `PUNKTFUNK_PERF=1`, +stdout-only, read once at startup). We make recording **runtime-toggleable**, route the same +aggregates into a **shared ring → on-disk recording**, and expose it over the mgmt REST API + +web console. + +--- + +## 1. Host: shared `StatsRecorder` + +New module `crates/punktfunk-host/src/stats_recorder.rs`. One `Arc` is created +once in the unified host entry (`gamestream::serve`, the `serve` subcommand) alongside +`Arc`, and shared with **both** the mgmt API (`MgmtState`) and the streaming +loops (threaded through `punktfunk1::serve` → `SessionContext` → `virtual_stream`/`send_loop`, +and into the GameStream encode loop). Mirror the existing `NativePairing` Arc-sharing pattern +exactly. + +### Data model (serde + utoipa `ToSchema`; this is the wire + on-disk shape) + +```rust +/// One pipeline stage's latency in a window (microseconds). +pub struct StageTiming { + pub name: String, // "capture" | "submit" | "encode" | "packetize" | "send" + pub p50_us: f32, + pub p99_us: f32, +} + +/// One aggregated sample (~ every 2 s native, ~ every 1 s GameStream). +pub struct StatsSample { + pub t_ms: u64, // ms since capture start (monotonic, from a stored Instant) + pub session_id: u32, // disambiguates concurrent sessions (usually constant) + pub stages: Vec, // ordered pipeline stages for this path + pub fps: f32, // genuine NEW frames/s from the source + pub repeat_fps: f32, // re-encoded holds/s (source-starvation indicator) + pub mbps: f32, // tx goodput (Mb/s) + pub bitrate_kbps: u32, // configured target bitrate + pub frames_dropped: u32, // delta in this window + pub packets_dropped: u32, // delta (receiver-side / reassembler), where known + pub send_dropped: u32, // delta (host send-buffer overflow / EAGAIN) + pub fec_recovered: u32, // delta (shards recovered) +} + +pub struct CaptureMeta { + pub id: String, // "2026-06-26T20-14-03Z_5120x1440" — also the filename stem + pub started_unix_ms: u64, + pub duration_ms: u64, + pub kind: String, // "native" | "gamestream" + pub width: u32, + pub height: u32, + pub fps: u32, + pub codec: String, // "h264" | "hevc" | "av1" + pub client: String, // short label / fingerprint prefix, or "" if unknown + pub sample_count: u32, +} + +pub struct Capture { + pub meta: CaptureMeta, + pub samples: Vec, +} + +pub struct StatsStatus { + pub armed: bool, // capture currently running + pub sample_count: u32, // samples in the in-progress capture + pub started_unix_ms: u64, // 0 if idle + pub kind: String, // path of the in-progress capture, "" if idle +} +``` + +Stage sets per path (ordered, roughly the per-frame critical path so stacking is meaningful): +- **native**: `capture` (try_latest ring read + color convert), `submit` (NVENC enqueue), + `encode` (lock_bitstream = NVENC schedule + ASIC — the dominant stage under GPU load), + `send` (paced_submit: seal + FEC + pace + sendmmsg). +- **gamestream**: `capture`, `encode`, `packetize` (poll+FEC+packetize), `send`. + +> Native naming: today's vectors are `st_cap`→`capture`, `st_submit`→`submit`, +> `st_wait`→`encode`, `pace_us`→`send`. (`encode_us` total ≈ capture+submit+encode; we do not +> emit it as a stage to avoid double-counting — it's implied by the stack.) + +### Recorder API + +```rust +pub struct StatsRecorder { /* dir, armed: AtomicBool, live: Mutex>, next_sid: AtomicU32 */ } + +impl StatsRecorder { + pub fn new(dir: PathBuf) -> Arc; // creates dir (0700) if missing + + pub fn is_armed(&self) -> bool; // cheap Relaxed atomic load — called on the hot path + + /// Arm a new capture. No-op if already armed (returns current status). + pub fn start(&self) -> StatsStatus; + + /// A streaming loop announces itself when it first records while armed. + /// Seeds CaptureMeta (kind/w/h/fps/codec/client) on the FIRST registration. Returns session_id. + pub fn register_session(&self, kind: &'static str, w: u32, h: u32, fps: u32, codec: &str, client: &str) -> u32; + + /// Append one aggregated sample (called from the loops' existing ~2 s/~1 s boundary). + /// Bounded: cap at MAX_SAMPLES (e.g. 5400 ≈ 3 h @ 2 s). On overflow, stop appending and + /// set a `truncated` flag (DO NOT drop oldest — a saved recording must keep its start). + pub fn push_sample(&self, session_id: u32, sample: StatsSample); + + /// Disarm + finalize: write /.json atomically, clear live, return saved meta. + pub fn stop(&self) -> std::io::Result>; + + pub fn status(&self) -> StatsStatus; + pub fn live_snapshot(&self) -> Option; // clone of the in-progress capture for live graphing + + pub fn list(&self) -> Vec; // scan dir, parse meta only, newest first + pub fn load(&self, id: &str) -> std::io::Result; + pub fn delete(&self, id: &str) -> std::io::Result<()>; +} +``` + +Invariants / safety: +- **No async on the per-frame path.** `is_armed()` is a `Relaxed` atomic load; sample + construction happens only at the existing 2 s / 1 s aggregation boundary, never per frame. +- **`id` is path-traversal-safe.** `load`/`delete` MUST reject any id not matching + `^[A-Za-z0-9._-]+$` (no `/`, no `..`, no `:` — keep it a valid Windows filename), and only ever + join `dir/.json`. Return NotFound on reject. (Endpoints are bearer-authed, but defend in + depth.) +- **Bounded memory.** `MAX_SAMPLES` cap; truncate (keep oldest), never unbounded. +- **Atomic disk write.** Write to `.json.tmp` then rename, so a crash mid-write can't leave + a half file. Pretty-print not required; compact JSON is fine. +- Captures dir: `~/.config/punktfunk/captures/` (next to `cert.pem` etc.). Resolve via the same + config-dir helper the rest of the host uses. + +### Runtime gating change (the key behavioral change) + +Today the loops measure per-stage timing only `if perf` (a startup bool). Change the per-frame +**measurement** predicate to `let measure = perf || recorder.is_armed();`, re-evaluated each +frame (cheap atomic). Then at the aggregation boundary: +- if `perf` → keep the existing `tracing::info!` log line (unchanged behavior); +- if `recorder.is_armed()` → also build a `StatsSample` and `push_sample`. + +So `PUNKTFUNK_PERF=1` still works exactly as before, AND the web toggle now works at runtime +with zero startup flags. + +### Where each loop emits the sample + +- **native** (`punktfunk1.rs`): the cap/submit/encode(`st_wait`) splits live in the capture + thread; `mbps`/`send_dropped`/`bytes` and `session.stats()` live in the send thread. Emit the + complete sample from **one** place. Cleanest: carry the per-frame `cap_us/submit_us/wait_us` + (and a `repeat: bool`) on `FrameMsg` to the send thread (it already carries `encode_us`), so + `send_loop` builds the whole sample at its existing 2 s boundary where `session.stats()` is + already read. Compute `frames_dropped/packets_dropped/send_dropped/fec_recovered` as deltas vs + the previous window's `Session::stats()` snapshot (the loop already tracks `last_bytes` / + `last_send_dropped` — extend that bookkeeping). `register_session` is called once with the + negotiated mode/codec and the client label. +- **gamestream** (`gamestream/stream.rs`): the encode loop already tracks per-stage max each + 1 s. Add p50/p99 accumulation (small per-stage `Vec` like the native path) and, when + `perf || recorder.is_armed()`, emit a `StatsSample` with stages + `[capture, encode, packetize, send]` + fps (unique new frames) + mbps + whatever loss/byte + counters that path exposes (use 0 where a counter doesn't exist; do NOT fabricate). Call + `register_session("gamestream", ...)` with the GameStream-negotiated mode/codec/client. + +Threading: add `stats: Arc` to `SessionContext` and the GameStream stream +setup; the standalone `punktfunk1-host` subcommand (no mgmt) passes a fresh recorder (harmless, +just unused). + +--- + +## 2. Host: mgmt REST API (`mgmt.rs`) + +Add `stats: Arc` to `MgmtState`. Register handlers in `api_router_parts()` via +`routes!()` with `#[utoipa::path]`. All under `/api/v1`, **bearer-token only** (operator +actions — do NOT add them to the mTLS `cert_may_access` read-only allowlist). All bodies/returns +derive `ToSchema`; errors use the `ApiJson`/`ApiError` envelope. Tag every operation `stats`. + +| Method & path | fn (operationId) | body → returns | +|---------------------------------------|-------------------------|-------------------------------| +| POST `/api/v1/stats/capture/start` | `stats_capture_start` | — → `StatsStatus` | +| POST `/api/v1/stats/capture/stop` | `stats_capture_stop` | — → `CaptureMeta` (200) / 204-ish if nothing was recording | +| GET `/api/v1/stats/capture/status` | `stats_capture_status` | → `StatsStatus` | +| GET `/api/v1/stats/capture/live` | `stats_capture_live` | → `Capture` (in-progress; 404/empty if idle) | +| GET `/api/v1/stats/recordings` | `stats_recordings_list` | → `Vec` | +| GET `/api/v1/stats/recordings/{id}` | `stats_recording_get` | → `Capture` | +| DELETE `/api/v1/stats/recordings/{id}`| `stats_recording_delete`| → `StatsStatus`/204 | + +Register the new `ToSchema` types with the OpenApi derive's `components(schemas(...))` list. +Then regenerate the checked-in spec: + +``` +cargo run -p punktfunk-host -- openapi > api/openapi.json +``` + +CI fails on drift — the regenerated `api/openapi.json` MUST be committed. + +--- + +## 3. Web console (`web/`) + +New page **"Performance"** following the established route → section/index (fetch) → +section/view (presentational) pattern, registered in the `NAV` array (`app-shell.tsx`) with a +lucide icon (`Activity` or `LineChart`). + +- Route: `web/src/routes/stats.tsx` → `createFileRoute('/stats')` → `SectionStats`. +- Section: `web/src/sections/Stats/index.tsx` (orval hooks) + `view.tsx` (presentational, + i18n via Paraglide `m.*`). Use `Section`, `QueryState`, `Card`/`CardHeader`/`CardTitle`/ + `CardContent`, `Button`, `Badge` from `web/src/components/ui`. +- Charts: **add `recharts`** to `web/package.json` (no chart lib exists today). Render charts + **client-only** (a mounted guard) so SSR doesn't choke on `ResponsiveContainer`'s 0-width + measure. Theme via existing CSS variables / brand violet, dark-mode aware. + +Data hooks come from regenerated orval (`bun run api:gen` after the host's openapi.json is +updated): `useStatsCaptureStatus`, `useStatsCaptureStart`, `useStatsCaptureStop`, +`useStatsCaptureLive`, `useStatsRecordingsList`, `useStatsRecordingGet`, +`useStatsRecordingDelete` (exact names per orval's tag/operationId convention — verify against +generated output and adjust the view imports to match). + +UI layout: +1. **Capture control card** — Start/Stop button (mutations; invalidate status query on + success), a "Recording…"/"Idle" `Badge`, elapsed time + live sample count + (`useStatsCaptureStatus`, `refetchInterval: 2000`). On Start, the live chart appears. +2. **Live chart** (visible while armed; `useStatsCaptureLive`, `refetchInterval: 2000`) — the + latency stage breakdown as a **stacked area** (capture/submit/encode/send in µs, the + "where does the time go" view), with fps and mbps as secondary line charts. +3. **Recordings card** — table from `useStatsRecordingsList`: time, kind badge, resolution, + codec, duration, sample count; row actions **View** (select → detail), **Download** (export + the `Capture` JSON via the recording GET), **Delete** (mutation, confirm). +4. **Recording detail** — when a recording (or the live capture) is selected, render the full + graph set from its `samples`: + - Latency stage breakdown (stacked area, µs) — primary bottleneck view; p99 overlay toggle. + - Throughput: fps (new vs repeat) + mbps. + - Health: frames_dropped / packets_dropped / send_dropped / fec_recovered over time. + +i18n: add keys to `web/messages/en.json` + `de.json` (nav label, titles, button/labels) and +regenerate Paraglide. Keep both locales in sync. + +--- + +## 4. Verification / done-criteria + +- `cargo build -p punktfunk-host` (and `--workspace`), `cargo clippy --workspace --all-targets + -D warnings`, `cargo fmt --all --check` — green. +- `cargo run -p punktfunk-host -- openapi > api/openapi.json` — committed, no drift. +- `PUNKTFUNK_PERF=1` stdout behavior unchanged (no regression to the existing perf log). +- Web: orval regen clean, typecheck/build green, charts render client-side. +- CLAUDE.md status note + this plan updated. +- Adversarial review: hot-path stays sync + bounded; `id` path-traversal-safe; OpenAPI/orval no + drift; SSR-safe charts; both paths actually emit samples. diff --git a/web/bun.lock b/web/bun.lock index 7f0789e..5d6897d 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -18,6 +18,7 @@ "radix-ui": "^1.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "recharts": "^3.9.0", "tailwind-merge": "^2.6.0", "zod": "^4.4.3", }, @@ -668,6 +669,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.2", "", { "os": "android", "cpu": "arm64" }, "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw=="], @@ -798,6 +801,10 @@ "@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.4.6", "", { "dependencies": { "@storybook/csf-plugin": "10.4.6", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.4.6", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg=="], "@storybook/csf-plugin": ["@storybook/csf-plugin@10.4.6", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.4.6", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA=="], @@ -920,6 +927,24 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -958,6 +983,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -1174,6 +1201,28 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="], "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], @@ -1184,6 +1233,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], @@ -1264,6 +1315,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1286,6 +1339,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], @@ -1410,6 +1465,8 @@ "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1420,6 +1477,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], @@ -1840,6 +1899,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1862,16 +1923,24 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recharts": ["recharts@3.9.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "remeda": ["remeda@2.39.0", "", {}, "sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -2146,6 +2215,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], @@ -2248,6 +2319,8 @@ "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], diff --git a/web/messages/de.json b/web/messages/de.json index 9b48867..2d7f1c3 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -105,5 +105,50 @@ "login_submit": "Anmelden", "login_error": "Falsches Passwort.", "login_signing_in": "Anmeldung läuft…", - "action_logout": "Abmelden" + "action_logout": "Abmelden", + "nav_stats": "Leistung", + "stats_title": "Leistung", + "stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.", + "stats_capture_title": "Aufzeichnung", + "stats_capture_desc": "Aufzeichnung scharfschalten, eine Sitzung fahren, dann stoppen, um eine Aufnahme zu speichern. Die Abtastung erfolgt an der Aggregationsgrenze des Hosts — kein Overhead pro Frame.", + "stats_recording": "Zeichnet auf", + "stats_idle": "Inaktiv", + "stats_start": "Aufzeichnung starten", + "stats_stop": "Stoppen & speichern", + "stats_elapsed": "Vergangen", + "stats_samples": "Proben", + "stats_kind": "Pfad", + "stats_kind_native": "Nativ", + "stats_kind_gamestream": "GameStream", + "stats_live_title": "Live", + "stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.", + "stats_latency_title": "Latenz nach Stufe", + "stats_latency_axis": "µs", + "stats_latency_desc": "Pipeline-Zeit pro Stufe, gestapelt — die Ansicht „wohin geht die Zeit“.", + "stats_throughput_title": "Durchsatz", + "stats_health_title": "Zustand", + "stats_fps_new": "Neue fps", + "stats_fps_repeat": "Wiederholte fps", + "stats_mbps": "Mb/s", + "stats_p99": "p99", + "stats_p50": "p50", + "stats_frames_dropped": "Verworfene Frames", + "stats_packets_dropped": "Verworfene Pakete", + "stats_send_dropped": "Sende-Verluste", + "stats_fec_recovered": "FEC wiederhergestellt", + "stats_recordings_title": "Aufnahmen", + "stats_recordings_empty": "Noch keine Aufnahmen. Starte eine Aufzeichnung, um eine anzulegen.", + "stats_col_time": "Zeit", + "stats_col_kind": "Pfad", + "stats_col_resolution": "Auflösung", + "stats_col_codec": "Codec", + "stats_col_duration": "Dauer", + "stats_col_samples": "Proben", + "stats_view": "Ansehen", + "stats_download": "Herunterladen", + "stats_delete": "Löschen", + "stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.", + "stats_detail_title": "Aufnahme-Details", + "stats_close": "Schließen", + "stats_no_samples": "Diese Aufnahme enthält keine Proben." } diff --git a/web/messages/en.json b/web/messages/en.json index 1a84172..557954c 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -105,5 +105,50 @@ "login_submit": "Sign in", "login_error": "Wrong password.", "login_signing_in": "Signing in…", - "action_logout": "Sign out" + "action_logout": "Sign out", + "nav_stats": "Performance", + "stats_title": "Performance", + "stats_subtitle": "Record a session's pipeline timings and review them as graphs.", + "stats_capture_title": "Capture", + "stats_capture_desc": "Arm capture, run a session, then stop to save a recording. Sampling runs at the host's aggregation boundary — no per-frame overhead.", + "stats_recording": "Recording", + "stats_idle": "Idle", + "stats_start": "Start capture", + "stats_stop": "Stop & save", + "stats_elapsed": "Elapsed", + "stats_samples": "Samples", + "stats_kind": "Path", + "stats_kind_native": "Native", + "stats_kind_gamestream": "GameStream", + "stats_live_title": "Live", + "stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.", + "stats_latency_title": "Latency by stage", + "stats_latency_axis": "µs", + "stats_latency_desc": "Per-stage pipeline time, stacked — the \"where does the time go\" view.", + "stats_throughput_title": "Throughput", + "stats_health_title": "Health", + "stats_fps_new": "New fps", + "stats_fps_repeat": "Repeat fps", + "stats_mbps": "Mb/s", + "stats_p99": "p99", + "stats_p50": "p50", + "stats_frames_dropped": "Frames dropped", + "stats_packets_dropped": "Packets dropped", + "stats_send_dropped": "Send drops", + "stats_fec_recovered": "FEC recovered", + "stats_recordings_title": "Recordings", + "stats_recordings_empty": "No recordings yet. Start a capture to record one.", + "stats_col_time": "Time", + "stats_col_kind": "Path", + "stats_col_resolution": "Resolution", + "stats_col_codec": "Codec", + "stats_col_duration": "Duration", + "stats_col_samples": "Samples", + "stats_view": "View", + "stats_download": "Download", + "stats_delete": "Delete", + "stats_delete_confirm": "Delete this recording? This can't be undone.", + "stats_detail_title": "Recording detail", + "stats_close": "Close", + "stats_no_samples": "This recording has no samples." } diff --git a/web/package.json b/web/package.json index faf08b5..21c7546 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "radix-ui": "^1.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "recharts": "^3.9.0", "tailwind-merge": "^2.6.0", "zod": "^4.4.3" }, diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 05c2841..f135aab 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -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() }, diff --git a/web/src/routes/stats.tsx b/web/src/routes/stats.tsx new file mode 100644 index 0000000..a9c7e3b --- /dev/null +++ b/web/src/routes/stats.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SectionStats } from "@/sections/Stats"; + +export const Route = createFileRoute("/stats")({ component: SectionStats }); diff --git a/web/src/sections/Stats/charts.tsx b/web/src/sections/Stats/charts.tsx new file mode 100644 index 0000000..fca07f6 --- /dev/null +++ b/web/src/sections/Stats/charts.tsx @@ -0,0 +1,268 @@ +// Recharts visualisations for a captured stats series. Everything here is rendered +// CLIENT-ONLY (behind '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 = { + 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
; + return ( + + {children} + + ); +} + +/** 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 = { 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 ( +
+ {toggle && ( +
+ +
+ )} + + + + + + + + {names.map((n, i) => ( + + ))} + + +
+ ); +} + +/** 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 ( + + + + + + + + + + + + + + ); +} + +/** 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 ( + + + + + + + + + + + + + + ); +} diff --git a/web/src/sections/Stats/index.tsx b/web/src/sections/Stats/index.tsx new file mode 100644 index 0000000..623a4c2 --- /dev/null +++ b/web/src/sections/Stats/index.tsx @@ -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(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 ( + + ); +}; diff --git a/web/src/sections/Stats/view.tsx b/web/src/sections/Stats/view.tsx new file mode 100644 index 0000000..99f0465 --- /dev/null +++ b/web/src/sections/Stats/view.tsx @@ -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; + live: Loadable; + recordings: Loadable; + detail: Loadable; + 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 = (props) => { + const armed = props.status.data?.armed ?? false; + return ( +
+
+

{m.stats_title()}

+

{m.stats_subtitle()}

+
+ + + + {armed && } + + + + {props.selectedId && ( + props.onSelect(null)} + /> + )} +
+ ); +}; + +/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */ +const CaptureControlCard: FC<{ + status: Loadable; + 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 ( + + + + + {m.stats_capture_title()} + {armed ? ( + + + {m.stats_recording()} + + ) : ( + {m.stats_idle()} + )} + + + +

+ {m.stats_capture_desc()} +

+ {armed && s && ( +
+ + + {s.kind && ( + + )} +
+ )} +
+ {armed ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const Stat: FC<{ label: string; value: string }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + +/** Live graphs while a capture is armed: latency stack + throughput. */ +const LiveCard: FC<{ live: Loadable }> = ({ live }) => { + const samples = live.data?.samples ?? []; + return ( + + + {m.stats_live_title()} + + + {samples.length === 0 ? ( +

+ {m.stats_live_waiting()} +

+ ) : ( + <> + + + + + + + + )} +
+
+ ); +}; + +/** Saved recordings, with View / Download / Delete row actions. */ +const RecordingsCard: FC<{ + recordings: Loadable; + 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 ( +
+

{m.stats_recordings_title()}

+ + {rows.length === 0 ? ( + + + {m.stats_recordings_empty()} + + + ) : ( + + + + + + {m.stats_col_time()} + {m.stats_col_kind()} + {m.stats_col_resolution()} + {m.stats_col_codec()} + + {m.stats_col_duration()} + + + {m.stats_col_samples()} + + + + + + {rows.map((r) => ( + + + {fmtTimestamp(r.started_unix_ms)} + + + + {kindLabel(r.kind)} + + + + {r.width}×{r.height}@{r.fps} + + + {r.codec} + + + {fmtDuration(r.duration_ms)} + + + {r.sample_count} + + +
+ + + +
+
+
+ ))} +
+
+
+
+ )} +
+
+ ); +}; + +/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */ +const DetailCard: FC<{ detail: Loadable; onClose: () => void }> = ({ + detail, + onClose, +}) => { + const cap = detail.data; + const samples = cap?.samples ?? []; + return ( + + + + + {m.stats_detail_title()} + {cap && ( + + {cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "} + {cap.meta.codec.toUpperCase()} + + )} + + + + + + + {samples.length === 0 ? ( +

+ {m.stats_no_samples()} +

+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+
+ ); +}; + +const ChartBlock: FC<{ + title: string; + desc?: string; + children: React.ReactNode; +}> = ({ title, desc, children }) => ( +
+
+

{title}

+ {desc &&

{desc}

} +
+ {children} +
+);