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
+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}");
}