From 60f3fa8e36001d03dbce3760d1dfaa52b005a208 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 15:26:54 -0700 Subject: [PATCH] Add noise floor visualizer to statistics. Closes #129. --- app/services/radio_noise_floor.py | 2 - frontend/package-lock.json | 387 +++++++++++++++++- frontend/package.json | 1 + frontend/src/components/ContactInfoPane.tsx | 232 +++++------ frontend/src/components/RawPacketFeedView.tsx | 164 +++++--- .../settings/SettingsStatisticsSection.tsx | 325 ++++++++++++--- frontend/src/test/settingsModal.test.tsx | 26 +- frontend/src/types.ts | 15 + 8 files changed, 894 insertions(+), 258 deletions(-) diff --git a/app/services/radio_noise_floor.py b/app/services/radio_noise_floor.py index 7790f49..d928821 100644 --- a/app/services/radio_noise_floor.py +++ b/app/services/radio_noise_floor.py @@ -86,8 +86,6 @@ async def stop_noise_floor_sampling() -> None: async def get_noise_floor_history() -> dict: """Return the current 24-hour in-memory noise floor history snapshot.""" - await sample_noise_floor_once(blocking=False) - now = int(time.time()) cutoff = now - NOISE_FLOOR_WINDOW_SECONDS diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 82e416e..69c4495 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.6.1", + "version": "3.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.6.1", + "version": "3.6.2", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", @@ -30,6 +30,7 @@ "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", "react-swipeable": "^7.0.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -2057,6 +2058,42 @@ "react-dom": "^18.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2414,6 +2451,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2564,6 +2613,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, "node_modules/@types/d3-force": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", @@ -2571,6 +2638,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2663,6 +2775,12 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -3712,12 +3830,33 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-binarytree": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", @@ -3727,6 +3866,15 @@ "node": ">=12" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", @@ -3757,12 +3905,42 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-octree": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", "license": "MIT" }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", @@ -3772,6 +3950,58 @@ "node": ">=12" } }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -3820,6 +4050,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3974,6 +4210,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4216,6 +4462,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4618,6 +4870,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4655,6 +4917,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5599,7 +5870,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, @@ -5617,6 +5887,29 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5726,6 +6019,36 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "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.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5740,6 +6063,27 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6134,6 +6478,12 @@ "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6448,12 +6798,43 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "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" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3d91d98..36f6053 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", "react-swipeable": "^7.0.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 1daea1d..59587ce 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -1,5 +1,15 @@ import { type ReactNode, useEffect, useState } from 'react'; import { Ban, Search, Star } from 'lucide-react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { @@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu {hasHourlyActivity && (
Messages Per Hour - value.toFixed(value % 1 === 0 ? 0 : 1)} tickFormatter={(bucket) => @@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu value.toFixed(0)} tickFormatter={(bucket) => new Date(bucket.bucket_start * 1000).toLocaleDateString([], { @@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu ); } -function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) { - return ( -
- {items.map((item) => ( - - - ))} -
- ); -} +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; function ActivityLineChart({ ariaLabel, points, series, + legendItems, tickFormatter, valueFormatter, }: { ariaLabel: string; points: T[]; - series: Array<{ key: keyof T; color: string }>; + series: Array<{ key: keyof T; color: string; label?: string }>; + legendItems?: Array<{ label: string; color: string }>; tickFormatter: (point: T) => string; valueFormatter: (value: number) => string; }) { - const width = 320; - const height = 132; - const padding = { top: 8, right: 8, bottom: 24, left: 32 }; - const plotWidth = width - padding.left - padding.right; - const plotHeight = height - padding.top - padding.bottom; - const allValues = points.flatMap((point) => - series.map((entry) => { - const value = point[entry.key]; - return typeof value === 'number' ? value : 0; - }) - ); - const maxValue = Math.max(1, ...allValues); - const tickIndices = Array.from( - new Set([ - 0, - Math.floor((points.length - 1) / 3), - Math.floor(((points.length - 1) * 2) / 3), - points.length - 1, - ]) - ); + const data = points.map((point, i) => { + const entry: Record = { idx: i, tick: tickFormatter(point) }; + for (const s of series) { + const raw = point[s.key]; + entry[String(s.key)] = typeof raw === 'number' ? raw : 0; + } + return entry; + }); - const buildPolyline = (key: keyof T) => - points - .map((point, index) => { - const rawValue = point[key]; - const value = typeof rawValue === 'number' ? rawValue : 0; - const x = - padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); - const y = padding.top + plotHeight - (value / maxValue) * plotHeight; - return `${x},${y}`; - }) - .join(' '); + const tickCount = Math.min(5, points.length); + const tickIndices: number[] = []; + if (points.length > 1) { + for (let i = 0; i < tickCount; i++) { + tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1))); + } + } return ( -
- - {[0, 0.5, 1].map((ratio) => { - const y = padding.top + plotHeight - ratio * plotHeight; - const value = maxValue * ratio; - return ( - - - - {valueFormatter(value)} - - - ); - })} - - {series.map((entry) => ( - + + + + String(data[idx]?.tick ?? '')} /> - ))} - - {tickIndices.map((index) => { - const point = points[index]; - const x = - padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); - return ( - - {tickFormatter(point)} - - ); - })} - + valueFormatter(v)} + width={40} + /> + String(data[Number(idx)]?.tick ?? '')} + formatter={(value, name) => { + const match = series.find((s) => String(s.key) === name); + return [valueFormatter(Number(value)), match?.label ?? String(name)]; + }} + /> + {legendItems && ( + ( +
+ {legendItems.map((item) => ( + + + {item.label} + + ))} +
+ )} + /> + )} + {series.map((entry) => ( + + ))} + +
); } diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 50f4407..6b04df4 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -1,5 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; import { RawPacketList } from './RawPacketList'; import { RawPacketInspectorDialog } from './RawPacketDetailModal'; @@ -24,6 +34,18 @@ interface RawPacketFeedViewProps { channels: Channel[]; } +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; + const WINDOW_LABELS: Record = { '1m': '1 min', '5m': '5 min', @@ -32,13 +54,7 @@ const WINDOW_LABELS: Record = { session: 'Session', }; -const TIMELINE_COLORS = [ - 'bg-sky-500/80', - 'bg-emerald-500/80', - 'bg-amber-500/80', - 'bg-rose-500/80', - 'bg-violet-500/80', -]; +const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6']; function formatTimestamp(timestampMs: number): string { return new Date(timestampMs).toLocaleString([], { @@ -220,7 +236,13 @@ function RankedBars({ emptyLabel: string; formatter?: (item: RankedPacketStat) => string; }) { - const maxCount = Math.max(...items.map((item) => item.count), 1); + const data = items.map((item) => ({ + name: item.label, + value: item.count, + detail: formatter + ? formatter(item) + : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`, + })); return (
@@ -228,25 +250,36 @@ function RankedBars({ {items.length === 0 ? (

{emptyLabel}

) : ( -
- {items.map((item) => ( -
-
- {item.label} - - {formatter - ? formatter(item) - : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`} - -
-
-
-
-
- ))} +
+ + + + + [props.payload.detail, null]} + /> + + {data.map((_, i) => ( + + ))} + + +
)}
@@ -320,53 +353,66 @@ function NeighborList({ } function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) { - const maxTotal = Math.max(...bins.map((bin) => bin.total), 1); const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice( 0, - TIMELINE_COLORS.length + TIMELINE_FILL_COLORS.length ); + const data = bins.map((bin) => { + const entry: Record = { label: bin.label }; + for (const type of typeOrder) { + entry[type] = bin.countsByType[type] ?? 0; + } + return entry; + }); + return (

Traffic Timeline

- {typeOrder.map((type, index) => ( + {typeOrder.map((type, i) => ( - + {type} ))}
- -
- {bins.map((bin, index) => ( -
-
-
- {typeOrder.map((type, index) => { - const count = bin.countsByType[type] ?? 0; - if (count === 0) return null; - return ( -
- ); - })} -
-
-
{bin.label}
-
- ))} +
+ + + + + + + {typeOrder.map((type, i) => ( + + ))} + +
); diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 626088a..6605a03 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -1,4 +1,16 @@ import { useState, useEffect } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + AreaChart, + Area, + Cell, +} from 'recharts'; import { Separator } from '../ui/separator'; import { api } from '../../api'; import type { StatisticsResponse } from '../../types'; @@ -7,6 +19,94 @@ function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } +const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6']; + +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function NoiseFloorChart({ + samples, +}: { + samples: { timestamp: number; noise_floor_dbm: number }[]; +}) { + const data = samples.map((s, i) => ({ + idx: i, + time: formatTime(s.timestamp), + noise_floor: s.noise_floor_dbm, + })); + + const tickCount = Math.min(6, samples.length); + const tickIndices: number[] = []; + if (samples.length > 1) { + for (let i = 0; i < tickCount; i++) { + tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1))); + } + } + + return ( + + + + data[idx]?.time ?? ''} + /> + `${v}`} + /> + data[Number(idx)]?.time ?? ''} + formatter={(value) => [`${value} dBm`, 'Noise Floor']} + /> + + + + ); +} + export function SettingsStatisticsSection({ className }: { className?: string }) { const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); @@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string }) - {/* Packets */} -
-

Packets

-
-
- Total stored - {stats.total_packets} -
-
- Decrypted - {stats.decrypted_packets} -
-
- Undecrypted - {stats.undecrypted_packets} -
-
-
- - - -
-

Path Hash Width (24h)

-
- Parsed stored raw packets from the last 24 hours:{' '} - {stats.path_hash_width_24h.total_packets} -
-
-
- 1-byte hops - - {stats.path_hash_width_24h.single_byte} ( - {formatPercent(stats.path_hash_width_24h.single_byte_pct)}) - -
-
- 2-byte hops - - {stats.path_hash_width_24h.double_byte} ( - {formatPercent(stats.path_hash_width_24h.double_byte_pct)}) - -
-
- 3-byte hops - - {stats.path_hash_width_24h.triple_byte} ( - {formatPercent(stats.path_hash_width_24h.triple_byte_pct)}) - -
-
-
- - - {/* Activity */}

Activity

@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
+ + + {/* Packets */} +
+

Packets

+
+
+ Total stored + {stats.total_packets} +
+
+ Decrypted + {stats.decrypted_packets} +
+
+ Undecrypted + {stats.undecrypted_packets} +
+
+
+ + + + {/* Path Hash Width */} +
+

Path Hash Width (24h)

+
+ Parsed stored raw packets from the last 24 hours:{' '} + {stats.path_hash_width_24h.total_packets} +
+ {stats.path_hash_width_24h.total_packets > 0 ? ( + + + + + + [ + `${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`, + 'Packets', + ]} + /> + + + + + + + + ) : ( +

No path data in the last 24 hours.

+ )} +
+ {/* Busiest Channels */} {stats.busiest_channels_24h.length > 0 && ( <>

Busiest Channels (24h)

-
- {stats.busiest_channels_24h.map((ch, i) => ( -
- - {i + 1}. - {ch.channel_name} - - {ch.message_count} msgs -
- ))} -
+ + ({ + name: ch.channel_name, + messages: ch.message_count, + }))} + layout="vertical" + margin={{ top: 0, right: 4, bottom: 0, left: 0 }} + barCategoryGap="20%" + > + + + [`${Number(value).toLocaleString()} messages`, null]} + /> + + {stats.busiest_channels_24h.map((_, i) => ( + + ))} + + + +
+ + )} + + {/* Noise Floor */} + {stats.noise_floor_24h.supported !== false && ( + <> + +
+

Noise Floor (24h)

+ {stats.noise_floor_24h.latest_noise_floor_dbm != null && ( +
+ Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm + {stats.noise_floor_24h.latest_timestamp != null && + ` at ${new Date( + stats.noise_floor_24h.latest_timestamp * 1000 + ).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })}`} +
+ )} + {stats.noise_floor_24h.samples.length > 1 ? ( + + ) : stats.noise_floor_24h.samples.length === 0 ? ( +

+ No noise floor samples collected yet. Samples are collected every five minutes, + and retained until server restart. +

+ ) : ( +

+ Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm). + More data needed for a chart. Samples are collected every five minutes, and + retained until server restart. +

+ )}
)} diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index aa96825..ec6d8f1 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -595,6 +595,14 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + noise_floor_24h: { + sample_interval_seconds: 300, + coverage_seconds: 3600, + latest_noise_floor_dbm: -105, + latest_timestamp: 1711800000, + supported: true, + samples: [], + }, }; vi.spyOn(globalThis, 'fetch').mockResolvedValue( @@ -626,17 +634,11 @@ describe('SettingsModal', () => { expect( screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/) ).toBeInTheDocument(); - expect(screen.getByText('1-byte hops')).toBeInTheDocument(); - expect(screen.getByText('60 (50.0%)')).toBeInTheDocument(); - expect(screen.getByText('36 (30.0%)')).toBeInTheDocument(); - expect(screen.getByText('24 (20.0%)')).toBeInTheDocument(); expect(screen.getByText('Contacts heard')).toBeInTheDocument(); expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); expect(screen.getByText('Known-channels active')).toBeInTheDocument(); - - // Busiest channels - expect(screen.getByText('general')).toBeInTheDocument(); - expect(screen.getByText('42 msgs')).toBeInTheDocument(); + expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument(); + expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument(); }); it('fetches statistics when expanded in mobile external-nav mode', async () => { @@ -663,6 +665,14 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + noise_floor_24h: { + sample_interval_seconds: 300, + coverage_seconds: 0, + latest_noise_floor_dbm: null, + latest_timestamp: null, + supported: null, + samples: [], + }, }; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2f1d7e0..d54a573 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -505,6 +505,20 @@ interface ContactActivityCounts { last_week: number; } +export interface NoiseFloorSample { + timestamp: number; + noise_floor_dbm: number; +} + +export interface NoiseFloorHistoryStats { + sample_interval_seconds: number; + coverage_seconds: number; + latest_noise_floor_dbm: number | null; + latest_timestamp: number | null; + supported: boolean | null; + samples: NoiseFloorSample[]; +} + export interface StatisticsResponse { busiest_channels_24h: BusyChannel[]; contact_count: number; @@ -528,4 +542,5 @@ export interface StatisticsResponse { double_byte_pct: number; triple_byte_pct: number; }; + noise_floor_24h: NoiseFloorHistoryStats; }