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
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:
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionStats } from "@/sections/Stats";
|
||||
|
||||
export const Route = createFileRoute("/stats")({ component: SectionStats });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user