feat(host): web-console performance capture — record stream stats, graph them
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m13s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m51s
apple / screenshots (push) Successful in 5m1s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m10s

Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.

- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
  shared with the mgmt API + both streaming loops, mirroring NativePairing). The
  hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
  for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
  temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
  poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
  ~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
  loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
  FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
  percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
  recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
  graphs while armed, recordings table (view / download-JSON / delete), and a detail
  view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
  + health. Charts adapt to either path's stage set.

Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:59:39 +00:00
parent 0a6c9d8852
commit 5bf787eb2b
20 changed files with 2907 additions and 53 deletions
+11 -3
View File
@@ -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<StatsRecorder>` 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/
+528
View File
@@ -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\n12 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"
}
]
}
+20 -7
View File
@@ -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<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
/// 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<crate::stats_recorder::StatsRecorder>,
}
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<crate::stats_recorder::StatsRecorder>,
) -> 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(())
@@ -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 {
@@ -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"),
+148 -14
View File
@@ -48,6 +48,7 @@ pub fn start(
force_idr: Arc<AtomicBool>,
rfi_range: RfiSlot,
video_cap: CapturerSlot,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) {
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<Option<(i64, i64)>>,
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
// 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<crate::stats_recorder::StatsRecorder>,
) -> 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<AtomicBool>,
force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
// 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<crate::stats_recorder::StatsRecorder>,
// 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<u32> = None;
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
(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();
}
+1
View File
@@ -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"]
+212 -4
View File
@@ -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<Arc<crate::native_pairing::NativePairing>>,
/// 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<crate::stats_recorder::StatsRecorder>,
token: Option<String>,
/// 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<AppState>,
opts: Options,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) -> 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<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
) -> Router {
let shared = Arc::new(MgmtState {
app: state,
native,
stats,
token,
port,
});
@@ -158,7 +165,13 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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<String>) -> 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
/// 12 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<Arc<MgmtState>>) -> Json<StatsStatus> {
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<Arc<MgmtState>>) -> 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<Arc<MgmtState>>) -> Json<StatsStatus> {
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<Arc<MgmtState>>) -> 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<Arc<MgmtState>>) -> Json<Vec<CaptureMeta>> {
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<Arc<MgmtState>>, Path(id): Path<String>) -> 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<Arc<MgmtState>>,
Path(id): Path<String>,
) -> 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> {
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<AppState> {
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<AppState>, 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}");
}
+236 -22
View File
@@ -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<NativePairing>) -> Result<()> {
pub(crate) async fn serve(
opts: Punktfunk1Options,
np: Arc<NativePairing>,
stats: Arc<StatsRecorder>,
) -> 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<NativePairing>) -> 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<NativePairing>) -> 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<Option<std::time::Instant>>,
stats: Arc<StatsRecorder>,
) -> 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<StatsRecorder>,
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<AtomicU8>,
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<u32> = Vec::new();
let mut pace_us: Vec<u32> = 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<u32> = None;
let (mut cap_v, mut submit_v, mut wait_v): (Vec<u32>, Vec<u32>, Vec<u32>) =
(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<AtomicU8>,
/// 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<StatsRecorder>,
/// 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::<FrameMsg>(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::<FrameMsg>(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));
+553
View File
@@ -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<StageTiming>,
/// 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<StatsSample>,
}
/// 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<MetaSeed>,
samples: Vec<StatsSample>,
/// 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<Option<Live>>,
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("<id>.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<Self> {
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 `<dir>/<id>.json` atomically (temp + rename) and return its meta.
/// `Ok(None)` if nothing was recording.
pub fn stop(&self) -> std::io::Result<Option<CaptureMeta>> {
// 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<Capture> {
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<CaptureMeta> {
/// Parse only the `meta` head — serde skips the (large) `samples` array.
#[derive(Deserialize)]
struct MetaOnly {
meta: CaptureMeta,
}
let mut out: Vec<CaptureMeta> = 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::<MetaOnly>(&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<Capture> {
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/<id>.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<PathBuf> {
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);
}
}
+246
View File
@@ -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<StatsRecorder>` is created
once in the unified host entry (`gamestream::serve`, the `serve` subcommand) alongside
`Arc<NativePairing>`, 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<StageTiming>, // 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<StatsSample>,
}
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<Option<Live>>, next_sid: AtomicU32 */ }
impl StatsRecorder {
pub fn new(dir: PathBuf) -> Arc<Self>; // 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 <dir>/<id>.json atomically, clear live, return saved meta.
pub fn stop(&self) -> std::io::Result<Option<CaptureMeta>>;
pub fn status(&self) -> StatsStatus;
pub fn live_snapshot(&self) -> Option<Capture>; // clone of the in-progress capture for live graphing
pub fn list(&self) -> Vec<CaptureMeta>; // scan dir, parse meta only, newest first
pub fn load(&self, id: &str) -> std::io::Result<Capture>;
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/<id>.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 `<id>.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<u32>` 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<StatsRecorder>` 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<StatsRecorder>` 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<CaptureMeta>` |
| 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.
+73
View File
@@ -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=="],
+46 -1
View File
@@ -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."
}
+46 -1
View File
@@ -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."
}
+1
View File
@@ -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"
},
+2
View File
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import {
Activity,
GaugeCircle,
KeyRound,
LibraryBig,
Server,
@@ -21,6 +22,7 @@ const NAV = [
{ to: "/", icon: Activity, label: () => m.nav_dashboard() },
{ to: "/host", icon: Server, label: () => m.nav_host() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionStats } from "@/sections/Stats";
export const Route = createFileRoute("/stats")({ component: SectionStats });
+268
View File
@@ -0,0 +1,268 @@
// Recharts visualisations for a captured stats series. Everything here is rendered
// CLIENT-ONLY (behind <ChartFrame>'s mounted guard): recharts' ResponsiveContainer
// measures its parent via ResizeObserver, which has no width during SSR and would
// otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample
// carries — native (capture/submit/encode/send) and gamestream
// (capture/encode/packetize/send) both stack sensibly.
import { type ReactElement, useEffect, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { StatsSample } from "@/api/gen/model/statsSample";
import { Button } from "@/components/ui/button";
import { m } from "@/paraglide/messages";
const CHART_H = 240;
const axisTick = { fontSize: 11, fill: "var(--muted-foreground)" } as const;
const gridStroke = "var(--border)";
const tooltipStyle = {
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 8,
fontSize: 12,
color: "var(--foreground)",
} as const;
const legendStyle = { fontSize: 12 } as const;
// Known stages get a stable hue; anything else falls back to the palette by
// appearance order, so an unexpected stage name still renders a distinct band.
const STAGE_COLORS: Record<string, string> = {
capture: "#6c5bf3",
submit: "#22a2f2",
encode: "#f2a922",
packetize: "#1fb6a8",
send: "#f25c8a",
};
const PALETTE = [
"#6c5bf3",
"#22a2f2",
"#f2a922",
"#1fb6a8",
"#f25c8a",
"#9b6cf3",
];
/** True only after the first client-side effect — gates recharts off the server render. */
function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}
/** Reserves the chart's height during SSR / before mount, then swaps in the responsive chart. */
function ChartFrame({ children }: { children: ReactElement }) {
const mounted = useMounted();
if (!mounted) return <div style={{ height: CHART_H }} aria-hidden />;
return (
<ResponsiveContainer width="100%" height={CHART_H}>
{children}
</ResponsiveContainer>
);
}
/** Stage names across all samples, in first-seen (pipeline) order. */
function stageNames(samples: StatsSample[]): string[] {
const seen: string[] = [];
for (const s of samples)
for (const st of s.stages) if (!seen.includes(st.name)) seen.push(st.name);
return seen;
}
function colorFor(name: string, i: number): string {
return STAGE_COLORS[name] ?? PALETTE[i % PALETTE.length] ?? "#6c5bf3";
}
/** Latency stacked-area (µs) — the "where does the time go" view. With `toggle`, a
* p50/p99 switch flips every stage band between its median and tail. */
export function LatencyChart({
samples,
toggle,
}: {
samples: StatsSample[];
toggle?: boolean;
}) {
const [p99, setP99] = useState(false);
const names = stageNames(samples);
const rows = samples.map((s) => {
const row: Record<string, number> = { t: Math.round(s.t_ms / 1000) };
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
for (const n of names) {
const st = byName.get(n);
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
}
return row;
});
return (
<div className="space-y-2">
{toggle && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setP99((v) => !v)}>
{p99 ? m.stats_p99() : m.stats_p50()}
</Button>
</div>
)}
<ChartFrame>
<AreaChart
data={rows}
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<YAxis
tick={axisTick}
stroke={gridStroke}
width={52}
unit="µs"
allowDecimals={false}
/>
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={legendStyle} />
{names.map((n, i) => (
<Area
key={n}
type="monotone"
dataKey={n}
stackId="lat"
stroke={colorFor(n, i)}
fill={colorFor(n, i)}
fillOpacity={0.5}
isAnimationActive={false}
/>
))}
</AreaChart>
</ChartFrame>
</div>
);
}
/** New vs repeat fps (left axis) + tx goodput Mb/s (right axis). */
export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000),
fps: s.fps,
repeat: s.repeat_fps,
mbps: s.mbps,
}));
return (
<ChartFrame>
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<YAxis
yAxisId="fps"
tick={axisTick}
stroke={gridStroke}
width={40}
allowDecimals={false}
/>
<YAxis
yAxisId="mbps"
orientation="right"
tick={axisTick}
stroke={gridStroke}
width={48}
/>
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={legendStyle} />
<Line
yAxisId="fps"
type="monotone"
dataKey="fps"
name={m.stats_fps_new()}
stroke="#6c5bf3"
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId="fps"
type="monotone"
dataKey="repeat"
name={m.stats_fps_repeat()}
stroke="#f2a922"
strokeDasharray="4 3"
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId="mbps"
type="monotone"
dataKey="mbps"
name={m.stats_mbps()}
stroke="#1fb6a8"
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ChartFrame>
);
}
/** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. */
export function HealthChart({ samples }: { samples: StatsSample[] }) {
const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000),
frames: s.frames_dropped,
packets: s.packets_dropped,
send: s.send_dropped,
fec: s.fec_recovered,
}));
return (
<ChartFrame>
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<YAxis
tick={axisTick}
stroke={gridStroke}
width={40}
allowDecimals={false}
/>
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={legendStyle} />
<Line
type="monotone"
dataKey="frames"
name={m.stats_frames_dropped()}
stroke="#f25c8a"
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="packets"
name={m.stats_packets_dropped()}
stroke="#f2a922"
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="send"
name={m.stats_send_dropped()}
stroke="#22a2f2"
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="fec"
name={m.stats_fec_recovered()}
stroke="#1fb6a8"
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ChartFrame>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useQueryClient } from "@tanstack/react-query";
import { type FC, useState } from "react";
import {
getStatsCaptureStatusQueryKey,
getStatsRecordingsListQueryKey,
statsRecordingGet,
useStatsCaptureLive,
useStatsCaptureStart,
useStatsCaptureStatus,
useStatsCaptureStop,
useStatsRecordingDelete,
useStatsRecordingGet,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { StatsView } from "./view";
export const SectionStats: FC = () => {
useLocale();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
// Poll the capture status (drives the control card + whether the live chart shows).
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
const armed = status.data?.armed ?? false;
// Live in-progress capture — only fetched while armed (404s when idle).
const live = useStatsCaptureLive({
query: { refetchInterval: 2_000, enabled: armed },
});
const recordings = useStatsRecordingsList();
// Selected recording detail — only fetched once a row is chosen.
const detail = useStatsRecordingGet(selectedId ?? "", {
query: { enabled: !!selectedId },
});
const start = useStatsCaptureStart();
const stop = useStatsCaptureStop();
const del = useStatsRecordingDelete();
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
const refreshRecordings = () =>
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
refreshRecordings();
},
});
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
del.mutate(
{ id },
{
onSuccess: () => {
if (selectedId === id) setSelectedId(null);
refreshRecordings();
},
},
);
};
// Export the full Capture JSON via a one-off GET → blob download.
const onDownload = async (id: string) => {
try {
const cap = await statsRecordingGet(id);
const blob = new Blob([JSON.stringify(cap, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// Best-effort export; the recording GET surfaces its own errors via the detail view.
}
};
return (
<StatsView
status={status}
live={live}
recordings={recordings}
detail={detail}
selectedId={selectedId}
onStart={onStart}
onStop={onStop}
onSelect={setSelectedId}
onDownload={onDownload}
onDelete={onDelete}
isStarting={start.isPending}
isStopping={stop.isPending}
isDeleting={del.isPending}
/>
);
};
+399
View File
@@ -0,0 +1,399 @@
import { Circle, Download, Eye, Square, Trash2, X } from "lucide-react";
import type { FC } from "react";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
/** ms → `m:ss`. */
function fmtDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
function fmtTimestamp(unixMs: number): string {
if (!unixMs) return "—";
return new Date(unixMs).toLocaleString();
}
function kindLabel(kind: string): string {
if (kind === "gamestream") return m.stats_kind_gamestream();
if (kind === "native") return m.stats_kind_native();
return kind;
}
export interface StatsViewProps {
status: Loadable<StatsStatus>;
live: Loadable<Capture>;
recordings: Loadable<CaptureMeta[]>;
detail: Loadable<Capture>;
selectedId: string | null;
onStart: () => void;
onStop: () => void;
onSelect: (id: string | null) => void;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isStarting: boolean;
isStopping: boolean;
isDeleting: boolean;
}
export const StatsView: FC<StatsViewProps> = (props) => {
const armed = props.status.data?.armed ?? false;
return (
<Section>
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.stats_title()}</h1>
<p className="text-sm text-muted-foreground">{m.stats_subtitle()}</p>
</div>
<CaptureControlCard
status={props.status}
onStart={props.onStart}
onStop={props.onStop}
isStarting={props.isStarting}
isStopping={props.isStopping}
/>
{armed && <LiveCard live={props.live} />}
<RecordingsCard
recordings={props.recordings}
selectedId={props.selectedId}
onSelect={props.onSelect}
onDownload={props.onDownload}
onDelete={props.onDelete}
isDeleting={props.isDeleting}
/>
{props.selectedId && (
<DetailCard
detail={props.detail}
onClose={() => props.onSelect(null)}
/>
)}
</Section>
);
};
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
const CaptureControlCard: FC<{
status: Loadable<StatsStatus>;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}> = ({ status, onStart, onStop, isStarting, isStopping }) => {
const s = status.data;
const armed = s?.armed ?? false;
const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>{m.stats_capture_title()}</span>
{armed ? (
<Badge variant="destructive" className="gap-1.5">
<Circle className="size-2.5 animate-pulse fill-current" />
{m.stats_recording()}
</Badge>
) : (
<Badge variant="outline">{m.stats_idle()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.stats_capture_desc()}
</p>
{armed && s && (
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
{s.kind && (
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
)}
</dl>
)}
<div className="flex gap-2">
{armed ? (
<Button
variant="destructive"
disabled={isStopping}
onClick={onStop}
>
<Square className="size-4" />
{m.stats_stop()}
</Button>
) : (
<Button disabled={isStarting} onClick={onStart}>
<Circle className="size-4 fill-current" />
{m.stats_start()}
</Button>
)}
</div>
</CardContent>
</Card>
</QueryState>
);
};
const Stat: FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex flex-col">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
);
/** Live graphs while a capture is armed: latency stack + throughput. */
const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle>{m.stats_live_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_live_waiting()}
</p>
) : (
<>
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
</>
)}
</CardContent>
</Card>
);
};
/** Saved recordings, with View / Download / Delete row actions. */
const RecordingsCard: FC<{
recordings: Loadable<CaptureMeta[]>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isDeleting: boolean;
}> = ({
recordings,
selectedId,
onSelect,
onDownload,
onDelete,
isDeleting,
}) => {
const rows = recordings.data ?? [];
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
<QueryState
isLoading={recordings.isLoading}
error={recordings.error}
refetch={recordings.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.stats_recordings_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.stats_col_time()}</TableHead>
<TableHead>{m.stats_col_kind()}</TableHead>
<TableHead>{m.stats_col_resolution()}</TableHead>
<TableHead>{m.stats_col_codec()}</TableHead>
<TableHead className="text-right">
{m.stats_col_duration()}
</TableHead>
<TableHead className="text-right">
{m.stats_col_samples()}
</TableHead>
<TableHead className="w-32" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.id}
data-state={selectedId === r.id ? "selected" : undefined}
>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.started_unix_ms)}
</TableCell>
<TableCell>
<Badge
variant={
r.kind === "gamestream" ? "secondary" : "default"
}
>
{kindLabel(r.kind)}
</Badge>
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{r.width}×{r.height}@{r.fps}
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{r.codec}
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtDuration(r.duration_ms)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.sample_count}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.stats_view()}
title={m.stats_view()}
onClick={() =>
onSelect(selectedId === r.id ? null : r.id)
}
>
<Eye className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_download()}
title={m.stats_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_delete()}
title={m.stats_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</div>
);
};
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
const DetailCard: FC<{ detail: Loadable<Capture>; onClose: () => void }> = ({
detail,
onClose,
}) => {
const cap = detail.data;
const samples = cap?.samples ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>
{m.stats_detail_title()}
{cap && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
{cap.meta.codec.toUpperCase()}
</span>
)}
</span>
<Button
variant="ghost"
size="icon"
aria-label={m.stats_close()}
onClick={onClose}
>
<X className="size-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<QueryState
isLoading={detail.isLoading}
error={detail.error}
refetch={detail.refetch}
>
{samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_no_samples()}
</p>
) : (
<div className="space-y-8">
<ChartBlock
title={m.stats_latency_title()}
desc={m.stats_latency_desc()}
>
<LatencyChart samples={samples} toggle />
</ChartBlock>
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} />
</ChartBlock>
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const ChartBlock: FC<{
title: string;
desc?: string;
children: React.ReactNode;
}> = ({ title, desc, children }) => (
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium">{title}</h3>
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
</div>
{children}
</div>
);