mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 02:53:00 +02:00
Add noise floor visualizer to statistics. Closes #129.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
387
frontend/package-lock.json
generated
387
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
/>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per hour"
|
||||
points={analytics.hourly_activity}
|
||||
series={[
|
||||
{ key: 'last_24h_count', color: '#2563eb' },
|
||||
{ key: 'last_week_average', color: '#ea580c' },
|
||||
{ key: 'all_time_average', color: '#64748b' },
|
||||
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||
]}
|
||||
legendItems={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||
tickFormatter={(bucket) =>
|
||||
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per week"
|
||||
points={analytics.weekly_activity}
|
||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
||||
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||
valueFormatter={(value) => 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 (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||
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<string, string | number> = { 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 (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
||||
const value = maxValue * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 6}
|
||||
y={y + 4}
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((entry) => (
|
||||
<polyline
|
||||
key={String(entry.key)}
|
||||
fill="none"
|
||||
stroke={entry.color}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={buildPolyline(entry.key)}
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, Math.max(1, points.length - 1)]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => 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 (
|
||||
<text
|
||||
key={`${ariaLabel}-${point.bucket_start}`}
|
||||
x={x}
|
||||
y={height - 6}
|
||||
fontSize="10"
|
||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{tickFormatter(point)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => valueFormatter(v)}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => 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 && (
|
||||
<Legend
|
||||
content={() => (
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||
{legendItems.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{series.map((entry) => (
|
||||
<Line
|
||||
key={String(entry.key)}
|
||||
type="linear"
|
||||
dataKey={String(entry.key)}
|
||||
stroke={entry.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
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 (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
@@ -228,25 +250,36 @@ function RankedBars({
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={80}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -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<string, string | number> = { label: bin.label };
|
||||
for (const type of typeOrder) {
|
||||
entry[type] = bin.countsByType[type] ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
/>
|
||||
{typeOrder.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="packets"
|
||||
fill={TIMELINE_FILL_COLORS[i]}
|
||||
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, samples.length - 1]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => data[idx]?.time ?? ''}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={['dataMin - 5', 'dataMax + 5']}
|
||||
tickFormatter={(v) => `${v}`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
|
||||
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="noise_floor"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf6"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>1-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.single_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>2-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.double_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>3-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.triple_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Path Hash Width */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
{stats.path_hash_width_24h.total_packets > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: '1-byte',
|
||||
count: stats.path_hash_width_24h.single_byte,
|
||||
pct: stats.path_hash_width_24h.single_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '2-byte',
|
||||
count: stats.path_hash_width_24h.double_byte,
|
||||
pct: stats.path_hash_width_24h.double_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '3-byte',
|
||||
count: stats.path_hash_width_24h.triple_byte,
|
||||
pct: stats.path_hash_width_24h.triple_byte_pct,
|
||||
},
|
||||
]}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, _: any, props: any) => [
|
||||
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
|
||||
'Packets',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||||
<Cell fill="#0ea5e9" />
|
||||
<Cell fill="#10b981" />
|
||||
<Cell fill="#f59e0b" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.busiest_channels_24h.map((ch, i) => (
|
||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span>
|
||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
||||
{ch.channel_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||
>
|
||||
<BarChart
|
||||
data={stats.busiest_channels_24h.map((ch) => ({
|
||||
name: ch.channel_name,
|
||||
messages: ch.message_count,
|
||||
}))}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
|
||||
/>
|
||||
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{stats.busiest_channels_24h.map((_, i) => (
|
||||
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Noise Floor */}
|
||||
{stats.noise_floor_24h.supported !== false && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
|
||||
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
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',
|
||||
})}`}
|
||||
</div>
|
||||
)}
|
||||
{stats.noise_floor_24h.samples.length > 1 ? (
|
||||
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
|
||||
) : stats.noise_floor_24h.samples.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No noise floor samples collected yet. Samples are collected every five minutes,
|
||||
and retained until server restart.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user