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