From dff9729583e3730497d09b9d09d20024bb007666 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:18:10 -0400 Subject: [PATCH] v1.2.0 (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes * feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count * fix: github workflow, post images publically * build(deps): bump undici from 7.27.2 to 7.28.0 (#9) Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: discover renderers, neighbor map lines, live packet flow - Discover packets decoded: DISCOVER_REQ/DISCOVER_RESP render real fields (type-filter roles, tag, node-to-node request SNR) instead of a raw dump. - Neighbor lines on the map: On/Selected/Off control draws links between nodes and their known neighbors. - Live packet flow: a play button animates packets hop-to-hop between repeaters in real time (off by default). * map: live mode dims nodes, flashes each packet's route * map: shoot a comet along each packet's route in live mode * map: rework live mode — packet dot, dashed trail, per-hop flash * map: fade live-mode clusters more so dense ones stay see-through * map: persist clustering + node-type, default neighbor lines to selected * map: fade live-mode node markers more so overlaps stay see-through * map: fade the live-mode node flash in sync with its packet trail * nodes: let the mobile detail panel minimize without deselecting * map: dim other nodes to spotlight a selected node and its neighbours * map: colour a selected node's neighbour lines by obs count and freshness * Add a neighbour graph under the renamed Analytics tab * Focus the neighbour graph into an ego view on click * Wrap the analytics sub-header so the range stays on-screen on mobile * Use a dropdown for the analytics sections on mobile * Gate the neighbour graph to a region and size labels by busyness * Add a name search that spotlights matches and dims the rest of the mesh * Add a copy button beside node and observer public keys * Add a copy link to node and observer detail panels Deep-linked ?node/?observer selections now survive load: the region reset watches the raw selection instead of the async-resolved regionKey, so slug expansion no longer wipes a restored panel. * Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link * Make VITE_ENABLED_THEMES an exclusive theme allowlist * Add a .env option to skip the load splash * Keep the packets list static while scrolled down instead of jumping on new packets * Add Map deep-links for camera and settings with a copy-link button * Filter packet history on the server when a single type, route or scope is selected * Show loading feedback while packet history loads * build(deps): bump echarts from 5.5.1 to 6.1.0 (#14) * Fix: github workflow + stats tab + add traces vs pings (#10) * stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes * feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count * fix: github workflow, post images publically * build(deps): bump echarts from 5.5.1 to 6.1.0 Bumps [echarts](https://github.com/apache/echarts) from 5.5.1 to 6.1.0. - [Release notes](https://github.com/apache/echarts/releases) - [Commits](https://github.com/apache/echarts/compare/5.5.1...6.1.0) --- updated-dependencies: - dependency-name: echarts dependency-version: 6.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump version to 1.2.0 --------- Signed-off-by: dependabot[bot] Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .build/Dockerfile | 4 + .build/docker-entrypoint.sh | 10 + .env.example | 14 + docker/.env.example | 14 + docker/docker-compose.yml | 6 + package-lock.json | 121 ++++++-- package.json | 4 +- public/themes.json | 44 +++ src/App.tsx | 64 ++-- src/api/client.ts | 7 +- src/api/ws-manager.ts | 22 ++ src/components/AppShell.tsx | 32 +- src/components/BeaconWordmark.tsx | 7 +- src/components/BottomNav.tsx | 38 ++- src/components/CloseButton.tsx | 2 +- src/components/CopyButton.tsx | 37 +++ src/components/CopyLinkButton.tsx | 43 +++ src/components/DetailPanel.tsx | 41 ++- src/components/MultiSelectDropdown.tsx | 2 +- src/components/SearchBar.tsx | 2 +- src/components/SelectDropdown.tsx | 4 +- src/components/SplashScreen.tsx | 5 +- src/features/channels/MessagePanel.tsx | 2 +- src/features/map/MapSettingsPanel.tsx | 52 +++- src/features/map/MapView.tsx | 117 +++++++- src/features/map/PacketFlowButton.tsx | 35 +++ src/features/map/SegmentedControl.tsx | 2 +- src/features/map/map-url.ts | 104 +++++++ src/features/map/neighbor-thresholds.ts | 10 + src/features/map/node-geojson.ts | 109 ++++++- src/features/map/packet-flow.ts | 41 +++ src/features/map/types.ts | 28 ++ src/features/map/useMapLibre.ts | 19 +- src/features/map/useMapNeighbors.ts | 105 +++++++ src/features/map/useMapNodes.ts | 41 ++- src/features/map/useMapNodesData.ts | 7 +- src/features/map/useMapPacketFlow.ts | 219 ++++++++++++++ src/features/nodes/NodeDetailPanel.tsx | 15 +- .../observers/ObserverDetailPanel.tsx | 12 +- src/features/packets/ObservationCard.tsx | 2 +- src/features/packets/PacketAnalyzerDrawer.tsx | 31 +- src/features/packets/PacketList.tsx | 94 ++++-- src/features/packets/PacketVirtualList.tsx | 83 ++---- src/features/packets/packet-structure.tsx | 14 +- src/features/packets/payload-renderers.tsx | 81 +++--- src/features/packets/types.ts | 7 + src/features/packets/usePacketFilters.ts | 12 +- src/features/packets/usePackets.ts | 35 ++- src/features/stats/EChart.tsx | 10 +- src/features/stats/MeshTab.tsx | 10 - src/features/stats/NeighbourGraph.tsx | 61 ++++ src/features/stats/NeighbourGraphTab.tsx | 99 +++++++ src/features/stats/ObserverTab.tsx | 2 +- src/features/stats/Segmented.tsx | 2 +- src/features/stats/StatsOverview.tsx | 9 +- src/features/stats/StatsSubHeader.tsx | 19 ++ src/features/stats/chartTheme.ts | 14 +- src/features/stats/echarts-setup.ts | 3 +- src/features/stats/neighbour-graph.ts | 250 ++++++++++++++++ src/features/stats/types.ts | 2 +- src/hooks/useInfinitePages.ts | 10 +- src/hooks/useTheme.tsx | 6 +- src/lib/constants.ts | 53 +++- src/lib/themes.ts | 2 + src/types/ws.ts | 12 + tests/App.selectionReset.test.tsx | 40 +++ tests/api/client.test.ts | 27 ++ tests/api/ws-manager.test.ts | 47 +++ tests/components/BottomNav.test.tsx | 2 +- tests/components/CopyButton.test.tsx | 49 ++++ tests/components/CopyLinkButton.test.tsx | 75 +++++ tests/components/DetailPanel.test.tsx | 43 +++ tests/features/map/map-url.test.ts | 134 +++++++++ tests/features/map/node-geojson.test.ts | 130 ++++++++- tests/features/map/packet-flow.test.ts | 43 +++ tests/features/map/useMapNodesData.test.tsx | 4 +- tests/features/packets/PacketList.test.tsx | 106 ++++++- .../packets/payload-renderers.test.tsx | 52 ++++ .../features/packets/usePacketFilters.test.ts | 38 ++- tests/features/packets/usePackets.test.tsx | 245 +++++++++++++++- tests/features/stats/neighbour-graph.test.ts | 275 ++++++++++++++++++ tests/lib/constants.test.ts | 75 +++++ 82 files changed, 3418 insertions(+), 331 deletions(-) create mode 100644 src/components/CopyButton.tsx create mode 100644 src/components/CopyLinkButton.tsx create mode 100644 src/features/map/PacketFlowButton.tsx create mode 100644 src/features/map/map-url.ts create mode 100644 src/features/map/neighbor-thresholds.ts create mode 100644 src/features/map/packet-flow.ts create mode 100644 src/features/map/useMapNeighbors.ts create mode 100644 src/features/map/useMapPacketFlow.ts create mode 100644 src/features/stats/NeighbourGraph.tsx create mode 100644 src/features/stats/NeighbourGraphTab.tsx create mode 100644 src/features/stats/neighbour-graph.ts create mode 100644 tests/App.selectionReset.test.tsx create mode 100644 tests/components/CopyButton.test.tsx create mode 100644 tests/components/CopyLinkButton.test.tsx create mode 100644 tests/components/DetailPanel.test.tsx create mode 100644 tests/features/map/map-url.test.ts create mode 100644 tests/features/map/packet-flow.test.ts create mode 100644 tests/features/stats/neighbour-graph.test.ts create mode 100644 tests/lib/constants.test.ts diff --git a/.build/Dockerfile b/.build/Dockerfile index ea6449b..2b83f38 100644 --- a/.build/Dockerfile +++ b/.build/Dockerfile @@ -7,6 +7,10 @@ ENV VITE_API_BASE=__VITE_API_BASE__ ENV VITE_WS_URL=__VITE_WS_URL__ ENV VITE_MAP_CENTER=__VITE_MAP_CENTER__ ENV VITE_MAP_ZOOM=__VITE_MAP_ZOOM__ +ENV VITE_DISABLED_TABS=__VITE_DISABLED_TABS__ +ENV VITE_ENABLED_THEMES=__VITE_ENABLED_THEMES__ +ENV VITE_APP_NAME=__VITE_APP_NAME__ +ENV VITE_SKIP_SPLASH=__VITE_SKIP_SPLASH__ RUN npm run build FROM caddy:2-alpine diff --git a/.build/docker-entrypoint.sh b/.build/docker-entrypoint.sh index b844b89..a08b8e5 100644 --- a/.build/docker-entrypoint.sh +++ b/.build/docker-entrypoint.sh @@ -6,12 +6,22 @@ WS_URL="${VITE_WS_URL:-ws://localhost:8080/ws}" # Optional map view — leave empty when unset; the app falls back to a world overview. MAP_CENTER="${VITE_MAP_CENTER:-}" MAP_ZOOM="${VITE_MAP_ZOOM:-}" +# Optional branding/customization — empty means default behaviour (all tabs, hidden themes hidden). +DISABLED_TABS="${VITE_DISABLED_TABS:-}" +ENABLED_THEMES="${VITE_ENABLED_THEMES:-}" +SKIP_SPLASH="${VITE_SKIP_SPLASH:-}" +# APP_NAME is substituted verbatim; avoid '&' and '|' (sed replacement metachar and delimiter). +APP_NAME="${VITE_APP_NAME:-BEACON}" find /srv -name '*.js' -exec sed -i \ -e "s|__VITE_API_BASE__|${API_BASE}|g" \ -e "s|__VITE_WS_URL__|${WS_URL}|g" \ -e "s|__VITE_MAP_CENTER__|${MAP_CENTER}|g" \ -e "s|__VITE_MAP_ZOOM__|${MAP_ZOOM}|g" \ + -e "s|__VITE_DISABLED_TABS__|${DISABLED_TABS}|g" \ + -e "s|__VITE_ENABLED_THEMES__|${ENABLED_THEMES}|g" \ + -e "s|__VITE_APP_NAME__|${APP_NAME}|g" \ + -e "s|__VITE_SKIP_SPLASH__|${SKIP_SPLASH}|g" \ {} + exec "$@" diff --git a/.env.example b/.env.example index adc707a..91b7213 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,17 @@ # selection has no airport coords; otherwise the map fits bounds over the airports. Unset = world. # VITE_MAP_CENTER=52.5,-96.8 # VITE_MAP_ZOOM=3.2 + +# Hide tabs completely (comma list, case-insensitive). Unset = show all. +# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# VITE_DISABLED_TABS=Map,Traces + +# Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none. +# Options: meshmapper_dark,meshmapper_light +# VITE_ENABLED_THEMES=meshmapper_dark,meshmapper_light + +# Custom top-left app name (default: BEACON). Avoid the & and | characters. +# VITE_APP_NAME=Beacon - MeshMapper + +# Skip the once-per-session load splash. Unset = shown. +# VITE_SKIP_SPLASH=true diff --git a/docker/.env.example b/docker/.env.example index 732972e..1ae20a3 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -11,3 +11,17 @@ VITE_WS_URL=ws://localhost:8080/ws # decimal "lat,lon", VITE_MAP_ZOOM is 0-22. The example below is a Canada-wide view. VITE_MAP_CENTER=52.5,-96.8 VITE_MAP_ZOOM=3.2 + +# Hide tabs completely (comma list, case-insensitive). Leave empty to show all. +# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +VITE_DISABLED_TABS= + +# Reveal normally-hidden themes in the picker (comma list of theme ids). Leave empty for none. +# Options: meshmapper_dark,meshmapper_light +VITE_ENABLED_THEMES= + +# Custom app name shown in the top-left wordmark. Default: BEACON. Avoid the & and | characters. +VITE_APP_NAME= + +# Skip the once-per-session load splash. Set to true to skip; leave empty to show it. +VITE_SKIP_SPLASH= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f449012..2efed27 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,4 +20,10 @@ services: # Optional "All" map view; unset = world overview - VITE_MAP_CENTER=${VITE_MAP_CENTER:-} - VITE_MAP_ZOOM=${VITE_MAP_ZOOM:-} + # Optional branding/customization; unset = all tabs, hidden themes hidden, name "BEACON" + - VITE_DISABLED_TABS=${VITE_DISABLED_TABS:-} + - VITE_ENABLED_THEMES=${VITE_ENABLED_THEMES:-} + - VITE_APP_NAME=${VITE_APP_NAME:-BEACON} + # Skip the once-per-session load splash; unset = shown + - VITE_SKIP_SPLASH=${VITE_SKIP_SPLASH:-} restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index 3baa125..f289c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "beacon-web", - "version": "0.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "0.0.0", + "version": "1.2.0", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", - "echarts": "5.5.1", + "echarts": "6.1.0", "maplibre-gl": "^5.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -128,7 +128,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -449,7 +448,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -498,7 +496,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1464,6 +1461,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -1666,7 +1729,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1719,7 +1783,6 @@ "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1730,7 +1793,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1741,7 +1803,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1791,7 +1852,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -2135,7 +2195,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2176,6 +2235,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2186,6 +2246,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2279,7 +2340,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2459,7 +2519,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/earcut": { "version": "3.0.2", @@ -2468,13 +2529,13 @@ "license": "ISC" }, "node_modules/echarts": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz", - "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", "license": "Apache-2.0", "dependencies": { "tslib": "2.3.0", - "zrender": "5.6.0" + "zrender": "6.1.0" } }, "node_modules/electron-to-chromium": { @@ -2547,7 +2608,6 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3439,6 +3499,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3707,7 +3768,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3766,6 +3826,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3802,7 +3863,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3812,7 +3872,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3825,7 +3884,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-router": { "version": "7.17.0", @@ -4196,7 +4256,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4230,9 +4289,9 @@ } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -4293,7 +4352,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4590,7 +4648,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4609,9 +4666,9 @@ } }, "node_modules/zrender": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz", - "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", "license": "BSD-3-Clause", "dependencies": { "tslib": "2.3.0" diff --git a/package.json b/package.json index 1d9e3dd..020d96b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "1.0.0", + "version": "1.2.0", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { @@ -20,7 +20,7 @@ "@nazka/map-gl-js-spiderfy": "^2.0.0", "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", - "echarts": "5.5.1", + "echarts": "6.1.0", "maplibre-gl": "^5.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/public/themes.json b/public/themes.json index 29a089c..21a161f 100644 --- a/public/themes.json +++ b/public/themes.json @@ -334,5 +334,49 @@ "--palette-text-muted": "#6B8A8A", "--palette-text-dim": "#4D6A6A" } + }, + { + "id": "meshmapper_dark", + "name": "MeshMapper Dark", + "hidden": true, + "vars": { + "--palette-bg-base": "#121212", + "--palette-bg-surface": "#1E1E1E", + "--palette-bg-raised": "#2A2A2A", + "--palette-border": "#333333", + "--palette-border-subtle": "#242424", + "--palette-primary": "#00D2FF", + "--palette-primary-dim": "#00A5C8", + "--palette-secondary": "#4A9EFF", + "--palette-green": "#28A745", + "--palette-danger": "#DC3545", + "--palette-warn": "#FFC107", + "--palette-text-bright": "#FFFFFF", + "--palette-text-normal": "#E0E0E0", + "--palette-text-muted": "#AAAAAA", + "--palette-text-dim": "#777777" + } + }, + { + "id": "meshmapper_light", + "name": "MeshMapper Light", + "hidden": true, + "vars": { + "--palette-bg-base": "#F4F4F9", + "--palette-bg-surface": "#FFFFFF", + "--palette-bg-raised": "#FFFFFF", + "--palette-border": "#DDDDDD", + "--palette-border-subtle": "#EEEEEE", + "--palette-primary": "#007A94", + "--palette-primary-dim": "#005F73", + "--palette-secondary": "#0062CC", + "--palette-green": "#1E7E34", + "--palette-danger": "#C82333", + "--palette-warn": "#9A6A00", + "--palette-text-bright": "#1A1A1A", + "--palette-text-normal": "#333333", + "--palette-text-muted": "#666666", + "--palette-text-dim": "#767676" + } } ] diff --git a/src/App.tsx b/src/App.tsx index afb2274..73637fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,7 @@ import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; import { getPacketDetail } from "./api/client"; import { WsManager } from "./api/ws-manager"; -import { WS_URL, TABS } from "./lib/constants"; +import { WS_URL, ENABLED_TABS } from "./lib/constants"; // Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the // first time someone opens the Map tab instead of bloating the initial bundle. @@ -96,19 +96,20 @@ function RegionUrlSync() { return null; } -// Drop the shared node selection when the region changes, so the detail panel doesn't keep showing a -// node that's no longer in the re-queried map/table. Lives inside RegionProvider so it can read useRegion. -function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () => void }) { - const { regionKey } = useRegion(); - const first = useRef(true); +// Drop the shared node/observer selection when the user changes region, so a detail panel doesn't keep +// showing an entity that's no longer in the re-queried map/table. Watches the raw selection rather than +// the resolved regionKey: the async slug→IATA expansion on load bumps regionKey without any user action, +// and that must NOT count as a change or it would wipe a deep-linked ?node/?observer before it renders. +// Comparing the previous selection (vs a first-run flag) also survives StrictMode's double effect invoke. +export function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () => void }) { + const { selection } = useRegionSelection(); + const prev = useRef(selection); useEffect(() => { - if (first.current) { - first.current = false; // skip the initial mount; only react to a real change - return; - } + if (prev.current === selection) return; // initial mount, or a re-render that didn't change the selection + prev.current = selection; onRegionChange(); - }, [regionKey, onRegionChange]); + }, [selection, onRegionChange]); return null; } @@ -120,16 +121,18 @@ function AppInner() { const isMobile = useIsMobile(); // The URL is the single source of truth for the active tab — back/forward just work, and an // unknown ?tab value falls back to Packets instead of rendering a blank pane. - const tabParam = searchParams.get("tab"); - const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets"; + // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. + const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); + const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); + // ?hash / ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button) const [analyzerHash, setAnalyzerHash] = useState(() => searchParams.get("hash")); const [selectedObservationId, setSelectedObservationId] = useState(null); - const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts - const [selectedObserverId, setSelectedObserverId] = useState(null); + const [selectedObserverId, setSelectedObserverId] = useState(() => searchParams.get("observer")); // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) const [overlayNodeId, setOverlayNodeId] = useState(null); // packet analyzer shown as a modal over the node panel (clicking a node's observation row) @@ -171,7 +174,7 @@ function AppInner() { const next = new URLSearchParams(prev); next.set("tab", tab); // stats sub-state shouldn't haunt the URL on other tabs - if (tab !== "Stats") { + if (tab !== "Analytics") { next.delete("statsTab"); next.delete("observerId"); next.delete("range"); @@ -187,6 +190,27 @@ function AppInner() { setSelectedObserverId(null); }, []); + // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet + // analyzer's ?hash cleanup). Selecting a different node/observer doesn't touch the URL — the panel's + // Copy Link button rebuilds a fresh link on demand. + const dropSelectionParam = useCallback((key: "node" | "observer") => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete(key); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const handleCloseNode = useCallback(() => { + setSelectedNodeId(null); + dropSelectionParam("node"); + }, [dropSelectionParam]); + + const handleSelectObserver = useCallback((id: string | null) => { + setSelectedObserverId(id); + if (id === null) dropSelectionParam("observer"); + }, [dropSelectionParam]); + // Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected). const handleViewObserverStats = useCallback( (id: string) => { @@ -194,7 +218,7 @@ function AppInner() { setOverlayPacketHash(null); setSearchParams((prev) => { const next = new URLSearchParams(prev); - next.set("tab", "Stats"); + next.set("tab", "Analytics"); next.set("statsTab", "observer"); next.set("observerId", id); return next; @@ -214,13 +238,13 @@ function AppInner() { const tabContent: Record = { Packets: , Nodes: , - Observers: , + Observers: , Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: , Channels: , - Stats: , + Analytics: , Map: , }; @@ -249,7 +273,7 @@ function AppInner() { {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( setSelectedNodeId(null)} + onClose={handleCloseNode} onViewObserver={(observerId) => { handleTabChange("Observers"); setSelectedObserverId(observerId); diff --git a/src/api/client.ts b/src/api/client.ts index c085d8a..116ca58 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -58,12 +58,15 @@ function iatasParam(iatas?: string[]): string | undefined { export function getPackets( iatas: string[] | undefined, - params?: { cursor?: number; limit?: number }, + params?: { cursor?: number; limit?: number; payloadType?: number; routeType?: number; scope?: string }, ): Promise> { return request("/packets", { iatas: iatasParam(iatas), cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, + payloadType: params?.payloadType, + routeType: params?.routeType, + scope: params?.scope, }); } @@ -208,6 +211,7 @@ export function getNodesPage( name?: string; supportsMultibytePaths?: "true" | "false"; supportsMultibyteTraces?: "true" | "false"; + neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, ): Promise> { return request("/nodes", { @@ -218,6 +222,7 @@ export function getNodesPage( name: params?.name, supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, + neighbors: params?.neighbors ? "true" : undefined, }); } diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index 93f71a4..ffda8b4 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -21,6 +21,9 @@ export class WsManager { private ws: WebSocket | null = null; private url: string; private filter: SubscriptionFilter | null = null; + // connection-wide toggle for resolvedPath on packetObservation events; survives reconnect (like + // filter), re-applied on each hello. Default off keeps the event payload small. + private resolvePath = false; private subscriptionId: string | null = null; private lastSubscribeId: string | null = null; private everConnected = false; @@ -113,6 +116,16 @@ export class WsManager { this.sendSubscribe(); } + // Enable/disable per-hop resolvedPath data on packetObservation events for the whole connection. + // Stored so it re-applies after a reconnect; sent immediately when the socket is already open. + setResolvePath(enabled: boolean): void { + if (this.resolvePath === enabled) return; + this.resolvePath = enabled; + if (this.ws?.readyState === WebSocket.OPEN) { + this.sendConfigure(); + } + } + disconnect(): void { this.intentionalClose = true; this.reconnectAttempt = 0; @@ -174,6 +187,7 @@ export class WsManager { this.setStatus("connected"); this.startPing(); this.sendSubscribe(); + if (this.resolvePath) this.sendConfigure(); // re-apply the connection-wide toggle if (isReconnect) { // we were dark during the outage — synthesize a lag notice so live views heal the gap const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp }; @@ -193,6 +207,10 @@ export class WsManager { } break; + case "configured": + // ack for our resolvePath toggle; nothing to do beyond the server now honoring it + break; + case "pong": // a pong proves the link is alive, so it counts as recent activity this.lastEventTimestamp = Date.now(); @@ -244,6 +262,10 @@ export class WsManager { }); } + private sendConfigure(): void { + this.send({ v: 1, type: "configure", id: `cfg-${this.nextId()}`, resolvePath: this.resolvePath }); + } + private startPing(): void { if (this.pingTimer) clearInterval(this.pingTimer); // a second hello must not double the interval this.pingTimer = setInterval(() => { diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 07205a1..11a0254 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -9,7 +9,7 @@ import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; -import { TABS } from "../lib/constants"; +import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants"; import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker @@ -131,7 +131,7 @@ function RegionSelector() { className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ isAllRegions(selection) ? "text-text-bright bg-primary/10" - : "text-text-muted hover:text-text-normal hover:bg-white/3" + : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => setSelection(ALL_REGIONS)} > @@ -151,7 +151,7 @@ function RegionSelector() { key={r.slug} type="button" className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ - checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3" + checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => toggleRegion(r.slug)} > @@ -172,7 +172,7 @@ function RegionSelector() { key={i.iata} type="button" className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ - checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3" + checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => toggleIata(i.iata)} > @@ -196,6 +196,7 @@ function RegionSelector() { function ThemePicker() { const { themeId, themes, setThemeId } = useTheme(); const current = themes.find((t) => t.id === themeId); + const list = selectableThemes(themes, ENABLED_THEME_IDS); return ( ▾ @@ -215,14 +216,14 @@ function ThemePicker() { > {(close) => ( <> - {themes.map((t) => ( + {list.map((t) => ( + ); +} diff --git a/src/features/map/SegmentedControl.tsx b/src/features/map/SegmentedControl.tsx index 071b41a..64d0fc0 100644 --- a/src/features/map/SegmentedControl.tsx +++ b/src/features/map/SegmentedControl.tsx @@ -28,7 +28,7 @@ export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = f : "border-border text-text-muted hover:text-text-normal hover:border-text-dim" }` : `flex-1 px-2.5 py-1 font-mono text-[11px] transition-colors cursor-pointer ${ - active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3" + active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`; return ( diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts new file mode 100644 index 0000000..d294318 --- /dev/null +++ b/src/features/map/map-url.ts @@ -0,0 +1,104 @@ +// Deep-link map view <-> URL params. Pure and maplibre-free so it stays unit-testable; mirrors the +// region-selection.ts pattern. Inbound parsing is lenient — any invalid/unknown value is dropped so a +// malformed link degrades to the normal view rather than breaking. See docs/superpowers/specs. +import { MAP_STYLES, type NeighborLinesMode } from "./types"; +import { NODE_TYPE_NAMES } from "../../lib/node-types"; + +// A parsed view carries only the fields whose params were present AND valid. +export interface ParsedMapView { + center?: [number, number]; // [lng, lat] + zoom?: number; + clustered?: boolean; + nodeType?: string; + neighborLines?: NeighborLinesMode; + styleId?: string; + flow?: boolean; +} + +// The live map state a copy-link snapshot is built from (every field concrete). +export interface MapViewSnapshot { + center: [number, number]; // [lng, lat] + zoom: number; + clustered: boolean; + nodeType: string; // "" = All + neighborLines: NeighborLinesMode; + styleId: string; + flow: boolean; +} + +const NEIGHBOR_MODES: NeighborLinesMode[] = ["on", "selected", "off"]; + +function parseCoord(lat: string | null, lng: string | null): [number, number] | undefined { + if (lat === null || lng === null) return undefined; + const latN = Number.parseFloat(lat); + const lngN = Number.parseFloat(lng); + if (!Number.isFinite(latN) || !Number.isFinite(lngN) || Math.abs(latN) > 90 || Math.abs(lngN) > 180) { + return undefined; + } + return [lngN, latN]; +} + +function parseZoom(raw: string | null): number | undefined { + if (raw === null) return undefined; + const z = Number.parseFloat(raw); + return Number.isFinite(z) && z >= 0 && z <= 22 ? z : undefined; +} + +function parseBool(raw: string | null): boolean | undefined { + const v = raw?.toLowerCase(); + if (v === "on") return true; + if (v === "off") return false; + return undefined; +} + +export function parseMapView(params: URLSearchParams): ParsedMapView { + const view: ParsedMapView = {}; + + const center = parseCoord(params.get("lat"), params.get("lng")); + if (center) view.center = center; + + const zoom = parseZoom(params.get("zoom")); + if (zoom !== undefined) view.zoom = zoom; + + const clustered = parseBool(params.get("clustering")); + if (clustered !== undefined) view.clustered = clustered; + + const type = params.get("node_type")?.toLowerCase(); + if (type && NODE_TYPE_NAMES.includes(type as (typeof NODE_TYPE_NAMES)[number])) view.nodeType = type; + + const neighbor = params.get("neighbor_lines")?.toLowerCase(); + if (neighbor && NEIGHBOR_MODES.includes(neighbor as NeighborLinesMode)) { + view.neighborLines = neighbor as NeighborLinesMode; + } + + const style = params.get("style")?.toLowerCase(); + const match = style && MAP_STYLES.find((s) => s.id.toLowerCase() === style); + if (match) view.styleId = match.id; + + const flow = parseBool(params.get("flow")); + if (flow !== undefined) view.flow = flow; + + return view; +} + +// Round to `dp` decimals and stringify without trailing-zero noise (45.3 stays "45.3"). +function round(n: number, dp: number): string { + const f = 10 ** dp; + return String(Math.round(n * f) / f); +} + +// Authoritative param map for a copy-link snapshot: every managed key set to its current value, with +// node_type deleted (null) when All so a stale ?node_type can't survive. Region/tab are handled by the +// caller (the URL is built from the address bar, which already carries ?iata). +export function buildMapParams(view: MapViewSnapshot): Record { + return { + lat: round(view.center[1], 5), + lng: round(view.center[0], 5), + zoom: round(view.zoom, 2), + clustering: view.clustered ? "on" : "off", + node_type: view.nodeType || null, + neighbor_lines: view.neighborLines, + style: view.styleId, + flow: view.flow ? "on" : "off", + }; +} diff --git a/src/features/map/neighbor-thresholds.ts b/src/features/map/neighbor-thresholds.ts new file mode 100644 index 0000000..c19557c --- /dev/null +++ b/src/features/map/neighbor-thresholds.ts @@ -0,0 +1,10 @@ +// Shared thresholds for styling neighbour links by observation count and freshness. Both the map's +// line layer (useMapNeighbors) and the Analytics neighbour graph read these, so the two views can't +// drift apart. + +// Observation counts are heavily right-skewed, so colour on a log10 axis: ~1 obs red, ~20 yellow, +// ~150+ green. Stops are log10(count) values. +export const OBS_STOPS = { danger: 0, warn: 1.3, green: 2.18 } as const; + +// A link's opacity fades with age — solid when fresh, faint by ~4 weeks (matches the 30-day retention). +export const AGE = { freshDays: 0, freshOp: 0.9, staleDays: 28, staleOp: 0.35 } as const; diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index eb6fef3..e6452d6 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -1,5 +1,5 @@ -import type { Feature, FeatureCollection, Point } from "geojson"; -import type { NodeSummary } from "../nodes/types"; +import type { Feature, FeatureCollection, LineString, Point } from "geojson"; +import type { NodeSummary, NodeNeighbor } from "../nodes/types"; // Build the maplibre GeoJSON source from the nodes API response. Properties stay primitive because // clustering serializes them, and there's no maplibre import, so this stays unit-testable. @@ -33,6 +33,111 @@ export function nodesToFeatureCollection( return { type: "FeatureCollection", features }; } +export interface NeighborEdgeProps { + selected: boolean; // incident to the currently selected node — styled brighter + // Present only on the selected node's edges (buildFocusedNeighborEdges): total observations of the + // link and its age in days. Drive the obs→color gradient and the freshness fade; absent edges (the + // ambient "on" mesh) render uniform. + obs?: number; + ageDays?: number; +} + +// LineString edges between located nodes and their neighbors (from each node's neighborIds). Each +// undirected pair is emitted once, and only when both ends are located nodes in this set. "selected" +// keeps just the selected node's edges; "on" emits all and flags its edges with the `selected` prop. +export function buildNeighborEdges( + nodes: NodeSummary[], + mode: "on" | "selected", + selectedId: string | null, +): FeatureCollection { + const located = new Map(); + for (const n of nodes) { + if (n.lat != null && n.lng != null) located.set(n.id, n); + } + + const seen = new Set(); + const features: Feature[] = []; + for (const n of nodes) { + if (n.lat == null || n.lng == null || !n.neighborIds) continue; + for (const otherId of n.neighborIds) { + if (otherId === n.id) continue; // a node listing itself would draw a zero-length edge + const other = located.get(otherId); + if (!other) continue; + const key = n.id < otherId ? `${n.id}|${otherId}` : `${otherId}|${n.id}`; + if (seen.has(key)) continue; + const incident = n.id === selectedId || otherId === selectedId; + if (mode === "selected" && !incident) continue; + seen.add(key); + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: [[n.lng, n.lat], [other.lng!, other.lat!]] }, + properties: { selected: incident }, + }); + } + } + return { type: "FeatureCollection", features }; +} + +// The selected node's edges, coloured by observation count and freshness. Data comes from the node +// detail endpoint (GET /nodes/{id}/neighbors), which unlike the list's bare neighborIds carries +// observationCount + lastSeen + coords. That endpoint returns one row per (neighbor, iata), so rows +// are folded per neighbor: obs summed, lastSeen taken at its freshest. `now` defaults to the current +// time; tests pass it explicitly so the age stays deterministic. +export function buildFocusedNeighborEdges( + selected: Pick | null | undefined, + neighbors: NodeNeighbor[], + now: number = Date.now(), +): FeatureCollection { + const empty: FeatureCollection = { type: "FeatureCollection", features: [] }; + if (!selected || selected.lat == null || selected.lng == null) return empty; + const from: [number, number] = [selected.lng, selected.lat]; + + const byId = new Map(); + for (const nb of neighbors) { + if (nb.id === selected.id || nb.lat == null || nb.lng == null) continue; + const prev = byId.get(nb.id); + if (prev) { + prev.obs += nb.observationCount; + prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen); + } else { + byId.set(nb.id, { lng: nb.lng, lat: nb.lat, obs: nb.observationCount, lastSeen: nb.lastSeen }); + } + } + + const features: Feature[] = []; + for (const n of byId.values()) { + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: [from, [n.lng, n.lat]] }, + properties: { selected: true, obs: n.obs, ageDays: Math.max(0, (now - n.lastSeen) / 86400000) }, + }); + } + return { type: "FeatureCollection", features }; +} + +// The located nodes to keep lit when a node is selected: the selection plus its neighbors (links are +// undirected — a node listing the selection counts). Returns null when there's nothing to focus on: +// no selection, the selected node isn't on the map, or it has no located neighbors. Mirrors the +// undirected logic in buildNeighborEdges so the bright set matches the drawn edges. +export function neighborFocusIds(nodes: NodeSummary[], selectedId: string | null): string[] | null { + if (!selectedId) return null; + const located = new Map(); + for (const n of nodes) { + if (n.lat != null && n.lng != null) located.set(n.id, n); + } + const selected = located.get(selectedId); + if (!selected) return null; + + const focus = new Set([selectedId]); + for (const otherId of selected.neighborIds ?? []) { + if (otherId !== selectedId && located.has(otherId)) focus.add(otherId); + } + for (const n of located.values()) { + if (n.id !== selectedId && n.neighborIds?.includes(selectedId)) focus.add(n.id); + } + return focus.size > 1 ? [...focus] : null; +} + // Filter to a single device type ("" = All). Filtering the data (not a layer filter) lets the // clustered source re-count only the visible type. export function filterByNodeType( diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts new file mode 100644 index 0000000..23208ca --- /dev/null +++ b/src/features/map/packet-flow.ts @@ -0,0 +1,41 @@ +import type { ResolvedHop } from "../../types/api"; + +// Pure helpers for the live packet-flow animation (modelled on MeshMapper's LiveViz). No maplibre +// import, so they stay unit-testable; the hook owns the layers, the rAF loop, and the node flashes. + +// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. The dot +// rides these coords and flashes each node as it crosses. +export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] { + const seen = new Set(); + const out: { id: string; lng: number; lat: number }[] = []; + for (const hop of resolvedPath) { + const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); + if (node && !seen.has(node.id)) { + seen.add(node.id); + out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); + } + } + return out; +} + +// Position at fractional hop index t (0 .. coords.length-1): the integer part picks the hop segment, +// the fraction interpolates within it. Constant time per hop, so long and short hops feel the same. +export function posAtHop(coords: [number, number][], t: number): [number, number] { + const n = coords.length - 1; + if (t <= 0) return coords[0]!; + if (t >= n) return coords[n]!; + const s = Math.floor(t); + const f = t - s; + const a = coords[s]!; + const b = coords[s + 1]!; + return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; +} + +// The polyline the dot has traced so far: every hop coord up to the head, plus the head position. +export function trailCoords(coords: [number, number][], headT: number): [number, number][] { + const seg = Math.floor(headT); + const out: [number, number][] = []; + for (let s = 0; s <= seg && s < coords.length; s++) out.push(coords[s]!); + out.push(posAtHop(coords, headT)); + return out; +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 1f7c6a6..2cda43d 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -18,6 +18,8 @@ export const DEFAULT_STYLE_ID = "dark"; // beacon-* matches the codebase convention (beacon-theme, beacon-region, beacon-analyzer-open) export const MAP_STYLE_STORAGE_KEY = "beacon-map-style"; +export const MAP_CLUSTER_STORAGE_KEY = "beacon-map-clustering"; +export const MAP_NODE_TYPE_STORAGE_KEY = "beacon-map-node-type"; // Always returns an option: falls back to the first entry, which also covers a stale/invalid id // restored from localStorage. @@ -90,6 +92,32 @@ export const NODES_SELECTED_LAYER_ID = "nodes-selected"; // circle ring under th // NODES_SELECTED_LAYER_ID can't reach it). Fed by its own geojson source, pointed at the leaf. export const NODES_SELECTED_LEAF_LAYER_ID = "nodes-selected-leaf"; +// --- Neighbor edges layer --- +export const NEIGHBORS_SOURCE_ID = "neighbors"; +export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn beneath the node markers +export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; +export type NeighborLinesMode = "on" | "selected" | "off"; + +// --- Live packet-flow (modelled on MeshMapper's "LiveViz"): dim every node, then per packet shoot an +// orange dot along its real hop path with a fading dashed trail, flashing each node as the dot crosses --- +export const PACKET_FLOW_TRAIL_SOURCE_ID = "packet-flow-trail"; +export const PACKET_FLOW_TRAIL_LAYER_ID = "packet-flow-trail"; // dashed line tracing behind the dot +export const PACKET_FLOW_DOT_SOURCE_ID = "packet-flow-dot"; +export const PACKET_FLOW_DOT_HALO_LAYER_ID = "packet-flow-dot-halo"; // dark halo behind the dot +export const PACKET_FLOW_DOT_LAYER_ID = "packet-flow-dot"; // the moving packet dot +export const PACKET_FLOW_COLOR = "#ff6b35"; // warm orange, distinct from the node palette so packets pop +export const PACKET_FLOW_HOP_MS = 480; // ms the dot takes to cross one hop segment +export const PACKET_FLOW_FLASH_MS = 900; // a crossed node's flash decays back to dim over this +export const PACKET_FLOW_TRAIL_FADE_MS = 1000; // the dashed trail fades once the dot reaches the end +export const PACKET_FLOW_MAX = 120; // cap on concurrent packet animations (busy-feed guard) +// Idle individual-node opacity while Live is on. Kept low because with clustering off, co-located +// markers overlap and their alphas composite toward bright; a crossed node still pops to full via +// the max(dim, feature-state glow) expression. +export const LIVE_DIM_OPACITY = 0.08; +// Clusters dim further: many overlapping semi-transparent hexagons composite toward opaque, so a +// dense cluster field stops being see-through. A lower per-cluster alpha keeps the stack translucent. +export const LIVE_CLUSTER_DIM_OPACITY = 0.07; + export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops // clustering above clusterMaxZoom, which would leave co-located nodes as stacked, un-spiderfy-able diff --git a/src/features/map/useMapLibre.ts b/src/features/map/useMapLibre.ts index c871bb2..08a8fea 100644 --- a/src/features/map/useMapLibre.ts +++ b/src/features/map/useMapLibre.ts @@ -67,6 +67,9 @@ export function useMapLibre( // lng/lat pairs to fitBounds over; null/empty falls back to the configured default view fitPoints: [number, number][] | null, onStyleError?: (lastGoodStyleId: string) => void, + // a deep-link camera ([lng, lat] + zoom); when set it opens the map here and wins over the initial + // region fitBounds. Later region changes still auto-fit. + initialCamera?: { center: [number, number]; zoom: number }, ) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -77,6 +80,8 @@ export function useMapLibre( const swapPendingRef = useRef(false); // a setStyle() basemap swap is in flight (awaiting style.load) const onStyleErrorRef = useRef(onStyleError); const lastFitKeyRef = useRef(null); // last applied fit target; skips redundant re-fits + const skipInitialFitRef = useRef(!!initialCamera); // let a deep-link camera win over the first fit + const initialCameraRef = useRef(initialCamera); // read once at map creation (deep link is load-time only) const [isReady, setIsReady] = useState(false); const [error, setError] = useState(null); @@ -96,12 +101,13 @@ export function useMapLibre( const container = containerRef.current; if (!container) return; - // open at the default view; the fit effect frames the selection once the style is ready + // open at the deep-link camera if one was given, else the default view; the fit effect frames the + // selection once the style is ready (unless a deep-link camera suppresses that first fit) const map = new maplibregl.Map({ container, style: resolveMapStyle(styleIdRef.current).url, - center: DEFAULT_CENTER, - zoom: DEFAULT_ZOOM, + center: initialCameraRef.current?.center ?? DEFAULT_CENTER, + zoom: initialCameraRef.current?.zoom ?? DEFAULT_ZOOM, pitch: DEFAULT_PITCH, bearing: DEFAULT_BEARING, maxPitch: MAX_PITCH, @@ -197,6 +203,13 @@ export function useMapLibre( return; } + // First real fit after a deep-link camera: keep the URL-supplied view instead of framing the + // region. Consumed once, so later region changes fit normally. + if (skipInitialFitRef.current) { + skipInitialFitRef.current = false; + return; + } + const bounds = fitPoints.reduce( (b, p) => b.extend(p), new maplibregl.LngLatBounds(fitPoints[0], fitPoints[0]), diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts new file mode 100644 index 0000000..d6db109 --- /dev/null +++ b/src/features/map/useMapNeighbors.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef } from "react"; +import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; +import type { FeatureCollection, LineString } from "geojson"; +import type { NeighborEdgeProps } from "./node-geojson"; +import { OBS_STOPS, AGE } from "./neighbor-thresholds"; +import { NEIGHBORS_SOURCE_ID, NEIGHBORS_LINE_LAYER_ID, NODES_CLUSTER_LAYER_ID } from "./types"; + +type EdgeFC = FeatureCollection; + +function paletteVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +// Opacity: the selected node's coloured edges (they carry `obs`) fade with age — solid when fresh, +// faint by ~4 weeks (matches the 30-day retention). Ambient "on" edges keep the flat selected/dim split. +const NEIGHBOR_OPACITY = [ + "case", ["has", "obs"], + ["interpolate", ["linear"], ["get", "ageDays"], AGE.freshDays, AGE.freshOp, AGE.staleDays, AGE.staleOp], + ["case", ["get", "selected"], 0.9, 0.3], +] as ExpressionSpecification; + +// Colour by observation count on a log axis (counts are heavily right-skewed): ~1 red, ~20 yellow, +// ~150+ green, clamped past the ends. Edges without a count (the ambient mesh) fall back to primary. +function neighborLineColor(danger: string, warn: string, green: string, primary: string): ExpressionSpecification { + return [ + "case", ["has", "obs"], + ["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], OBS_STOPS.danger, danger, OBS_STOPS.warn, warn, OBS_STOPS.green, green], + primary, + ] as ExpressionSpecification; +} + +// Draws neighbor edges as a line layer beneath the node markers. Like useMapNodes, the source and +// layer re-add themselves after a style switch, and edge data flows through a separate setData +// effect so toggling or changing the selection never rebuilds the layer. +export function useMapNeighbors( + mapRef: React.RefObject, + isReady: boolean, + edges: EdgeFC, + themeKey: string, +) { + const edgesRef = useRef(edges); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + // build source + layer, and keep the line color in step with the palette + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const lineColor = neighborLineColor( + paletteVar("--palette-danger", "#EF4444"), + paletteVar("--palette-warn", "#EAB308"), + paletteVar("--palette-green", "#22C55E"), + paletteVar("--palette-primary", "#3B82F6"), + ); + + if (!map.getSource(NEIGHBORS_SOURCE_ID)) { + map.addSource(NEIGHBORS_SOURCE_ID, { type: "geojson", data: edgesRef.current }); + } + if (!map.getLayer(NEIGHBORS_LINE_LAYER_ID)) { + map.addLayer( + { + id: NEIGHBORS_LINE_LAYER_ID, + type: "line", + source: NEIGHBORS_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { + "line-color": lineColor, + // edges touching the selected node read stronger than the ambient mesh + "line-width": ["case", ["get", "selected"], 2, 1], + "line-opacity": NEIGHBOR_OPACITY, + }, + } as LineLayerSpecification, + // beneath the node markers; guard the beforeId in case the nodes layer isn't added yet + map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined, + ); + } + map.setPaintProperty(NEIGHBORS_LINE_LAYER_ID, "line-color", lineColor); + (map.getSource(NEIGHBORS_SOURCE_ID) as GeoJSONSource).setData(edgesRef.current); + }, [mapRef, isReady, themeKey]); + + // push new edge data as the selection / toggle / node set changes + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const src = map.getSource(NEIGHBORS_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(edges); + }, [mapRef, isReady, edges]); + + // remove the layer + source on unmount. Capturing map here is safe: it's the same instance for the + // component's life, and this cleanup runs before useMapLibre tears the map down. + useEffect(() => { + const map = mapRef.current; + return () => { + if (!map) return; + try { + if (map.getLayer(NEIGHBORS_LINE_LAYER_ID)) map.removeLayer(NEIGHBORS_LINE_LAYER_ID); + if (map.getSource(NEIGHBORS_SOURCE_ID)) map.removeSource(NEIGHBORS_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index 545811d..c4a7807 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -21,6 +21,8 @@ import { NODES_SOURCE_MAXZOOM, SPIDERFY_MIN_ZOOM, NODE_LABEL_MIN_ZOOM, + LIVE_DIM_OPACITY, + LIVE_CLUSTER_DIM_OPACITY, NODE_TYPE_NAMES, NODE_ICON_UNKNOWN, nodeIconId, @@ -81,6 +83,11 @@ const ICON_IMAGE: ExpressionSpecification = [ NODE_ICON_UNKNOWN, ] as unknown as ExpressionSpecification; +// Node labels fade in only past NODE_LABEL_MIN_ZOOM. +const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; +// Live mode: dim to the idle floor, but lift a currently-flashing node to full (feature-state glow 0..1). +const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; + const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { "icon-image": ICON_IMAGE, "icon-size": 1, @@ -98,6 +105,10 @@ export function useMapNodes( clustered: boolean, onSelectNode: (id: string) => void, selectedNodeId: string | null, + // live packet-flow on: fade every node (a crossed one lifts via feature-state glow) + live: boolean, + // selection focus: keep only these node ids lit and fade the rest; null = off + focusIds: string[] | null, // identity of the dataset (region + type filter); an open spiderfy fan closes when it changes, // since its leaves were drawn from the previous dataset resetKey = "", @@ -162,6 +173,8 @@ export function useMapNodes( cluster: clustered, clusterRadius: CLUSTER_RADIUS, clusterMaxZoom: CLUSTER_MAX_ZOOM, + // promote the node id so live packet-flow can flash individual nodes via feature-state + promoteId: "id", }); } @@ -208,8 +221,7 @@ export function useMapNodes( "text-color": textColor, "text-halo-color": halo, "text-halo-width": 1.3, - // labels fade in only at high zoom - "text-opacity": ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1], + "text-opacity": LABEL_OPACITY, // labels fade in only at high zoom }, } as SymbolLayerSpecification); } @@ -311,6 +323,31 @@ export function useMapNodes( syncLeafSelectionRing(map, selectedNodeId); }, [mapRef, isReady, selectedNodeId]); + // Base-layer opacity for the two "fade all but a subset" dim modes. Single owner of icon/text + // opacity so live mode and selection focus never fight over the paint property; re-applies after a + // style/theme/clustering rebuild via the deps. Live wins over focus. The live-mode packet glow + // rides feature-state (set by useMapPacketFlow's loop), so it needs no re-run here. + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + // lit for the focus set, dimmed otherwise + const focusCase = (lit: ExpressionSpecification | number, dim: ExpressionSpecification | number) => + ["case", ["in", ["get", "id"], ["literal", focusIds ?? []]], lit, dim] as ExpressionSpecification; + + const iconOpacity: ExpressionSpecification | number = live ? LIVE_ICON_OPACITY : focusIds ? focusCase(1, LIVE_DIM_OPACITY) : 1; + const labelOpacity: ExpressionSpecification | number = live ? 0 : focusIds ? focusCase(LABEL_OPACITY, 0) : LABEL_OPACITY; + const dimActive = live || Boolean(focusIds); + if (map.getLayer(NODES_POINT_LAYER_ID)) { + map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", iconOpacity); + map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", labelOpacity); + } + // a cluster can't tell which nodes it holds, so both modes just dim it flat (matches live mode) + if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", dimActive ? LIVE_CLUSTER_DIM_OPACITY : 1); + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", dimActive ? 0 : 1); + } + }, [mapRef, isReady, live, focusIds, clustered, themeKey]); + // Push new node data into the source as it arrives; the source re-clusters automatically. useEffect(() => { const map = mapRef.current; diff --git a/src/features/map/useMapNodesData.ts b/src/features/map/useMapNodesData.ts index aa1b44e..e720d72 100644 --- a/src/features/map/useMapNodesData.ts +++ b/src/features/map/useMapNodesData.ts @@ -7,11 +7,14 @@ const nodeId = (n: NodeSummary) => n.id; // Page the selected region's nodes 50 at a time for the map, so the canvas fills batch by batch // instead of waiting for one big response. Thin wrapper over the shared useInfinitePages (which owns // the auto-chain, dedup, and error handling). Loads once per region; WS updates keep nodes live. -export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string) { +export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string, opts?: { enabled?: boolean }) { const { items, loadedCount, isPaging, isError } = useInfinitePages({ queryKey: ["map-nodes", regionKey], - queryFn: (cursor) => getNodesPage(selectedIatas, { cursor }), + // Always request neighborIds (just UUIDs) so the neighbor-lines toggle is a pure client-side + // render switch over already-loaded data — no refetch when toggling. + queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true }), getId: nodeId, + enabled: opts?.enabled, }); return { nodes: items, loadedCount, isPaging, isError }; } diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts new file mode 100644 index 0000000..b4e815f --- /dev/null +++ b/src/features/map/useMapPacketFlow.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification } from "maplibre-gl"; +import type { Feature, FeatureCollection, Point, LineString } from "geojson"; +import type { WsManager } from "../../api/ws-manager"; +import { resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; +import { + PACKET_FLOW_TRAIL_SOURCE_ID, + PACKET_FLOW_TRAIL_LAYER_ID, + PACKET_FLOW_DOT_SOURCE_ID, + PACKET_FLOW_DOT_HALO_LAYER_ID, + PACKET_FLOW_DOT_LAYER_ID, + PACKET_FLOW_COLOR, + PACKET_FLOW_HOP_MS, + PACKET_FLOW_TRAIL_FADE_MS, + PACKET_FLOW_MAX, + NODES_SOURCE_ID, +} from "./types"; + +const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; + +// one packet riding its hop path once +interface Flow { + coords: [number, number][]; + ids: (string | null)[]; + start: number; + lastNode: number; +} + +// Live mode (MeshMapper LiveViz style): dim every node, then per observed packet shoot an orange dot +// along its real hop path with a fading dashed trail, flashing each node to full opacity as the dot +// crosses it. Enabling it opts the WS connection into resolvedPath data. Geometry is pure +// (packet-flow.ts); here we own the maplibre layers, the dimming, the rAF loop, and the subscription. +export function useMapPacketFlow( + mapRef: React.RefObject, + isReady: boolean, + enabled: boolean, + wsManager: WsManager, + themeKey: string, + resetKey: string, +) { + const flowsRef = useRef([]); + const litRef = useRef>(new Set()); // node ids currently lit (feature-state glow set) + const rafRef = useRef(null); + + const clearFlows = useCallback((map: MapLibreMap | null) => { + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + flowsRef.current = []; + // guard the whole block: on a not-yet-ready or torn-down map, getSource/setFeatureState throw + try { + for (const id of litRef.current) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + } catch { + // map style not ready / already removed + } + litRef.current.clear(); + }, []); + + const startLoop = useCallback(() => { + if (rafRef.current != null) return; + function frame() { + const map = mapRef.current; + const now = performance.now(); + + const dots: Feature[] = []; + const lines: Feature[] = []; + const glowByNode = new Map(); // node id -> glow this frame (max across packets) + + for (let i = flowsRef.current.length - 1; i >= 0; i--) { + const p = flowsRef.current[i]!; + const nSeg = p.coords.length - 1; + const t = (now - p.start) / PACKET_FLOW_HOP_MS; + const node = Math.min(nSeg, Math.floor(t + 1e-6)); + if (node > p.lastNode) p.lastNode = node; + const headT = Math.min(t, nSeg); + // full while the dot is travelling, then eases out with the trail after it reaches the end + const fade = t > nSeg ? Math.max(0, 1 - (now - (p.start + nSeg * PACKET_FLOW_HOP_MS)) / PACKET_FLOW_TRAIL_FADE_MS) : 1; + + const coords = trailCoords(p.coords, headT); + if (coords.length >= 2) { + lines.push({ type: "Feature", properties: { a: 0.6 * fade }, geometry: { type: "LineString", coordinates: coords } }); + } + if (t <= nSeg) { + dots.push({ type: "Feature", properties: { r: 5, a: 1 }, geometry: { type: "Point", coordinates: posAtHop(p.coords, headT) } }); + } + // light every node the dot has reached; they hold at full while it travels, then fade with the trail + if (fade > 0) { + for (let k = 0; k <= p.lastNode; k++) { + const id = p.ids[k]; + if (id != null) glowByNode.set(id, Math.max(glowByNode.get(id) ?? 0, fade)); + } + } + if (t > nSeg && fade <= 0) flowsRef.current.splice(i, 1); + } + + // apply node glows via feature-state; drop nodes that are no longer lit by any packet + try { + for (const [id, g] of glowByNode) map?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow: g }); + for (const id of litRef.current) { + if (!glowByNode.has(id)) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + } + } catch { /* node gone */ } + litRef.current = new Set(glowByNode.keys()); + + (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: lines }); + (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: dots }); + + const busy = flowsRef.current.length > 0 || litRef.current.size > 0; + rafRef.current = busy ? requestAnimationFrame(frame) : null; + } + rafRef.current = requestAnimationFrame(frame); + }, [mapRef]); + + // build the trail + dot layers (re-add after a style switch); the dot is orange with a white stroke + // and a dark halo behind it, the trail a dashed line whose opacity is data-driven + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + if (!map.getSource(PACKET_FLOW_TRAIL_SOURCE_ID)) { + map.addSource(PACKET_FLOW_TRAIL_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_TRAIL_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_TRAIL_LAYER_ID, + type: "line", + source: PACKET_FLOW_TRAIL_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": PACKET_FLOW_COLOR, "line-width": 2.5, "line-dasharray": [2, 2], "line-opacity": ["get", "a"] }, + } as LineLayerSpecification); + } + if (!map.getSource(PACKET_FLOW_DOT_SOURCE_ID)) { + map.addSource(PACKET_FLOW_DOT_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_DOT_HALO_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_DOT_HALO_LAYER_ID, + type: "circle", + source: PACKET_FLOW_DOT_SOURCE_ID, + paint: { "circle-radius": ["+", ["get", "r"], 2.4], "circle-color": "rgba(0,0,0,0.5)", "circle-opacity": ["*", ["get", "a"], 0.5], "circle-blur": 0.5 }, + } as CircleLayerSpecification); + } + if (!map.getLayer(PACKET_FLOW_DOT_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_DOT_LAYER_ID, + type: "circle", + source: PACKET_FLOW_DOT_SOURCE_ID, + paint: { + "circle-radius": ["get", "r"], + "circle-color": PACKET_FLOW_COLOR, + "circle-opacity": ["get", "a"], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": ["*", ["get", "a"], 1.1], + }, + } as CircleLayerSpecification); + } + }, [mapRef, isReady, themeKey]); + + // connection-wide resolvePath toggle: on while enabled, off otherwise + useEffect(() => { + wsManager.setResolvePath(enabled); + return () => wsManager.setResolvePath(false); + }, [enabled, wsManager]); + + // Base-node dimming (fade all, lift the flashing node) is owned by useMapNodes so live mode and + // selection focus share one opacity owner; here we only feed it the per-node glow feature-state. + + // launch a flow per observed packet; tear the animation down when disabled + useEffect(() => { + if (!enabled) return; + const map = mapRef.current; + const unsub = wsManager.onPacketObservation((data) => { + const resolved = data.observation?.resolvedPath; + if (!resolved) return; + const nodes = resolvedPathNodes(resolved); + if (nodes.length < 2) return; // need at least two located hops to animate a path + while (flowsRef.current.length >= PACKET_FLOW_MAX) flowsRef.current.shift(); + flowsRef.current.push({ + coords: nodes.map((n) => [n.lng, n.lat] as [number, number]), + ids: nodes.map((n) => n.id), + start: performance.now(), + lastNode: -1, + }); + startLoop(); + }); + + return () => { + unsub(); + clearFlows(map); + }; + }, [enabled, wsManager, mapRef, startLoop, clearFlows]); + + // clear on region change (paths came from the old dataset) + useEffect(() => { + clearFlows(mapRef.current); + }, [resetKey, mapRef, clearFlows]); + + // remove layers + sources on unmount (runs before useMapLibre's map.remove()) + useEffect(() => { + const map = mapRef.current; + return () => { + clearFlows(map); + if (!map) return; + try { + for (const id of [PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID]) { + if (map.getLayer(id)) map.removeLayer(id); + } + for (const id of [PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_DOT_SOURCE_ID]) { + if (map.getSource(id)) map.removeSource(id); + } + } catch { + // map may already be torn down + } + }; + }, [mapRef, clearFlows]); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 91673c1..f3bf209 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; +import { CopyButton } from "../../components/CopyButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -10,7 +12,7 @@ import type { NodeObservation, NodeNeighbor } from "./types"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { return (
@@ -34,7 +36,7 @@ function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?: const level = snrLevel(obs.snr); return (
@@ -97,6 +99,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o } isLoading={isLoading} notFound={!node} notFoundLabel="Node not found" @@ -115,8 +119,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node.nodeTypeName}
-
- {node.publicKey} +
+
+ {node.publicKey} +
+
{node.observerId && ( - ); -} - interface PacketAnalyzerDrawerProps { detail: PacketDetail | undefined; selectedObservationId: number | null; @@ -91,7 +68,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
Packet Analyzer
- {detail && } + {detail && }
diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 278b0f1..7413a4a 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,11 +1,14 @@ -import { useState, useCallback, useRef, useMemo } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { usePackets } from "./usePackets"; -import { usePacketFilters, matchesFilters } from "./usePacketFilters"; +import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; import { useScopes } from "../../hooks/useScopes"; +import { useRegion } from "../../hooks/useRegion"; import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers"; import { PacketVirtualList } from "./PacketVirtualList"; import { FilterBar } from "../../components/FilterBar"; +import { LoadingPill } from "../../components/LoadingPill"; +import { SkeletonRows } from "../../components/SkeletonRows"; import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; import type { WsManager } from "../../api/ws-manager"; @@ -31,8 +34,18 @@ interface PacketListProps { export function PacketList({ wsManager, onAnalyze }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); + // single-value selections go to the server so scrolling pages through matching history + const serverFilter = useMemo(() => toServerFilter(filters), [filters]); const scopeNames = useScopes(); const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]); + const { regionKey } = useRegion(); + + // isAtTop drives the freeze (list held static while scrolled off the very top); isScrolledAway + // (a wider deadband) drives the banner. listResetKey remounts the list to reveal held packets. + const [isScrolledAway, setIsScrolledAway] = useState(false); + const [isAtTop, setIsAtTop] = useState(true); + const [listResetKey, setListResetKey] = useState(0); + const { allPackets, observerOptions, @@ -41,21 +54,20 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { fetchNextPage, hasNextPage, isFetchingNextPage, + isLoading, + isError, observersByHash, handlePacketObservation, handleLagged, laggedCount, dismissLagged, - } = usePackets(); + } = usePackets(!isAtTop, serverFilter); const packets = useMemo( () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)), [allPackets, filters, observersByHash], ); - const [isScrolledAway, setIsScrolledAway] = useState(false); - const scrollToTopRef = useRef<(() => void) | null>(null); - // ?hash is the source of truth — the analyzer drawer clears it on close, deselecting the row const expandedHash = searchParams.get("hash"); @@ -74,22 +86,40 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { const bannerCount = isScrolledAway ? newPacketCount : 0; - const handleScrolledAway = useCallback( - (isAway: boolean) => { - setIsScrolledAway(isAway); - if (!isAway) acknowledgeNewPackets(); - }, - [acknowledgeNewPackets], - ); + // Remount the list (fresh at the top, no stale scroll anchor for the virtualizer to preserve) + // when returning to the top with packets held while away — a big prepend into the live list + // would otherwise keep the old row anchored instead of landing on the newest. + const [prevAtTop, setPrevAtTop] = useState(isAtTop); + if (prevAtTop !== isAtTop) { + setPrevAtTop(isAtTop); + if (isAtTop && newPacketCount > 0) setListResetKey((k) => k + 1); + } + // A region switch starts fresh at the top so the new region's list isn't held frozen. + const [prevRegionKey, setPrevRegionKey] = useState(regionKey); + if (prevRegionKey !== regionKey) { + setPrevRegionKey(regionKey); + setListResetKey((k) => k + 1); + setIsAtTop(true); + setIsScrolledAway(false); + } + + // At the top the held packets are revealed, so acknowledge continuously there — the banner then + // counts only what arrived while the user was away (and never flashes a count at the top). + useEffect(() => { + if (isAtTop && newPacketCount > 0) acknowledgeNewPackets(); + }, [isAtTop, newPacketCount, acknowledgeNewPackets]); + + // Returning to the top (revealing held packets) is a remount; releasing the freeze first lets + // the fresh list mount with the newest packet already in place. const handleScrollToTop = useCallback(() => { - scrollToTopRef.current?.(); - acknowledgeNewPackets(); - }, [acknowledgeNewPackets]); + setIsScrolledAway(false); + setIsAtTop(true); + }, []); return (
-
+
)} - + ) : ( + + )} +
diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index c61898c..80fdb78 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -1,9 +1,13 @@ -import { useRef, useCallback, useEffect, useLayoutEffect } from "react"; +import { useRef, useCallback, useLayoutEffect } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { PacketSummary } from "../../types/api"; import { PacketRow } from "./PacketRow"; import { useFreshHashes } from "./useFreshHashes"; -import { SCROLL_TOP_THRESHOLD_PX, SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants"; +import { + SCROLL_TOP_THRESHOLD_PX, + SCROLL_BOTTOM_THRESHOLD_PX, + SCROLL_REVEAL_EPSILON_PX, +} from "../../lib/constants"; interface PacketVirtualListProps { packets: PacketSummary[]; @@ -11,7 +15,7 @@ interface PacketVirtualListProps { isFetchingNextPage: boolean; fetchNextPage: () => void; onScrollAwayFromTop: (isAway: boolean) => void; - scrollToTopRef?: React.MutableRefObject<(() => void) | null>; + onAtTopChange: (isAtTop: boolean) => void; expandedHash: string | null; onToggleExpand: (hash: string) => void; } @@ -24,33 +28,14 @@ export function PacketVirtualList({ isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, - scrollToTopRef, + onAtTopChange, expandedHash, onToggleExpand, }: PacketVirtualListProps) { const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); - const isAtTopRef = useRef(true); - const prevCountRef = useRef(packets.length); + const atTopRef = useRef(true); const prevFirstKeyRef = useRef(packets[0]?.packetHash); - const savedScrollHeightRef = useRef(0); - const shouldCompensateRef = useRef(false); - - // Anchor scroll position when live packets are PREPENDED while the user is scrolled away from - // the top. The pre-commit scroll height must be read here in the render body (parentRef still - // points at the old DOM); a post-commit effect is too late — the virtualizer's spacer has - // already grown, collapsing the delta to ~0 so nothing offsets the new rows. A changed first - // key distinguishes a real top-prepend from history pages appended at the bottom by - // fetchNextPage (those grow the count too but must NOT shift the view). - if ( - packets.length > prevCountRef.current && - packets[0]?.packetHash !== prevFirstKeyRef.current && - !isAtTopRef.current && - parentRef.current - ) { - savedScrollHeightRef.current = parentRef.current.scrollHeight; - shouldCompensateRef.current = true; - } const virtualizer = useVirtualizer({ count: packets.length, @@ -60,28 +45,16 @@ export function PacketVirtualList({ getItemKey: (index) => packets[index]?.packetHash ?? index, }); - // After commit, before paint: offset scrollTop by the height the prepended rows added so the - // view stays anchored on the same packet. Keyed on the array (not just length) so bookkeeping - // stays current even when the live buffer is at its cap and the count holds steady. - useLayoutEffect(() => { - if (shouldCompensateRef.current) { - shouldCompensateRef.current = false; - const el = parentRef.current; - if (el) { - const delta = el.scrollHeight - savedScrollHeightRef.current; - if (delta > 0) el.scrollTop += delta; - } - } - prevCountRef.current = packets.length; - prevFirstKeyRef.current = packets[0]?.packetHash; - }, [packets]); - + // The list is frozen at the data layer while scrolled away, so there's nothing to compensate + // here — we only report scroll position: past the threshold shows the banner, and back at the + // very top releases the freeze (revealing held packets where a prepend is jump-free). const handleScroll = useCallback(() => { const el = parentRef.current; if (!el) return; - isAtTopRef.current = el.scrollTop <= SCROLL_TOP_THRESHOLD_PX; - onScrollAwayFromTop(!isAtTopRef.current); + atTopRef.current = el.scrollTop <= SCROLL_REVEAL_EPSILON_PX; + onScrollAwayFromTop(el.scrollTop > SCROLL_TOP_THRESHOLD_PX); + onAtTopChange(atTopRef.current); if (hasNextPage && !isFetchingNextPage) { const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; @@ -89,19 +62,21 @@ export function PacketVirtualList({ fetchNextPage(); } } - }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); - useEffect(() => { - if (!scrollToTopRef) return; - scrollToTopRef.current = () => { - const el = parentRef.current; - if (el) { - isAtTopRef.current = true; - el.scrollTop = 0; - onScrollAwayFromTop(false); - } - }; - }, [scrollToTopRef, onScrollAwayFromTop]); + // When rows are prepended at the top (a reveal on return-to-top, or a live packet while already + // at the top), TanStack keeps the previously-top row anchored — which drifts the view off the + // newest packet. Re-pin index 0 so the newest stays at the top; only while at the top, so a + // prepend never disturbs someone reading further down. + useLayoutEffect(() => { + const firstKey = packets[0]?.packetHash; + const firstChanged = firstKey !== prevFirstKeyRef.current; + prevFirstKeyRef.current = firstKey; + if (firstChanged && atTopRef.current) { + virtualizer.scrollToIndex(0, { align: "start" }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- fires on packet-list change; virtualizer is stable + }, [packets]); return (
= { + 0x01: "ChatNode", + 0x02: "Repeater", + 0x03: "RoomServer", + 0x04: "Sensor", +}; + // maps packet bytes to named field ranges for hex coloring export type FieldId = "header" | "transport" | "pathLength" | "pathData" | "payload" | "channelHash" | "cipherMac" | "ciphertext" | "publicKey" | "signature" | "advertTimestamp" | "flags" | "location" | "advertName" | "destinationHash" | "sourceHash" | "senderPublicKey" | "checksum" | "traceTag" | "authCode" | "tracePath"; @@ -337,12 +345,6 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { const nm = bits[0]; const role = flagsByte & 0x0f; - const DEVICE_ROLE_NAMES: Record = { - 0x01: "ChatNode", - 0x02: "Repeater", - 0x03: "RoomServer", - 0x04: "Sensor", - }; const roleName = DEVICE_ROLE_NAMES[role] ?? `0x${role.toString(16)}`; return ( diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index c998254..e36e3cc 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { Badge } from "../../components/Badge"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; -import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS } from "./packet-structure"; +import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS, DEVICE_ROLE_NAMES } from "./packet-structure"; import type { FieldId } from "./packet-structure"; import { ResolvedHopBlock } from "./PathData"; import type { ResolvedHop } from "../../types/api"; @@ -449,27 +449,36 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record i).filter((i) => (typeFilter >> i) & 1) + : []; + return (
- {tag != null && ( + {tag && ( - 0x{tag.toString(16).toUpperCase()} + 0x{tag.toUpperCase()} )} - {typeFilterNames && typeFilterNames.length > 0 && ( + {typeFilter != null && (
Type Filter - - {typeFilterNames.map((name) => ( - - ))} - + {roles.length > 0 ? ( + + {roles.map((i) => ( + + ))} + + ) : ( + any + )}
)} {since != null && ( @@ -484,21 +493,21 @@ function DiscoverReqFields({ payload }: PayloadProps) { ); } -function DiscoverRespFields({ payload }: PayloadProps) { - const tag = payload.tag as number | undefined; +function DiscoverRespPayload({ payload }: PayloadProps) { + const tag = payload.tag as string | undefined; const nodeTypeName = payload.nodeTypeName as string | undefined; - const snr = payload.snr as number | undefined; - const publicKey = payload.publicKey as string | undefined; - const publicKeyLength = payload.publicKeyLength as number | undefined; + const requestSnr = payload.requestSnr as number | undefined; + const pubKey = payload.pubKey as string | undefined; + const pubKeyPrefixOnly = payload.pubKeyPrefixOnly as boolean | undefined; - const level = snr != null ? snrLevel(snr) : null; + const level = requestSnr != null ? snrLevel(requestSnr) : null; const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; return (
- {tag != null && ( + {tag && ( - 0x{tag.toString(16).toUpperCase()} + 0x{tag.toUpperCase()} )} {nodeTypeName && ( @@ -507,29 +516,27 @@ function DiscoverRespFields({ payload }: PayloadProps) { {nodeTypeName}
)} - {snr != null && ( - {formatSnr(snr)} dB + {requestSnr != null && ( + + {formatSnr(requestSnr)} dB + (node-to-node) + )} - {publicKey && ( - + {pubKey && ( + )}
); } +// Non-DISCOVER control payloads have no dedicated fields yet, so fall back to the generic dump. function ControlPayload({ payload }: PayloadProps) { - const subTypeName = payload.subTypeName as string | undefined; - - return ( -
- {subTypeName &&
{subTypeName}
} - {subTypeName === "DISCOVER_REQ" && } - {subTypeName === "DISCOVER_RESP" && } - {subTypeName !== "DISCOVER_REQ" && subTypeName !== "DISCOVER_RESP" && ( - - )} -
- ); + return ; } function RawPayload({ payload }: PayloadProps) { @@ -681,6 +688,8 @@ export function PayloadBreakdown({ payload, resolvedRoute, onViewNode }: { case "ACK": return ; case "PATH": return ; case "CONTROL": return ; + case "DISCOVER_REQ": return ; + case "DISCOVER_RESP": return ; case "GROUP_DATA": return ; case "RAW": return ; // MULTIPART carries structured remaining/wrappedType/wrappedPayload fields, which the diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts index c6e4aa3..e563c10 100644 --- a/src/features/packets/types.ts +++ b/src/features/packets/types.ts @@ -11,6 +11,13 @@ export interface PacketFilterState { searchField: SearchField; } +// Filters /packets history can apply server-side (each accepts a single value per request) +export interface PacketServerFilter { + payloadType?: number; + routeType?: number; + scope?: string; +} + export const EMPTY_FILTERS: PacketFilterState = { payloadTypes: [], routeTypes: [], diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index 261aaa3..5645a6c 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -1,6 +1,6 @@ import { useSearchParams } from "react-router-dom"; import { useMemo, useCallback } from "react"; -import type { PacketFilterState, SearchField } from "./types"; +import type { PacketFilterState, PacketServerFilter, SearchField } from "./types"; import type { PacketSummary } from "../../types/api"; import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums"; @@ -115,6 +115,16 @@ export function usePacketFilters() { return { filters, setFilter, setSearch, setSearchField, clearFilters }; } +// The /packets endpoint filters by a single payloadType/routeType/scope per request, so a +// dimension only goes server-side when exactly one value is selected; the rest stay client-side. +export function toServerFilter(filters: PacketFilterState): PacketServerFilter | null { + const serverFilter: PacketServerFilter = {}; + if (filters.payloadTypes.length === 1) serverFilter.payloadType = filters.payloadTypes[0]!; + if (filters.routeTypes.length === 1) serverFilter.routeType = filters.routeTypes[0]!; + if (filters.scopes.length === 1) serverFilter.scope = filters.scopes[0]!; + return Object.keys(serverFilter).length > 0 ? serverFilter : null; +} + // client-side filter predicate for packet rows export function matchesFilters( diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 5a299fc..35e704e 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -4,6 +4,7 @@ import { getPackets } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import type { WsPacketObservation, WsLagged } from "../../types/ws"; import type { PacketSummary } from "../../types/api"; +import type { PacketServerFilter } from "./types"; import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants"; // merge and deduplicate live + paginated packets @@ -114,14 +115,15 @@ class LivePacketStore { // combines live WS stream with paginated history -export function usePackets() { +export function usePackets(frozen: boolean = false, serverFilter: PacketServerFilter | null = null) { const { iatas, regionKey } = useRegion(); const queryClient = useQueryClient(); const [store] = useState(() => new LivePacketStore()); const [laggedCount, setLaggedCount] = useState(0); const [prevRegionKey, setPrevRegionKey] = useState(regionKey); - if (prevRegionKey !== regionKey) { + const regionChanged = prevRegionKey !== regionKey; + if (regionChanged) { setPrevRegionKey(regionKey); store.reset(); setLaggedCount(0); @@ -132,6 +134,15 @@ export function usePackets() { store.getSnapshot, ); + // While scrolled away from the top, render a latched buffer so live prepends don't shift the + // view; the held packets reveal when the user returns to the top. Latched by holding the last + // value (set-state-during-render, this file's pattern — cf. prevRegionKey above). The + // regionChanged guard drops the latch so a region switch never shows the previous region. + const [displayBuffer, setDisplayBuffer] = useState(liveBuffer); + if ((!frozen || regionChanged) && displayBuffer !== liveBuffer) { + setDisplayBuffer(liveBuffer); + } + const handlePacketObservation = useCallback( (data: WsPacketObservation["data"]) => { const summary: PacketSummary = { @@ -157,7 +168,8 @@ export function usePackets() { ); // Reset (drop to one fresh first page) instead of invalidate: an invalidate replays every cached - // page sequentially — up to 20 requests per lag notice during a flood. + // page sequentially — up to 20 requests per lag notice during a flood. The 2-element key matches + // filtered variants by prefix, so those reset too. const handleLagged = useCallback( (data: WsLagged) => { setLaggedCount((prev) => prev + data.droppedCount); @@ -167,7 +179,8 @@ export function usePackets() { ); // The WS handler is down whenever this tab is unmounted, so cached history may hide a gap right - // where the live buffer begins. Refresh the first page on mount to close it. + // where the live buffer begins. Refresh the first page on mount to close it (prefix match: + // filtered variants included). useEffect(() => { queryClient.resetQueries({ queryKey: ["packets", regionKey] }); // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only; region changes refetch via the key @@ -180,10 +193,14 @@ export function usePackets() { fetchNextPage, hasNextPage, isFetchingNextPage, + isLoading, + isError, } = useInfiniteQuery({ - queryKey: ["packets", regionKey], + // The unfiltered key must stay 2-element so its cached entry survives filter toggling; the + // lagged/mount resets above match both shapes by prefix. + queryKey: serverFilter ? ["packets", regionKey, serverFilter] : ["packets", regionKey], // first load and every scroll page are the default 50; getPackets fills in the limit - queryFn: ({ pageParam }) => getPackets(iatas, { cursor: pageParam }), + queryFn: ({ pageParam }) => getPackets(iatas, { cursor: pageParam, ...serverFilter }), getNextPageParam: (last) => last.nextCursor ?? undefined, initialPageParam: undefined as number | undefined, staleTime: Infinity, @@ -191,8 +208,8 @@ export function usePackets() { }); const allPackets = useMemo( - () => dedup([...liveBuffer, ...flattenPages(history)]), - [liveBuffer, history], + () => dedup([...displayBuffer, ...flattenPages(history)]), + [displayBuffer, history], ); const observerOptions = useMemo(() => { @@ -221,6 +238,8 @@ export function usePackets() { fetchNextPage, hasNextPage: hasNextPage ?? false, isFetchingNextPage, + isLoading, + isError, observersByHash, handlePacketObservation, handleLagged, diff --git a/src/features/stats/EChart.tsx b/src/features/stats/EChart.tsx index 71b9f27..2c2abcc 100644 --- a/src/features/stats/EChart.tsx +++ b/src/features/stats/EChart.tsx @@ -7,19 +7,27 @@ interface EChartProps { style?: React.CSSProperties; // Map of ECharts event name -> handler (e.g. { click: (p) => ... }). Kept stable by the caller. onEvents?: Record void>; + // Called once with the instance after init, for callers that need imperative control (e.g. the + // neighbour graph dispatches highlight/downplay so selection never re-runs the force layout). + onInit?: (chart: EChartsInstance) => void; } // Thin React wrapper over the core ECharts API: init once, resize via ResizeObserver, dispose on // unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state. // Hand-rolled on purpose — we avoid the echarts-for-react dependency. -export function EChart({ option, className, style, onEvents }: EChartProps) { +export function EChart({ option, className, style, onEvents, onInit }: EChartProps) { const elRef = useRef(null); const chartRef = useRef(null); + const onInitRef = useRef(onInit); + useEffect(() => { + onInitRef.current = onInit; + }, [onInit]); useEffect(() => { if (!elRef.current) return; const chart = echarts.init(elRef.current, null, { renderer: "canvas" }); chartRef.current = chart; + onInitRef.current?.(chart); const ro = new ResizeObserver(() => chart.resize()); ro.observe(elRef.current); return () => { diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index f2ec64f..ebd0f3c 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -23,16 +23,6 @@ function aggregateByHour(points: ObservationPoint[]) { return [...byHour.values()].sort((a, b) => a.hour - b.hour); } -function nodeTypeColor(typeName: string, c: ChartColors): string { - switch (typeName) { - case "companion": return c.primary; - case "repeater": return c.green; - case "room_server": return c.secondary; - case "sensor": return c.warn; - default: return c.primaryDim; - } -} - interface MeshTabProps { range: StatsRange; onSelectObserver: (observerId: string) => void; diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx new file mode 100644 index 0000000..4127998 --- /dev/null +++ b/src/features/stats/NeighbourGraph.tsx @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { EChart } from "./EChart"; +import type { EChartsInstance, EChartsOption } from "./echarts-setup"; +import { nodeNameMatches, type GraphNode } from "./neighbour-graph"; + +interface Props { + option: EChartsOption; + nodes: GraphNode[]; + search: string; + onSelect: (id: string | null) => void; +} + +// Presentational force graph. The caller swaps the whole option between the full mesh and a node's ego +// view; this stays dumb apart from the search overlay. No emphasis/dispatch, so dragging never flickers. +export function NeighbourGraph({ option, nodes, search, onSelect }: Props) { + const chartRef = useRef(null); + + const onInit = useCallback( + (chart: EChartsInstance) => { + chartRef.current = chart; + // clicking empty canvas returns to the full mesh + chart.getZr().on("click", (e: { target?: unknown }) => { + if (!e.target) onSelect(null); + }); + }, + [onSelect], + ); + + const onEvents = useMemo( + () => ({ + click: (p: unknown) => { + const param = p as { dataType?: string; data?: { id?: string } }; + if (param.dataType === "edge") return; + const id = param.data?.id; + if (id) onSelect(id); + }, + }), + [onSelect], + ); + + // Search: highlight matches, dim the rest — a per-node itemStyle/label merge only. The layout is at + // equilibrium, so a style-only merge (no x/y change) leaves node positions put — no relayout, no + // flicker. `option` is a dep so styling re-applies after a region/ego swap replaces the chart state. + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; + const q = search.trim(); + const data = nodes.map((n) => { + if (!q) return { ...n, itemStyle: { opacity: 1 }, label: n.label ?? { show: false } }; + const match = nodeNameMatches(n.name, q); + return { + ...n, + itemStyle: { opacity: match ? 1 : 0.06 }, + label: match ? { show: true, fontSize: 13 } : { show: false }, + }; + }); + chart.setOption({ series: [{ data }] }, { notMerge: false }); + }, [search, nodes, option]); + + return ; +} diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx new file mode 100644 index 0000000..952566b --- /dev/null +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -0,0 +1,99 @@ +import { useCallback, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { useMapNodesData } from "../map/useMapNodesData"; +import { getNodeNeighbors } from "../../api/client"; +import { useChartColors } from "./chartTheme"; +import { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } from "./neighbour-graph"; +import { NeighbourGraph } from "./NeighbourGraph"; +import { EmptyState } from "../../components/EmptyState"; +import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; + +// Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node +// query (same cache), so the whole region still loads — this only caps what the full mesh draws. +const CAP = 1000; + +const SEARCH_FIELDS: SearchFieldOption[] = [{ value: "name", label: "Name" }]; + +export function NeighbourGraphTab() { + const { iatas, regionKey } = useRegion(); + // "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and + // prompt for a region instead of freezing the browser. + const isAll = regionKey === "*"; + const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll }); + const colors = useChartColors(); + const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); + const [searchField, setSearchField] = useState("name"); + + // a different region is a different mesh — drop any stale selection/search (adjust-during-render) + const [region, setRegion] = useState(regionKey); + if (region !== regionKey) { + setRegion(regionKey); + setSelectedId(null); + setSearch(""); + } + + // focusing a node clears the search so the ego view isn't dimmed by a stale query + const handleSelect = useCallback((id: string | null) => { + setSelectedId(id); + if (id) setSearch(""); + }, []); + + const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); + const selectedNode = useMemo( + () => (selectedId ? nodes.find((n) => n.id === selectedId) ?? null : null), + [selectedId, nodes], + ); + + // Selected node's neighbours (shared cache with the map + node panel); dataUpdatedAt stands in for + // "now" so the freshness fade is pure at render time. + const { data: neighbours, dataUpdatedAt } = useQuery({ + queryKey: ["node-neighbors", selectedId], + queryFn: () => getNodeNeighbors(selectedId!), + enabled: !!selectedId, + staleTime: 30_000, + }); + const ego = useMemo(() => { + if (!selectedId || !neighbours) return null; + // fall back to a bare centre if the node was heard from another region (not in the loaded set) + const center = selectedNode ?? { id: selectedId, name: null, nodeTypeName: "" }; + return buildEgoGraph(center, neighbours, dataUpdatedAt); + }, [selectedId, selectedNode, neighbours, dataUpdatedAt]); + + const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); + + if (isAll) + return ( + + ); + if (isError) return ; + // build only once the pager settles, or the force layout would restart on every streamed page + if (isPaging) return ; + if (graph.nodes.length === 0) return ; + + return ( +
+ {ego ? ( +
+ Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours — click empty space for the full mesh +
+ ) : ( +
+ + {graph.capped ? `Showing ${CAP} of ${graph.total} nodes — narrow to an IATA for the rest` : `${graph.total} nodes`} + +
+ +
+
+ )} +
+ +
+
+ ); +} diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 05ccc54..71eab28 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -82,7 +82,7 @@ function ObserverList({ type="button" onClick={() => onSelect(r.id)} className={`relative overflow-hidden rounded border-l-2 px-2.5 py-1.5 text-left transition-colors cursor-pointer ${ - active ? "border-primary bg-primary/10" : "border-transparent hover:bg-white/3" + active ? "border-primary bg-primary/10" : "border-transparent hover:bg-text-normal/3" }`} > {r.count != null && ( diff --git a/src/features/stats/Segmented.tsx b/src/features/stats/Segmented.tsx index 00baa1b..ee905ac 100644 --- a/src/features/stats/Segmented.tsx +++ b/src/features/stats/Segmented.tsx @@ -34,7 +34,7 @@ export function Segmented({ options, value, onChange, ariaLabel, size = "sm", cl type="button" aria-pressed={active} onClick={() => onChange(o.value)} - className={`flex items-center gap-1.5 rounded font-mono font-semibold tracking-wide transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${pad} ${ + className={`flex items-center gap-1.5 whitespace-nowrap rounded font-mono font-semibold tracking-wide transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${pad} ${ active ? "bg-primary/15 text-text-bright ring-1 ring-inset ring-primary/30" : "text-text-muted hover:text-text-normal" diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index bd78f3f..9397ada 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -4,9 +4,10 @@ import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { ObserverTab } from "./ObserverTab"; +import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "observer"]; +const TABS: StatsTab[] = ["mesh", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -50,11 +51,11 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
- {tab === "mesh" ? ( - - ) : ( + {tab === "mesh" && } + {tab === "observer" && ( )} + {tab === "graph" && }
); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 0d2840b..a0d33af 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,4 +1,6 @@ import { Segmented } from "./Segmented"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { useIsMobile } from "../../hooks/useMediaQuery"; import type { StatsRange, StatsTab } from "./types"; function MeshIcon() { @@ -21,11 +23,28 @@ function ObserverIcon() { ); } +function GraphIcon() { + return ( + + + + + + + + + ); +} + const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "observer", label: "Observer", icon: }, + { value: "graph", label: "Neighbour Graph", icon: }, ]; +// Same sections, minus icons, for the mobile dropdown (which is text-only). Scales with TAB_OPTIONS. +const TAB_SELECT_OPTIONS = TAB_OPTIONS.map(({ value, label }) => ({ value, label })); + const RANGE_OPTIONS = [ { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 7e6a2f5..745c9e5 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -53,7 +53,7 @@ export function withAlpha(color: string, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})`; } -function blend(a: string, b: string, t = 0.5): string { +export function blend(a: string, b: string, t = 0.5): string { const [r1, g1, b1] = parseColor(a); const [r2, g2, b2] = parseColor(b); const mix = (x: number, y: number) => Math.round(x + (y - x) * t); @@ -100,6 +100,18 @@ export function useChartColors(): ChartColors { return useMemo(() => readChartColors(), [paletteRev]); } +// Per-device-type colour, shared by the Mesh "Node types" donut and the neighbour graph so the two +// views stay in sync. Unknown types fall back to a dim primary. +export function nodeTypeColor(typeName: string, c: ChartColors): string { + switch (typeName) { + case "companion": return c.primary; + case "repeater": return c.green; + case "room_server": return c.secondary; + case "sensor": return c.warn; + default: return c.primaryDim; + } +} + // A reusable ECharts tooltip style block bound to the active palette. export function tooltipStyle(c: ChartColors) { return { diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index 319b6f5..b41cf57 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -3,7 +3,7 @@ // the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps // the core API directly instead. import * as echarts from "echarts/core"; -import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts"; +import { LineChart, BarChart, PieChart, GaugeChart, GraphChart } from "echarts/charts"; import { GridComponent, TitleComponent, @@ -20,6 +20,7 @@ echarts.use([ BarChart, PieChart, GaugeChart, + GraphChart, GridComponent, TitleComponent, TooltipComponent, diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts new file mode 100644 index 0000000..6586c4f --- /dev/null +++ b/src/features/stats/neighbour-graph.ts @@ -0,0 +1,250 @@ +import type { NodeSummary, NodeNeighbor } from "../nodes/types"; +import { NODE_TYPE_NAMES, NODE_TYPES } from "../../lib/node-types"; +import { blend, nodeTypeColor, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; +import { OBS_STOPS, AGE } from "../map/neighbor-thresholds"; +import type { EChartsOption } from "./echarts-setup"; + +const MONO = "JetBrains Mono, monospace"; + +// Pure, render-free transform from the region's nodes into an ECharts force-graph shape. Kept +// maplibre- and echarts-free so it stays unit-testable (mirrors features/map/node-geojson.ts). + +export interface GraphNode { + id: string; + name: string; + category: number; // index into the node-type categories, or OTHER_CATEGORY for unknown types + nodeTypeName: string; + degree: number; + symbolSize: number; + label?: { show: boolean; fontSize?: number }; +} + +export interface GraphLink { + source: number; // index into GraphNode[] + target: number; + obs?: number; // weighted-edge fields, set on the ego view's edges (drive colour + freshness fade) + ageDays?: number; +} + +export interface NeighbourGraph { + nodes: GraphNode[]; + links: GraphLink[]; + total: number; // nodes before the cap, so callers can show "showing N of total" + capped: boolean; +} + +const OTHER_CATEGORY = NODE_TYPE_NAMES.length; +const MIN_SIZE = 6; +const MAX_SIZE = 34; +const HUB_LABELS = 30; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall +const MIN_LABEL = 9; +const MAX_LABEL = 16; + +// Case-insensitive substring match for the graph search; an empty query matches nothing. +export function nodeNameMatches(name: string, query: string): boolean { + const q = query.trim().toLowerCase(); + return q.length > 0 && name.toLowerCase().includes(q); +} + +// Busier hubs get a louder label; sqrt so a few giant hubs don't dwarf the rest of the labelled set. +export function labelSize(degree: number, maxDegree: number): number { + if (maxDegree <= 0) return MIN_LABEL; + const t = Math.sqrt(degree) / Math.sqrt(maxDegree); + return Math.round(MIN_LABEL + (MAX_LABEL - MIN_LABEL) * t); +} + +// Keep the top-`cap` most-connected nodes and their internal edges. Unlike the map's edge builder we +// do NOT require coordinates — the graph is non-geographic, so unlocated nodes belong here too. +export function buildNeighbourGraph(nodes: NodeSummary[], cap: number): NeighbourGraph { + const total = nodes.length; + // rank by neighbour count, id tie-break so the kept set + indices are stable across re-renders + const ranked = [...nodes].sort( + (a, b) => b.knownNeighborCount - a.knownNeighborCount || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + const kept = ranked.slice(0, cap); + const capped = total > kept.length; + + const indexById = new Map(); + kept.forEach((n, i) => indexById.set(n.id, i)); + const maxDegree = kept.reduce((m, n) => Math.max(m, n.knownNeighborCount), 0); + + const graphNodes: GraphNode[] = kept.map((n, i) => { + const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(n.nodeTypeName); + return { + id: n.id, + name: n.name ?? n.id.slice(0, 6), + category: cat === -1 ? OTHER_CATEGORY : cat, + nodeTypeName: n.nodeTypeName, + degree: n.knownNeighborCount, + symbolSize: symbolSize(n.knownNeighborCount, maxDegree), + label: + i < HUB_LABELS && n.knownNeighborCount > 0 + ? { show: true, fontSize: labelSize(n.knownNeighborCount, maxDegree) } + : undefined, + }; + }); + + const seen = new Set(); + const links: GraphLink[] = []; + for (const n of kept) { + if (!n.neighborIds) continue; + const from = indexById.get(n.id)!; + for (const otherId of n.neighborIds) { + if (otherId === n.id) continue; // no self-loops + const to = indexById.get(otherId); + if (to === undefined) continue; // skip edges to capped-out / foreign nodes + const key = n.id < otherId ? `${n.id}|${otherId}` : `${otherId}|${n.id}`; + if (seen.has(key)) continue; // undirected — one line per pair + seen.add(key); + links.push({ source: from, target: to }); + } + } + + return { nodes: graphNodes, links, total, capped }; +} + +// sqrt so a few high-degree hubs don't dwarf everything else; floor keeps degree-0 nodes clickable. +function symbolSize(degree: number, maxDegree: number): number { + if (maxDegree <= 0) return MIN_SIZE; + const t = Math.sqrt(degree) / Math.sqrt(maxDegree); + return MIN_SIZE + (MAX_SIZE - MIN_SIZE) * t; +} + +// Observation count → colour, log10 axis red→yellow→green (ports the map's line-colour expression). +export function obsColor(obs: number, c: { danger: string; warn: string; green: string }): string { + const x = Math.log10(Math.max(1, obs)); + if (x <= OBS_STOPS.warn) return blend(c.danger, c.warn, x / OBS_STOPS.warn); + const t = Math.min(1, (x - OBS_STOPS.warn) / (OBS_STOPS.green - OBS_STOPS.warn)); + return blend(c.warn, c.green, t); +} + +// Link age (days) → opacity, solid when fresh, faint by ~4 weeks (ports the map's opacity expression). +export function ageOpacity(ageDays: number): number { + const t = Math.max(0, Math.min(1, ageDays / AGE.staleDays)); + return AGE.freshOp + (AGE.staleOp - AGE.freshOp) * t; +} + +const CENTER_SIZE = 30; +const NEIGHBOUR_SIZE = 14; + +function egoNode(id: string, name: string | null, nodeTypeName: string, size: number, degree: number): GraphNode { + const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(nodeTypeName); + return { + id, + name: name ?? id.slice(0, 6), + category: cat === -1 ? OTHER_CATEGORY : cat, + nodeTypeName, + degree, + symbolSize: size, + label: { show: true }, + }; +} + +// The focused view: one centre node with its neighbours fanned out around it. Neighbours come from +// GET /nodes/{id}/neighbors (one row per neighbour+iata), folded per neighbour — obs summed, age from +// the freshest lastSeen. Edges carry those weights so the option can colour/fade them like the map. +export function buildEgoGraph( + center: { id: string; name: string | null; nodeTypeName: string }, + neighbors: NodeNeighbor[], + now: number, +): NeighbourGraph { + const folded = new Map(); + for (const nb of neighbors) { + if (nb.id === center.id) continue; + const prev = folded.get(nb.id); + if (prev) { + prev.obs += nb.observationCount; + prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen); + } else { + folded.set(nb.id, { name: nb.name ?? null, nodeTypeName: nb.nodeTypeName, obs: nb.observationCount, lastSeen: nb.lastSeen }); + } + } + + const nodes: GraphNode[] = [egoNode(center.id, center.name, center.nodeTypeName, CENTER_SIZE, folded.size)]; + const links: GraphLink[] = []; + for (const [id, n] of folded) { + // push the link first so target points at the node's about-to-be index + links.push({ source: 0, target: nodes.length, obs: n.obs, ageDays: Math.max(0, (now - n.lastSeen) / 86_400_000) }); + nodes.push(egoNode(id, n.name, n.nodeTypeName, NEIGHBOUR_SIZE, 0)); + } + return { nodes, links, total: nodes.length, capped: false }; +} + +// One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the +// GraphNode.category index lines up with this list. +function graphCategories(c: ChartColors) { + return [ + ...NODE_TYPES.map((t) => ({ name: t.label, itemStyle: { color: nodeTypeColor(t.name, c) } })), + { name: "Other", itemStyle: { color: c.primaryDim } }, + ]; +} + +// The themed ECharts force-graph option, for both the full mesh and the ego (single-node focus) view. +// No hover-adjacency emphasis: it toggles on/off as a dragged node lags the cursor, which flickers the +// graph. Focus is instead the ego view (opts.ego), a clean re-render the caller swaps in. +export function neighbourGraphOption( + graph: NeighbourGraph, + c: ChartColors, + opts: { ego?: boolean } = {}, +): EChartsOption { + const ego = !!opts.ego; + const big = graph.nodes.length > 500; // settle without animating once the full mesh gets dense + // weighted edges (ego view) get an obs→colour, freshness→opacity line; plain mesh edges stay uniform + const links = graph.links.map((l) => + l.obs != null + ? { ...l, lineStyle: { color: obsColor(l.obs, c), opacity: ageOpacity(l.ageDays ?? 0), width: 1.8 } } + : l, + ); + return { + animation: false, + backgroundColor: "transparent", + tooltip: { + ...tooltipStyle(c), + trigger: "item", + formatter: (p: unknown) => { + const param = p as { dataType?: string; data: Record }; + if (param.dataType === "edge") { + const obs = param.data.obs as number | undefined; + if (obs == null) return ""; // uniform mesh edge — nothing to show + const days = Math.round((param.data.ageDays as number) ?? 0); + return `${obs} obs · ${days === 0 ? "seen today" : `seen ${days}d ago`}`; + } + const d = param.data as unknown as GraphNode; + const type = d.nodeTypeName || "unknown"; + return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`; + }, + }, + legend: [ + { + data: graphCategories(c).map((cat) => cat.name), + bottom: 4, + left: "center", + icon: "circle", + itemWidth: 9, + itemHeight: 9, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + ], + series: [ + { + type: "graph", + layout: "force", + roam: true, + draggable: true, + scaleLimit: { min: 0.2, max: 8 }, + categories: graphCategories(c), + force: ego + ? { repulsion: 320, edgeLength: 120, gravity: 0.05, friction: 0.15, layoutAnimation: true } + : { repulsion: big ? 60 : 120, edgeLength: big ? [20, 60] : [40, 90], gravity: 0.08, friction: 0.2, layoutAnimation: !big }, + emphasis: { focus: "none", scale: false }, + label: { show: false, position: "right", color: c.textNormal, fontFamily: MONO, fontSize: 9 }, + labelLayout: { hideOverlap: true }, + lineStyle: { color: withAlpha(c.textMuted, 0.22), width: 0.6 }, + itemStyle: { borderColor: c.bgBase, borderWidth: 0.5 }, + data: graph.nodes, + links, + }, + ], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index d09650c..a517233 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -78,7 +78,7 @@ export interface ObserverTelemetry { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "observer"; +export type StatsTab = "mesh" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/hooks/useInfinitePages.ts b/src/hooks/useInfinitePages.ts index dcd45e9..3184c12 100644 --- a/src/hooks/useInfinitePages.ts +++ b/src/hooks/useInfinitePages.ts @@ -13,6 +13,9 @@ interface UseInfinitePagesOptions { // auto-chain every page eagerly (default). false = load only the first page; the caller pulls the // rest via loadMore() (e.g. on scroll) so a large dataset isn't fetched all at once. auto?: boolean; + // false = don't fetch at all (idle query). Lets a caller gate a heavy load off — e.g. the neighbour + // graph skips the ~5k-node "All regions" fetch until a region is picked. + enabled?: boolean; } // Page through a cursor-paginated endpoint. By default it auto-chains page by page as each settles so @@ -20,7 +23,7 @@ interface UseInfinitePagesOptions { // load only the first page and pull the rest on demand via loadMore(). Loads once per key (staleTime // Infinity, no maxPages); dedupes by id because a non-unique cursor can repeat a row across a page // boundary. Shared by the map and the entity tables. -export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, auto = true }: UseInfinitePagesOptions) { +export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions) { const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } = useInfiniteQuery({ queryKey, @@ -28,6 +31,7 @@ export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, au getNextPageParam: (last) => last.nextCursor ?? undefined, initialPageParam: undefined as number | undefined, staleTime: Infinity, + enabled, placeholderData: keepPrevious ? keepPreviousData : undefined, }); @@ -41,8 +45,8 @@ export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, au // In auto mode, chain to the next page once the current settles — this streams rows batch by batch. // In on-demand mode the caller drives loadMore() instead. useEffect(() => { - if (auto) loadMore(); - }, [auto, loadMore]); + if (auto && enabled) loadMore(); + }, [auto, enabled, loadMore]); const items = useMemo(() => { const seen = new Set(); diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx index 66917a0..9e659a1 100644 --- a/src/hooks/useTheme.tsx +++ b/src/hooks/useTheme.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"; import { type Theme, DEFAULT_THEME_ID, loadThemes, applyTheme } from "../lib/themes"; +import { ENABLED_THEME_IDS, selectableThemes } from "../lib/constants"; // loads themes from JSON, persists selection, applies CSS vars @@ -33,7 +34,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { loadThemes().then((loaded) => { setThemes(loaded); const saved = localStorage.getItem(STORAGE_KEY); - const match = loaded.find((t) => t.id === saved) ?? loaded[0]; + // Restrict the startup theme to the selectable set, so an allowlist can't leave the app on a + // theme the picker won't offer (e.g. a persisted default when only MeshMapper themes are enabled). + const selectable = selectableThemes(loaded, ENABLED_THEME_IDS); + const match = selectable.find((t) => t.id === saved) ?? selectable[0] ?? loaded[0]; if (!match) return; applyTheme(match); setPaletteRev((r) => r + 1); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 3d2fa49..ed4cab8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,8 @@ export const DEFAULT_PAGE_SIZE = 50; export const SCROLL_TOP_THRESHOLD_PX = 100; export const SCROLL_BOTTOM_THRESHOLD_PX = 500; +// reveal held live packets only at the very top, so the prepend that reveals them is jump-free +export const SCROLL_REVEAL_EPSILON_PX = 4; export const WS_PING_INTERVAL_MS = 30_000; export const WS_RECONNECT_BASE_MS = 1000; @@ -14,4 +16,53 @@ export const WS_RECONNECT_MAX_MS = 30_000; export const WS_RECONNECT_JITTER = 0.25; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Stats"] as const; +export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; + +// Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser; +// the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). + +// Split a comma-separated env value into trimmed, non-empty entries. An unset var or an +// un-substituted "__VITE_..__" sentinel yields a harmless single entry that matches nothing. +export function parseEnvList(raw: string | undefined): string[] { + return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean); +} + +// Tabs left after removing the disabled ones (case-insensitive), keeping display order. +export function filterEnabledTabs(tabs: readonly string[], disabledRaw: string | undefined): string[] { + const disabled = new Set(parseEnvList(disabledRaw).map((t) => t.toLowerCase())); + return tabs.filter((t) => !disabled.has(t.toLowerCase())); +} + +// Truthy env flag: "1"/"true"/"yes"/"on" (case-insensitive). Anything else — empty, unset, or an +// un-substituted sentinel — is false, so the gated behaviour only turns on when explicitly asked for. +export function parseEnvBool(raw: string | undefined): boolean { + return ["1", "true", "yes", "on"].includes((raw ?? "").trim().toLowerCase()); +} + +// The themes offered in the picker. When VITE_ENABLED_THEMES lists ids it's an EXCLUSIVE allowlist — +// only those show (e.g. a branded deployment that wants just its own themes). Otherwise every +// non-hidden theme shows and `hidden` ones (like the MeshMapper pair) stay out. +export function selectableThemes( + themes: readonly T[], + enabledIds: Set, +): T[] { + if (enabledIds.size > 0) { + return themes.filter((t) => enabledIds.has(t.id.toLowerCase())); + } + return themes.filter((t) => !t.hidden); +} + +const enabledTabs = filterEnabledTabs(TABS, import.meta.env.VITE_DISABLED_TABS); +// Floor: an all-disabled misconfig would otherwise leave a blank app. +export const ENABLED_TABS = enabledTabs.length > 0 ? enabledTabs : [...TABS]; + +export const ENABLED_THEME_IDS = new Set( + parseEnvList(import.meta.env.VITE_ENABLED_THEMES).map((s) => s.toLowerCase()), +); + +// "||" (not "??") so an empty sed substitution falls back to the default too. +export const APP_NAME = import.meta.env.VITE_APP_NAME || "BEACON"; +export const GITHUB_URL = "https://github.com/MeshCore-Beacon"; + +// Skip the once-per-session load splash entirely (e.g. an embedded/branded deployment). +export const SKIP_SPLASH = parseEnvBool(import.meta.env.VITE_SKIP_SPLASH); diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 28f54a9..46bd726 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -4,6 +4,8 @@ export interface Theme { id: string; name: string; vars: Record; + // hidden themes are omitted from the picker unless enabled via VITE_ENABLED_THEMES + hidden?: boolean; } export const DEFAULT_THEME_ID = "neutral-blue"; diff --git a/src/types/ws.ts b/src/types/ws.ts index 3a0590a..bbff970 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,5 +1,6 @@ import type { ChannelMessage } from "../features/channels/types"; import type { NodeIATA } from "../features/nodes/types"; +import type { ResolvedHop } from "./api"; // individual server-sent message shapes @@ -29,6 +30,13 @@ export interface WsPong { id: string; } +export interface WsConfigured { + v: 1; + type: "configured"; + id: string; + resolvePath: boolean; +} + export interface WsPacketObservation { v: 1; type: "event"; @@ -52,6 +60,9 @@ export interface WsPacketObservation { rssi: number; snr: number; sourceBroker: string; + // per-hop resolved path; populated only when the connection opts in via configure{resolvePath}, + // null otherwise. Same shape as the REST Observation.resolvedPath. + resolvedPath?: ResolvedHop[] | null; }; }; } @@ -124,6 +135,7 @@ export type WsServerMessage = | WsSubscribed | WsUnsubscribed | WsPong + | WsConfigured | WsPacketObservation | WsObserverStatus | WsNodeUpdate diff --git a/tests/App.selectionReset.test.tsx b/tests/App.selectionReset.test.tsx new file mode 100644 index 0000000..a3311d6 --- /dev/null +++ b/tests/App.selectionReset.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { StrictMode } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { SelectionResetOnRegion } from "../src/App"; +import { RegionProvider, useRegionSelection } from "../src/hooks/useRegion"; + +function RegionChanger() { + const { setSelection } = useRegionSelection(); + return ; +} + +describe("SelectionResetOnRegion", () => { + // Regression: a deep-linked ?node/?observer selection must survive load. The reset watches the raw + // selection (not the resolved regionKey) so the async slug→IATA expansion doesn't count as a change, + // and compares the previous value so StrictMode's double effect invoke can't fire it on mount. + it("does not reset on mount, even under StrictMode's double effect invoke", () => { + const onReset = vi.fn(); + render( + + + + + , + ); + expect(onReset).not.toHaveBeenCalled(); + }); + + it("resets once when the user changes the region selection", () => { + const onReset = vi.fn(); + render( + + + + , + ); + expect(onReset).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("change")); + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a9909e9..d2d7b4b 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -22,6 +22,33 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("getPackets", () => { + it("forwards the single-value server filters (routeType 0 survives, scope is encoded)", async () => { + const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); + + await getPackets(["YOW"], { payloadType: 4, routeType: 0, scope: "#bc" }); + + const url = getUrl(); + expect(url).toContain("/packets"); + expect(url).toContain("payloadType=4"); + expect(url).toContain("routeType=0"); + expect(url).toContain("scope=%23bc"); + }); + + it("omits the filter params when none are given", async () => { + const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); + + await getPackets(["YOW"], { cursor: 100 }); + + const url = getUrl(); + expect(url).not.toContain("payloadType="); + expect(url).not.toContain("routeType="); + expect(url).not.toContain("scope="); + expect(url).toContain("cursor=100"); + expect(url).toContain("limit=50"); + }); +}); + describe("getNodesPage", () => { const node: NodeSummary = { id: "n1", diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index a230a50..913b62c 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -136,6 +136,53 @@ describe("WsManager", () => { expect(sub.scope.iatas).toEqual(["YOW"]); }); + it("sends a configure frame when resolvePath is enabled while connected", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + mgr.setResolvePath(true); + + const configure = JSON.parse(ws.sent.at(-1)!); + expect(configure.type).toBe("configure"); + expect(configure.resolvePath).toBe(true); + }); + + it("re-sends the resolvePath configure after a reconnect", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + mgr.setResolvePath(true); + ws1.simulateClose(1006); + + vi.advanceTimersByTime(1500); + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateMessage({ v: 1, type: "hello", serverTime: 456, connectionId: "def" }); + + const frames = ws2.sent.map((s) => JSON.parse(s)); + expect(frames.some((f) => f.type === "configure" && f.resolvePath === true)).toBe(true); + }); + + it("handles a configured reply without throwing", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + expect(() => + ws.simulateMessage({ v: 1, type: "configured", id: "cfg-1", resolvePath: true }), + ).not.toThrow(); + }); + it("updates subscription without reconnecting", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index fe2c1bd..3ae3aed 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -24,7 +24,7 @@ describe("BottomNav", () => { }); it("highlights More when an overflow tab is active", () => { - render( {}} />); + render( {}} />); const more = screen.getByText("More").closest("button")!; expect(more.className).toContain("text-primary"); }); diff --git a/tests/components/CopyButton.test.tsx b/tests/components/CopyButton.test.tsx new file mode 100644 index 0000000..3d5fb4a --- /dev/null +++ b/tests/components/CopyButton.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyButton } from "../../src/components/CopyButton"; + +const writeText = vi.fn(); + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + writeText.mockClear(); +}); + +describe("CopyButton", () => { + it("shows the default 'Copy' label", () => { + render(); + expect(screen.getByRole("button")).toHaveTextContent("Copy"); + }); + + it("writes the full value to the clipboard on click", () => { + const key = "0123456789abcdef0123456789abcdef"; + render(); + fireEvent.click(screen.getByRole("button")); + expect(writeText).toHaveBeenCalledWith(key); + }); + + it("swaps to 'Copied' after clicking, then reverts", () => { + vi.useFakeTimers(); + try { + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).toHaveTextContent("Copied"); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button).toHaveTextContent("Copy"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the provided aria-label for the accessible name", () => { + render(); + expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx new file mode 100644 index 0000000..a7fe975 --- /dev/null +++ b/tests/components/CopyLinkButton.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyLinkButton } from "../../src/components/CopyLinkButton"; + +const writeText = vi.fn(); + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + writeText.mockClear(); +}); + +afterEach(() => { + window.history.replaceState({}, "", "/"); +}); + +describe("CopyLinkButton", () => { + it("shows the default 'Copy Link' label", () => { + render(); + expect(screen.getByRole("button")).toHaveTextContent("Copy Link"); + }); + + it("copies a URL carrying the given params on click", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(writeText).toHaveBeenCalledTimes(1); + const copied = new URL(writeText.mock.calls[0][0]); + expect(copied.searchParams.get("tab")).toBe("Nodes"); + expect(copied.searchParams.get("node")).toBe("abc123"); + }); + + it("swaps to 'Copied' after clicking, then reverts", () => { + vi.useFakeTimers(); + try { + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).toHaveTextContent("Copied"); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button).toHaveTextContent("Copy Link"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the provided aria-label for the accessible name", () => { + render(); + expect(screen.getByRole("button", { name: "Copy node link" })).toBeInTheDocument(); + }); + + it("evaluates a params thunk at click time, capturing live values", () => { + let zoom = "5"; + render( ({ tab: "Map", zoom })} />); + zoom = "11"; // changes after render, before the click + fireEvent.click(screen.getByRole("button")); + const copied = new URL(writeText.mock.calls[0][0]); + expect(copied.searchParams.get("tab")).toBe("Map"); + expect(copied.searchParams.get("zoom")).toBe("11"); + }); + + it("deletes params whose value is null, preserving unrelated ones", () => { + window.history.replaceState({}, "", "/?node_type=repeater&keep=1"); + render( ({ tab: "Map", node_type: null })} />); + fireEvent.click(screen.getByRole("button")); + const copied = new URL(writeText.mock.calls[0][0]); + expect(copied.searchParams.has("node_type")).toBe(false); + expect(copied.searchParams.get("keep")).toBe("1"); + expect(copied.searchParams.get("tab")).toBe("Map"); + }); +}); diff --git a/tests/components/DetailPanel.test.tsx b/tests/components/DetailPanel.test.tsx new file mode 100644 index 0000000..dfd6fd2 --- /dev/null +++ b/tests/components/DetailPanel.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DetailPanel } from "../../src/components/DetailPanel"; + +function renderPanel(props: { collapsible?: boolean; onClose?: () => void } = {}) { + const onClose = props.onClose ?? vi.fn(); + render( + +

neighbor details

+
, + ); + return { onClose }; +} + +describe("DetailPanel", () => { + it("renders no minimize control unless collapsible", () => { + renderPanel(); + expect(screen.queryByRole("button", { name: /minimize/i })).toBeNull(); + }); + + it("minimizes without closing, keeping the entity selected", () => { + const { onClose } = renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" })); + expect(onClose).not.toHaveBeenCalled(); + // still selected: the content stays mounted (just visually collapsed on mobile) + expect(screen.getByTestId("body")).toBeTruthy(); + // the control now offers to expand again + expect(screen.getByRole("button", { name: "Expand detail panel" })).toBeTruthy(); + }); + + it("toggles back to expanded", () => { + renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" })); + fireEvent.click(screen.getByRole("button", { name: "Expand detail panel" })); + expect(screen.getByRole("button", { name: "Minimize detail panel" })).toBeTruthy(); + }); + + it("still closes via the close button", () => { + const { onClose } = renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Close detail panel" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/features/map/map-url.test.ts b/tests/features/map/map-url.test.ts new file mode 100644 index 0000000..877819a --- /dev/null +++ b/tests/features/map/map-url.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { parseMapView, buildMapParams, type MapViewSnapshot } from "../../../src/features/map/map-url"; + +// URLSearchParams from a build result, skipping deleted (null) keys — models what the copy button does. +function toParams(built: Record): URLSearchParams { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(built)) if (v !== null) p.set(k, v); + return p; +} + +describe("parseMapView", () => { + it("is empty for no params", () => { + expect(parseMapView(new URLSearchParams(""))).toEqual({}); + }); + + it("reads lat+lng into a maplibre [lng, lat] center", () => { + expect(parseMapView(new URLSearchParams("lat=45.32&lng=-75.66"))).toEqual({ center: [-75.66, 45.32] }); + }); + + it("omits the center unless BOTH lat and lng are present", () => { + expect(parseMapView(new URLSearchParams("lat=45.32"))).toEqual({}); + expect(parseMapView(new URLSearchParams("lng=-75.66"))).toEqual({}); + }); + + it("omits an out-of-range center", () => { + expect(parseMapView(new URLSearchParams("lat=200&lng=0"))).toEqual({}); + expect(parseMapView(new URLSearchParams("lat=0&lng=500"))).toEqual({}); + expect(parseMapView(new URLSearchParams("lat=abc&lng=0"))).toEqual({}); + }); + + it("reads a valid zoom and omits an out-of-range or malformed one", () => { + expect(parseMapView(new URLSearchParams("zoom=9.4"))).toEqual({ zoom: 9.4 }); + expect(parseMapView(new URLSearchParams("zoom=0"))).toEqual({ zoom: 0 }); + expect(parseMapView(new URLSearchParams("zoom=99"))).toEqual({}); + expect(parseMapView(new URLSearchParams("zoom=-1"))).toEqual({}); + expect(parseMapView(new URLSearchParams("zoom=abc"))).toEqual({}); + }); + + it("reads clustering on/off, case-insensitively, ignoring junk", () => { + expect(parseMapView(new URLSearchParams("clustering=on"))).toEqual({ clustered: true }); + expect(parseMapView(new URLSearchParams("clustering=OFF"))).toEqual({ clustered: false }); + expect(parseMapView(new URLSearchParams("clustering=maybe"))).toEqual({}); + }); + + it("reads node_type by canonical name, case-insensitively, and drops unknown/All", () => { + expect(parseMapView(new URLSearchParams("node_type=repeater"))).toEqual({ nodeType: "repeater" }); + expect(parseMapView(new URLSearchParams("node_type=Repeater"))).toEqual({ nodeType: "repeater" }); + expect(parseMapView(new URLSearchParams("node_type=room_server"))).toEqual({ nodeType: "room_server" }); + expect(parseMapView(new URLSearchParams("node_type=Room"))).toEqual({}); // label, not a name + expect(parseMapView(new URLSearchParams("node_type=banana"))).toEqual({}); + expect(parseMapView(new URLSearchParams("node_type="))).toEqual({}); + }); + + it("reads the 3-way neighbor_lines mode, case-insensitively", () => { + expect(parseMapView(new URLSearchParams("neighbor_lines=on"))).toEqual({ neighborLines: "on" }); + expect(parseMapView(new URLSearchParams("neighbor_lines=Selected"))).toEqual({ neighborLines: "selected" }); + expect(parseMapView(new URLSearchParams("neighbor_lines=off"))).toEqual({ neighborLines: "off" }); + expect(parseMapView(new URLSearchParams("neighbor_lines=nope"))).toEqual({}); + }); + + it("reads a known basemap style id and drops an unknown one", () => { + expect(parseMapView(new URLSearchParams("style=positron"))).toEqual({ styleId: "positron" }); + expect(parseMapView(new URLSearchParams("style=DARK"))).toEqual({ styleId: "dark" }); + expect(parseMapView(new URLSearchParams("style=does-not-exist"))).toEqual({}); + }); + + it("reads the packet-flow toggle on/off", () => { + expect(parseMapView(new URLSearchParams("flow=on"))).toEqual({ flow: true }); + expect(parseMapView(new URLSearchParams("flow=off"))).toEqual({ flow: false }); + expect(parseMapView(new URLSearchParams("flow=x"))).toEqual({}); + }); + + it("combines every param into one view", () => { + const params = new URLSearchParams( + "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on", + ); + expect(parseMapView(params)).toEqual({ + center: [-113.58, 53.31], + zoom: 9, + clustered: false, + nodeType: "repeater", + neighborLines: "on", + styleId: "liberty", + flow: true, + }); + }); +}); + +describe("buildMapParams", () => { + const snapshot: MapViewSnapshot = { + center: [-113.583456, 53.312345], + zoom: 9.4567, + clustered: false, + nodeType: "repeater", + neighborLines: "on", + styleId: "liberty", + flow: true, + }; + + it("emits every managed key with rounded camera values", () => { + expect(buildMapParams(snapshot)).toEqual({ + lat: "53.31235", + lng: "-113.58346", + zoom: "9.46", + clustering: "off", + node_type: "repeater", + neighbor_lines: "on", + style: "liberty", + flow: "on", + }); + }); + + it("deletes node_type (null) when the filter is All", () => { + expect(buildMapParams({ ...snapshot, nodeType: "" }).node_type).toBeNull(); + }); + + it("round-trips through parseMapView (modulo rounding)", () => { + const built = buildMapParams(snapshot); + expect(parseMapView(toParams(built))).toEqual({ + center: [-113.58346, 53.31235], + zoom: 9.46, + clustered: false, + nodeType: "repeater", + neighborLines: "on", + styleId: "liberty", + flow: true, + }); + }); + + it("round-trips an All-node-type snapshot (no node_type key survives)", () => { + const built = buildMapParams({ ...snapshot, nodeType: "" }); + expect(parseMapView(toParams(built)).nodeType).toBeUndefined(); + }); +}); diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 0caf41a..79a7de4 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { nodesToFeatureCollection, filterByNodeType } from "../../../src/features/map/node-geojson"; -import type { NodeSummary } from "../../../src/features/nodes/types"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, buildFocusedNeighborEdges } from "../../../src/features/map/node-geojson"; +import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { return { @@ -76,6 +76,132 @@ describe("nodesToFeatureCollection", () => { }); }); +describe("buildNeighborEdges", () => { + const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); + const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); + const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); + + it("emits one undirected edge per neighbor pair (a<->b counted once) in [lng, lat] order", () => { + const fc = buildNeighborEdges([a, b], "on", null); + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.geometry).toEqual({ + type: "LineString", + coordinates: [[-75, 45], [-76, 46]], + }); + }); + + it("skips neighbor ids absent from the set or without coordinates", () => { + const lonely = node({ id: "a", lat: 45, lng: -75, neighborIds: ["ghost"] }); + const noCoord = node({ id: "b", lat: null, lng: null, neighborIds: ["a"] }); + expect(buildNeighborEdges([lonely, noCoord], "on", null).features).toEqual([]); + }); + + it("in 'selected' mode keeps only edges incident to the selected node", () => { + const fc = buildNeighborEdges([a, b, c], "selected", "b"); + expect(fc.features).toHaveLength(1); // only a<->b + expect(fc.features[0]!.properties.selected).toBe(true); + }); + + it("in 'on' mode emits all edges and flags those incident to the selected node", () => { + const fc = buildNeighborEdges([a, b, c], "on", "b"); + expect(fc.features).toHaveLength(2); // a<->b and a<->c + expect(fc.features.filter((f) => f.properties.selected)).toHaveLength(1); // a<->b + }); + + it("ignores a node that lists itself as a neighbor (no zero-length edge)", () => { + const selfRef = node({ id: "a", lat: 45, lng: -75, neighborIds: ["a", "b"] }); + const fc = buildNeighborEdges([selfRef, b], "on", null); + expect(fc.features).toHaveLength(1); // a<->b only, not a<->a + }); +}); + +describe("neighborFocusIds", () => { + const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); + const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); + const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); + + const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids); + + it("returns null when nothing is selected", () => { + expect(neighborFocusIds([a, b, c], null)).toBeNull(); + }); + + it("includes the selected node and its located neighbors", () => { + expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]); + }); + + it("treats neighbor links as undirected (a node listing the selection counts)", () => { + // b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set + expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]); + }); + + it("skips neighbor ids that are absent or unlocated", () => { + const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] }); + expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]); + }); + + it("returns null when the selected node is unlocated (no marker to keep lit)", () => { + const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] }); + expect(neighborFocusIds([unlocated, b], "a")).toBeNull(); + }); + + it("returns null when the selected node has no located neighbors", () => { + const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] }); + expect(neighborFocusIds([lonely, b], "x")).toBeNull(); + }); +}); + +describe("buildFocusedNeighborEdges", () => { + const DAY = 86400000; + const NOW = 1000 * DAY; + const sel = node({ id: "a", lat: 45, lng: -75 }); + function nb(o: Partial): NodeNeighbor { + return { id: "b", publicKey: "pk", nodeType: 2, nodeTypeName: "repeater", iata: "YOW", observationCount: 10, firstSeen: 0, lastSeen: NOW, lat: 46, lng: -76, ...o }; + } + + it("returns empty when nothing is selected", () => { + expect(buildFocusedNeighborEdges(null, [nb({})], NOW).features).toEqual([]); + }); + + it("returns empty when the selected node has no coordinates", () => { + expect(buildFocusedNeighborEdges(node({ id: "a", lat: null, lng: null }), [nb({})], NOW).features).toEqual([]); + }); + + it("draws one edge selected->neighbor with obs, in [lng, lat] order", () => { + const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lat: 46, lng: -76, observationCount: 42, lastSeen: NOW })], NOW); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.geometry.coordinates).toEqual([[-75, 45], [-76, 46]]); + expect(fc.features[0]!.properties.obs).toBe(42); + expect(fc.features[0]!.properties.selected).toBe(true); + expect(fc.features[0]!.properties.ageDays).toBe(0); + }); + + it("computes ageDays from lastSeen relative to now", () => { + const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lastSeen: NOW - 3 * DAY })], NOW); + expect(fc.features[0]!.properties.ageDays).toBeCloseTo(3); + }); + + it("aggregates a neighbor's per-iata rows: sums obs, keeps the freshest lastSeen", () => { + const fc = buildFocusedNeighborEdges(sel, [ + nb({ id: "b", iata: "YOW", observationCount: 30, lastSeen: NOW - 5 * DAY }), + nb({ id: "b", iata: "YYZ", observationCount: 12, lastSeen: NOW - 1 * DAY }), + ], NOW); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.properties.obs).toBe(42); + expect(fc.features[0]!.properties.ageDays).toBeCloseTo(1); + }); + + it("skips neighbors without coordinates and any self-referential row", () => { + const fc = buildFocusedNeighborEdges(sel, [ + nb({ id: "b", lat: undefined, lng: undefined }), + nb({ id: "a", lat: 45, lng: -75 }), // self — no zero-length edge + nb({ id: "c", lat: 47, lng: -77, observationCount: 5 }), + ], NOW); + expect(fc.features.map((f) => f.properties.obs)).toEqual([5]); + }); +}); + describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }), diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts new file mode 100644 index 0000000..05155a7 --- /dev/null +++ b/tests/features/map/packet-flow.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { resolvedPathNodes, posAtHop, trailCoords } from "../../../src/features/map/packet-flow"; +import type { ResolvedHop } from "../../../src/types/api"; + +function hop(id: string, lng: number, lat: number): ResolvedHop { + return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }; +} + +describe("resolvedPathNodes", () => { + it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { + const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("a", -75, 45), hop("b", -76, 46)]; + expect(resolvedPathNodes(path)).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); + }); + + it("skips hops with no located candidate", () => { + expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]); + }); +}); + +describe("posAtHop", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; + + it("returns hop endpoints at integer t and interpolates within a segment", () => { + expect(posAtHop(coords, 0)).toEqual([0, 0]); + expect(posAtHop(coords, 1)).toEqual([10, 0]); + expect(posAtHop(coords, 2)).toEqual([10, 10]); + expect(posAtHop(coords, 0.5)).toEqual([5, 0]); // halfway through hop 0 + expect(posAtHop(coords, 1.5)).toEqual([10, 5]); // halfway through hop 1 + }); + + it("clamps beyond either end", () => { + expect(posAtHop(coords, -1)).toEqual([0, 0]); + expect(posAtHop(coords, 9)).toEqual([10, 10]); + }); +}); + +describe("trailCoords", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; + + it("traces every crossed hop plus the current head position", () => { + expect(trailCoords(coords, 1.5)).toEqual([[0, 0], [10, 0], [10, 5]]); + }); +}); diff --git a/tests/features/map/useMapNodesData.test.tsx b/tests/features/map/useMapNodesData.test.tsx index 3154ef2..33f45ba 100644 --- a/tests/features/map/useMapNodesData.test.tsx +++ b/tests/features/map/useMapNodesData.test.tsx @@ -38,8 +38,8 @@ describe("useMapNodesData", () => { expect(result.current.nodes.map((n) => n.id)).toEqual(["a", "b", "c"]); expect(result.current.loadedCount).toBe(3); expect(mockGetNodesPage).toHaveBeenCalledTimes(2); - // second call paginates with the previous page's nextCursor - expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2 }); + // second call paginates with the previous page's nextCursor; the map always requests neighbor ids + expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2, neighbors: true }); }); it("stops after a single page when hasMore is false", async () => { diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index bd3d8d1..1f40e28 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,28 +1,37 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, useSearchParams } from "react-router-dom"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; +import type { PacketSummary } from "../../../src/types/api"; +const basePackets = () => ({ + allPackets: [] as PacketSummary[], + observerOptions: [], + newPacketCount: 0, + acknowledgeNewPackets: () => {}, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + observersByHash: new Map(), + handlePacketObservation: () => {}, + handleLagged: () => {}, + laggedCount: 0, + dismissLagged: () => {}, +}); +const usePackets = vi.fn(basePackets); vi.mock("../../../src/features/packets/usePackets", () => ({ - usePackets: () => ({ - allPackets: [], - observerOptions: [], - newPacketCount: 0, - acknowledgeNewPackets: () => {}, - fetchNextPage: () => {}, - hasNextPage: false, - isFetchingNextPage: false, - observersByHash: new Map(), - handlePacketObservation: () => {}, - handleLagged: () => {}, - laggedCount: 0, - dismissLagged: () => {}, - }), + usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/hooks/useRegion", () => ({ + useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), +})); + vi.mock("../../../src/hooks/useWsHandlers", () => ({ useWsPacketHandler: () => {}, useWsLaggedHandler: () => {}, @@ -63,6 +72,73 @@ function ExternalHashCloser() { ); } +describe("PacketList server filter wiring", () => { + function renderAt(url: string) { + render( + + + , + ); + } + + it("passes a single selected type to usePackets as the server filter", () => { + usePackets.mockClear(); + renderAt("/?types=4"); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadType: 4 }); + }); + + it("passes null for multi-select so history stays unfiltered", () => { + usePackets.mockClear(); + renderAt("/?types=2,4"); + expect(usePackets).toHaveBeenLastCalledWith(false, null); + }); +}); + +describe("PacketList loading feedback", () => { + afterEach(() => { + usePackets.mockImplementation(basePackets); + }); + + function renderList() { + render( + + + , + ); + } + + it("shows skeletons instead of the list plus a loading pill during an empty initial load", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true })); + renderList(); + + expect(screen.queryByTestId("expanded")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain("Loading packets…"); + }); + + it("keeps the list and shows the pill while paging in more history", () => { + const pkt = { packetHash: "h1", payloadType: 4, routeType: 1, firstHeardAt: 1, lastHeardAt: 1, observationCount: 1 } as PacketSummary; + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [pkt], isFetchingNextPage: true })); + renderList(); + + expect(screen.getByTestId("expanded")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("Loading packets… (1)"); + }); + + it("surfaces a failed history fetch through the pill", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isError: true })); + renderList(); + + expect(screen.getByRole("status").textContent).toContain("Failed to load packets"); + }); + + it("renders neither pill nor skeletons when idle", () => { + renderList(); + + expect(screen.getByTestId("expanded")).toBeTruthy(); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); + describe("PacketList expanded row", () => { it("follows the ?hash param so an external analyzer close deselects the row", () => { const onAnalyze = vi.fn(); diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index f84c905..192829a 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -46,6 +46,58 @@ describe("PayloadBreakdown — trace resolvedRoute overlay", () => { }); }); +describe("PayloadBreakdown — DISCOVER_REQ", () => { + // Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL). + // See beacon-server internal/ingest/packet.go parsedDiscoverReq. + const reqPayload = { + type: "DISCOVER_REQ", + raw: "0b00", + prefixOnly: true, + typeFilter: 0x06, // bits 1 and 2 → ADV_TYPE 1 (ChatNode) + 2 (Repeater) + tag: "0a0b0c0d", + since: 1_700_000_000, // epoch seconds + }; + + it("decodes the typeFilter bitfield into device-role names", () => { + render(); + expect(screen.getByText("ChatNode")).toBeInTheDocument(); + expect(screen.getByText("Repeater")).toBeInTheDocument(); + }); + + it("renders the tag as a 0x-prefixed hex value", () => { + render(); + expect(screen.getByText("0x0A0B0C0D")).toBeInTheDocument(); + }); +}); + +describe("PayloadBreakdown — DISCOVER_RESP", () => { + // See beacon-server internal/ingest/packet.go parsedDiscoverResp. + const respPayload = { + type: "DISCOVER_RESP", + raw: "0b01", + nodeType: 2, + nodeTypeName: "repeater", + requestSnr: -4.5, // responder's node-to-node reading of the request, not observer reception + tag: "0a0b0c0d", + pubKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + pubKeyPrefixOnly: false, + }; + + it("renders the responder node type and its request SNR", () => { + render(); + expect(screen.getByText("repeater")).toBeInTheDocument(); + expect(screen.getByText(/-4\.50/)).toBeInTheDocument(); + }); + + it("labels a full public key vs. an 8-byte prefix", () => { + const { unmount } = render(); + expect(screen.getByText(/Public Key/)).toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText(/Key Prefix/)).toBeInTheDocument(); + }); +}); + describe("PayloadBreakdown — GROUP_TEXT decrypted channel message", () => { // Backend GetPacket enrichment nests decrypted:{sender,content,sentAt} (sentAt is epoch ms). // See beacon-server db/packets.go + internal/ingest/side_effects.go. diff --git a/tests/features/packets/usePacketFilters.test.ts b/tests/features/packets/usePacketFilters.test.ts index b32bc55..43e4593 100644 --- a/tests/features/packets/usePacketFilters.test.ts +++ b/tests/features/packets/usePacketFilters.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest"; import { renderHook } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; -import { matchesFilters, usePacketFilters } from "../../../src/features/packets/usePacketFilters"; +import { matchesFilters, toServerFilter, usePacketFilters } from "../../../src/features/packets/usePacketFilters"; import { EMPTY_FILTERS } from "../../../src/features/packets/types"; -import type { PayloadTypeValue } from "../../../src/types/enums"; +import type { PayloadTypeValue, RouteTypeValue } from "../../../src/types/enums"; import type { PacketSummary } from "../../../src/types/api"; function pkt(over: Partial): PacketSummary { @@ -48,6 +48,40 @@ describe("matchesFilters — scope", () => { }); }); +describe("toServerFilter", () => { + it("returns null when nothing narrows to a single value", () => { + expect(toServerFilter(EMPTY_FILTERS)).toBeNull(); + expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [2, 4] as PayloadTypeValue[] })).toBeNull(); + }); + + it("emits payloadType only for a single selected type", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[] })).toEqual({ payloadType: 4 }); + }); + + it("emits routeType 0 (falsy) for a single selected route", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, routeTypes: [0] as RouteTypeValue[] })).toEqual({ routeType: 0 }); + }); + + it("emits scope for a single selected scope", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, scopes: ["#bc"] })).toEqual({ scope: "#bc" }); + }); + + it("emits only the single-valued dimensions when combined", () => { + const filters = { + ...EMPTY_FILTERS, + payloadTypes: [4] as PayloadTypeValue[], + routeTypes: [1, 2] as RouteTypeValue[], + scopes: ["#bc"], + }; + expect(toServerFilter(filters)).toEqual({ payloadType: 4, scope: "#bc" }); + }); + + it("ignores client-only filters (observers, search)", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, observers: ["o1"] })).toBeNull(); + expect(toServerFilter({ ...EMPTY_FILTERS, search: "ab" })).toBeNull(); + }); +}); + function routerAt(url: string) { return ({ children }: { children: ReactNode }) => createElement(MemoryRouter, { initialEntries: [url] }, children); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index 4f77c3d..ea5ea7f 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -1,9 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { usePackets } from "../../../src/features/packets/usePackets"; +import type { PacketServerFilter } from "../../../src/features/packets/types"; import type { PacketSummary } from "../../../src/types/api"; +import type { WsPacketObservation } from "../../../src/types/ws"; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), @@ -84,3 +86,242 @@ describe("usePackets gap healing", () => { expect(result.current.laggedCount).toBe(5); }); }); + +describe("usePackets server filter", () => { + let qc: QueryClient; + + beforeEach(() => { + getPackets.mockReset(); + getPackets.mockResolvedValue({ items: [packet("fresh")], nextCursor: 999 }); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("fetches filtered history under its own query key, leaving the unfiltered entry alone", async () => { + renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper }); + + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + expect(getPackets.mock.calls[0]![0]).toEqual(["YOW"]); + expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 }); + await waitFor(() => { + expect(qc.getQueryData(["packets", "YOW", { payloadType: 4 }])).toBeDefined(); + }); + expect(qc.getQueryData(["packets", "YOW"])).toBeUndefined(); + }); + + it("carries the filter on subsequent pages", async () => { + const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + await act(async () => { + await result.current.fetchNextPage(); + }); + + expect(getPackets).toHaveBeenCalledTimes(2); + expect(getPackets.mock.calls[1]![1]).toEqual({ cursor: 999, payloadType: 4 }); + }); + + it("reuses the cached unfiltered pages when the filter clears (no refetch)", async () => { + const { result, rerender } = renderHook( + ({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter), + { initialProps: { filter: null as PacketServerFilter | null }, wrapper }, + ); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + rerender({ filter: { payloadType: 4 } }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(2)); + + rerender({ filter: null }); + await waitFor(() => expect(result.current.allPackets.length).toBeGreaterThan(0)); + expect(getPackets).toHaveBeenCalledTimes(2); // staleTime Infinity + unchanged unfiltered key + }); + + it("keeps the live buffer when the filter changes", async () => { + const rafCallbacks: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + + const { result, rerender } = renderHook( + ({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter), + { initialProps: { filter: null as PacketServerFilter | null }, wrapper }, + ); + await waitFor(() => expect(getPackets).toHaveBeenCalled()); + + act(() => { + result.current.handlePacketObservation(observation("p1")); + rafCallbacks.splice(0).forEach((cb) => cb(0)); + }); + expect(result.current.newPacketCount).toBe(1); + + rerender({ filter: { payloadType: 4 } }); + + expect(result.current.allPackets.map((p) => p.packetHash)).toContain("p1"); + expect(result.current.newPacketCount).toBe(1); // store untouched by the key switch + }); + + it("reports isLoading during the first fetch of a key and clears it after", async () => { + let resolveFetch!: (v: unknown) => void; + getPackets.mockReturnValueOnce(new Promise((r) => (resolveFetch = r))); + + const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + await act(async () => { + resolveFetch({ items: [packet("fresh")], nextCursor: null }); + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it("reports isError when the history fetch fails", async () => { + getPackets.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => usePackets(), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("lag reset collapses the filtered entry and refetches with the filter", async () => { + const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + qc.setQueryData(["packets", "YOW", { payloadType: 4 }], { + pages: [ + { items: [packet("a1")], nextCursor: 200 }, + { items: [packet("b2")], nextCursor: 100 }, + { items: [packet("c3")], nextCursor: null }, + ], + pageParams: [undefined, 200, 100], + }); + getPackets.mockClear(); + result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 }); + + await waitFor(() => expect(getPackets).toHaveBeenCalled()); + await waitFor(() => { + const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW", { payloadType: 4 }]); + expect(data?.pages).toHaveLength(1); + }); + expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 }); + }); +}); + +function observation(hash: string): WsPacketObservation["data"] { + return { + packetHash: hash, + packet: { + payloadType: 4, + payloadTypeName: "ADVERT", + routeType: 1, + routeTypeName: "FLOOD", + isFirstObservation: true, + observationCount: 1, + }, + observation: { + observerId: "o1", + observerName: "Obs", + iata: "YOW", + heardAt: 1, + rssi: -80, + snr: 5, + sourceBroker: "b", + }, + }; +} + +describe("usePackets freeze while scrolled away", () => { + let qc: QueryClient; + let rafCallbacks: FrameRequestCallback[]; + + beforeEach(() => { + getPackets.mockReset(); + getPackets.mockResolvedValue({ items: [], nextCursor: null }); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + rafCallbacks = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0)); + + async function mount() { + const view = renderHook(({ frozen }) => usePackets(frozen), { + initialProps: { frozen: false }, + wrapper, + }); + await waitFor(() => expect(getPackets).toHaveBeenCalled()); + return view; + } + + it("withholds live packets prepended while frozen, but still counts them", async () => { + const { result, rerender } = await mount(); + + act(() => { + result.current.handlePacketObservation(observation("p1")); + flushRaf(); + }); + expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]); + + rerender({ frozen: true }); + + act(() => { + result.current.handlePacketObservation(observation("p2")); + flushRaf(); + }); + // frozen: the rendered list stays on p1; the banner still counts the held packet + expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]); + expect(result.current.newPacketCount).toBe(2); + }); + + it("reveals held packets once unfrozen", async () => { + const { result, rerender } = await mount(); + + act(() => { + result.current.handlePacketObservation(observation("p1")); + flushRaf(); + }); + rerender({ frozen: true }); + act(() => { + result.current.handlePacketObservation(observation("p2")); + flushRaf(); + }); + + rerender({ frozen: false }); + expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p2", "p1"]); + }); + + it("clears the new-packet count on acknowledge", async () => { + const { result } = await mount(); + + act(() => { + result.current.handlePacketObservation(observation("p1")); + result.current.handlePacketObservation(observation("p2")); + flushRaf(); + }); + expect(result.current.newPacketCount).toBe(2); + + act(() => result.current.acknowledgeNewPackets()); + expect(result.current.newPacketCount).toBe(0); + }); +}); diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts new file mode 100644 index 0000000..f3251f1 --- /dev/null +++ b/tests/features/stats/neighbour-graph.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize, nodeNameMatches } from "../../../src/features/stats/neighbour-graph"; +import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; + +function neighbor(overrides: Partial): NodeNeighbor { + return { + id: "nb", + publicKey: "pk", + nodeType: 1, + nodeTypeName: "repeater", + iata: "YYZ", + observationCount: 1, + firstSeen: 0, + lastSeen: 0, + ...overrides, + }; +} + +const DAY = 86_400_000; + +function node(overrides: Partial): NodeSummary { + return { + id: "n1", + publicKey: "pk", + nodeType: 1, + nodeTypeName: "repeater", + name: "Node 1", + lat: 45, + lng: -75, + iatas: [], + knownNeighborCount: 0, + ...overrides, + }; +} + +// obsColor takes explicit palette colours so the test is theme-independent. +const C = { danger: "#ff0000", warn: "#ffff00", green: "#00ff00" }; + +describe("buildNeighbourGraph", () => { + it("returns an empty graph for no nodes", () => { + expect(buildNeighbourGraph([], 1000)).toEqual({ nodes: [], links: [], total: 0, capped: false }); + }); + + it("includes unlocated nodes (unlike the map's coordinate-gated edges)", () => { + const g = buildNeighbourGraph([node({ id: "a", lat: null, lng: null })], 1000); + expect(g.nodes).toHaveLength(1); + expect(g.nodes[0]!.id).toBe("a"); + }); + + it("ranks by neighbour count and keeps only the top `cap`", () => { + const g = buildNeighbourGraph( + [ + node({ id: "low", knownNeighborCount: 1 }), + node({ id: "high", knownNeighborCount: 5 }), + node({ id: "mid", knownNeighborCount: 3 }), + ], + 2, + ); + expect(g.total).toBe(3); + expect(g.capped).toBe(true); + expect(g.nodes.map((n) => n.id)).toEqual(["high", "mid"]); + }); + + it("is not capped when total <= cap", () => { + const g = buildNeighbourGraph([node({ id: "a" }), node({ id: "b" })], 5); + expect(g.capped).toBe(false); + expect(g.total).toBe(2); + }); + + it("breaks equal-degree ties by id so indices are deterministic", () => { + const g = buildNeighbourGraph( + [node({ id: "b", knownNeighborCount: 2 }), node({ id: "a", knownNeighborCount: 2 })], + 5, + ); + expect(g.nodes.map((n) => n.id)).toEqual(["a", "b"]); + }); + + it("emits each undirected pair once even when both nodes list each other", () => { + const g = buildNeighbourGraph( + [node({ id: "a", neighborIds: ["b"] }), node({ id: "b", neighborIds: ["a"] })], + 5, + ); + expect(g.links).toHaveLength(1); + }); + + it("drops self-loops", () => { + const g = buildNeighbourGraph([node({ id: "a", neighborIds: ["a"] })], 5); + expect(g.links).toEqual([]); + }); + + it("drops edges to ids outside the kept set (foreign or capped-out)", () => { + const g = buildNeighbourGraph( + [ + node({ id: "keep", knownNeighborCount: 9, neighborIds: ["gone", "foreign"] }), + node({ id: "gone", knownNeighborCount: 0 }), + ], + 1, // only "keep" survives the cap + ); + expect(g.links).toEqual([]); + }); + + it("resolves link source/target to indices of the correct kept nodes", () => { + const g = buildNeighbourGraph( + [node({ id: "a", neighborIds: ["b"] }), node({ id: "b" })], + 5, + ); + expect(g.links).toHaveLength(1); + const { source, target } = g.links[0]!; + const ids = [g.nodes[source]!.id, g.nodes[target]!.id].sort(); + expect(ids).toEqual(["a", "b"]); + }); + + it("maps node type to a category index, bucketing unknowns to 'Other'", () => { + const g = buildNeighbourGraph( + [ + node({ id: "c", nodeTypeName: "companion" }), + node({ id: "r", nodeTypeName: "repeater" }), + node({ id: "rs", nodeTypeName: "room_server" }), + node({ id: "s", nodeTypeName: "sensor" }), + node({ id: "x", nodeTypeName: "mystery" }), + ], + 10, + ); + const cat = Object.fromEntries(g.nodes.map((n) => [n.id, n.category])); + expect(cat).toEqual({ c: 0, r: 1, rs: 2, s: 3, x: 4 }); + }); + + it("sizes nodes monotonically by degree, with a floor for degree 0", () => { + const g = buildNeighbourGraph( + [ + node({ id: "hub", knownNeighborCount: 40 }), + node({ id: "mid", knownNeighborCount: 8 }), + node({ id: "leaf", knownNeighborCount: 0 }), + ], + 10, + ); + const size = Object.fromEntries(g.nodes.map((n) => [n.id, n.symbolSize])); + expect(size.hub).toBeGreaterThan(size.mid!); + expect(size.mid).toBeGreaterThan(size.leaf!); + expect(size.leaf).toBeGreaterThan(0); + }); + + it("keeps a node with no neighborIds but contributes no links", () => { + const g = buildNeighbourGraph([node({ id: "a", neighborIds: undefined })], 5); + expect(g.nodes).toHaveLength(1); + expect(g.links).toEqual([]); + }); + + it("gives busier hubs a larger label font than quieter ones", () => { + const g = buildNeighbourGraph( + [node({ id: "hub", knownNeighborCount: 40 }), node({ id: "small", knownNeighborCount: 2 })], + 10, + ); + const hub = g.nodes.find((n) => n.id === "hub")!; + const small = g.nodes.find((n) => n.id === "small")!; + expect(hub.label?.show).toBe(true); + expect(small.label?.show).toBe(true); + expect(hub.label!.fontSize!).toBeGreaterThan(small.label!.fontSize!); + }); +}); + +describe("nodeNameMatches", () => { + it("matches a case-insensitive substring", () => { + expect(nodeNameMatches("McCall_Lake", "call")).toBe(true); + expect(nodeNameMatches("YWK Repeater", "repe")).toBe(true); + }); + it("does not match a missing substring", () => { + expect(nodeNameMatches("McCall_Lake", "xyz")).toBe(false); + }); + it("treats an empty/whitespace query as no match", () => { + expect(nodeNameMatches("anything", "")).toBe(false); + expect(nodeNameMatches("anything", " ")).toBe(false); + }); +}); + +describe("labelSize", () => { + it("grows with degree and is largest at the max", () => { + expect(labelSize(40, 40)).toBeGreaterThan(labelSize(5, 40)); + expect(labelSize(20, 40)).toBeGreaterThanOrEqual(labelSize(5, 40)); + }); + + it("clamps to a sane font range and survives maxDegree 0", () => { + expect(labelSize(0, 0)).toBeGreaterThanOrEqual(9); + expect(labelSize(0, 40)).toBeGreaterThanOrEqual(9); + expect(labelSize(1000, 1000)).toBeLessThanOrEqual(16); + }); +}); + +describe("obsColor", () => { + it("is red (danger) at one observation and below", () => { + expect(obsColor(1, C)).toBe("rgb(255, 0, 0)"); + expect(obsColor(0, C)).toBe("rgb(255, 0, 0)"); + }); + + it("saturates to green for high observation counts", () => { + expect(obsColor(1000, C)).toBe("rgb(0, 255, 0)"); + }); + + it("interpolates strictly between the endpoints for mid counts", () => { + const mid = obsColor(5, C); + expect(mid).not.toBe("rgb(255, 0, 0)"); + expect(mid).not.toBe("rgb(0, 255, 0)"); + }); +}); + +describe("ageOpacity", () => { + it("is nearly solid for fresh links", () => { + expect(ageOpacity(0)).toBeCloseTo(0.9); + expect(ageOpacity(-5)).toBeCloseTo(0.9); // clamped + }); + + it("fades to the floor by ~4 weeks and stays there", () => { + expect(ageOpacity(28)).toBeCloseTo(0.35); + expect(ageOpacity(56)).toBeCloseTo(0.35); // clamped + }); + + it("interpolates linearly in between", () => { + expect(ageOpacity(14)).toBeCloseTo(0.625); + }); +}); + +describe("buildEgoGraph", () => { + const NOW = 10 * DAY; + const center = { id: "c", name: "Center", nodeTypeName: "companion" }; + + it("puts the center first with its neighbours fanned out from it", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" }), neighbor({ id: "b" })], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c", "a", "b"]); + expect(g.links).toHaveLength(2); + expect(g.links.every((l) => l.source === 0)).toBe(true); + expect(g.nodes[0]!.category).toBe(0); // companion + }); + + it("shows a label on every node", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" })], NOW); + expect(g.nodes.every((n) => n.label?.show)).toBe(true); + }); + + it("folds per-iata rows: obs summed, freshest lastSeen wins", () => { + const g = buildEgoGraph( + center, + [ + neighbor({ id: "a", iata: "YYZ", observationCount: 3, lastSeen: 8 * DAY }), + neighbor({ id: "a", iata: "YUL", observationCount: 5, lastSeen: 9 * DAY }), + ], + NOW, + ); + expect(g.nodes.filter((n) => n.id === "a")).toHaveLength(1); + const link = g.links.find((l) => g.nodes[l.target]!.id === "a")!; + expect(link.obs).toBe(8); + expect(link.ageDays).toBeCloseTo(1); + }); + + it("excludes the center's own rows", () => { + const g = buildEgoGraph(center, [neighbor({ id: "c" })], NOW); + expect(g.nodes).toHaveLength(1); + expect(g.links).toEqual([]); + }); + + it("returns just the center when there are no neighbours", () => { + const g = buildEgoGraph(center, [], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c"]); + expect(g.links).toEqual([]); + }); + + it("never reports a negative edge age", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", lastSeen: NOW + 3 * DAY })], NOW); + expect(g.links[0]!.ageDays).toBe(0); + }); + + it("maps a neighbour's node type to a category index", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", nodeTypeName: "sensor" })], NOW); + expect(g.nodes.find((n) => n.id === "a")!.category).toBe(3); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts new file mode 100644 index 0000000..328fdd6 --- /dev/null +++ b/tests/lib/constants.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { TABS, parseEnvList, filterEnabledTabs, parseEnvBool, selectableThemes } from "../../src/lib/constants"; + +describe("parseEnvList", () => { + it("returns [] for undefined or empty", () => { + expect(parseEnvList(undefined)).toEqual([]); + expect(parseEnvList("")).toEqual([]); + }); + + it("splits, trims, and drops empty entries", () => { + expect(parseEnvList("Map,Nodes")).toEqual(["Map", "Nodes"]); + expect(parseEnvList(" Map , Nodes ,, ")).toEqual(["Map", "Nodes"]); + }); +}); + +describe("filterEnabledTabs", () => { + it("returns all tabs (order preserved) when nothing is disabled", () => { + expect(filterEnabledTabs(TABS, undefined)).toEqual([...TABS]); + expect(filterEnabledTabs(TABS, "")).toEqual([...TABS]); + }); + + it("excludes named tabs case-insensitively", () => { + expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + ]); + expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + ]); + }); + + it("ignores unknown tab names", () => { + expect(filterEnabledTabs(TABS, "bogus")).toEqual([...TABS]); + }); + + it("can disable every tab", () => { + expect(filterEnabledTabs(TABS, TABS.join(","))).toEqual([]); + }); +}); + +describe("parseEnvBool", () => { + it("is true for common truthy strings (case-insensitive)", () => { + for (const v of ["1", "true", "TRUE", "Yes", "on", " on "]) expect(parseEnvBool(v)).toBe(true); + }); + + it("is false for unset, empty, sentinel, or other values", () => { + for (const v of [undefined, "", "0", "false", "no", "__VITE_SKIP_SPLASH__"]) expect(parseEnvBool(v)).toBe(false); + }); +}); + +describe("selectableThemes", () => { + const THEMES = [ + { id: "neutral-blue", name: "Neutral Blue" }, + { id: "teal", name: "Teal" }, + { id: "meshmapper_dark", name: "MeshMapper Dark", hidden: true }, + { id: "meshmapper_light", name: "MeshMapper Light", hidden: true }, + ]; + + it("shows all non-hidden themes when no allowlist is set", () => { + expect(selectableThemes(THEMES, new Set()).map((t) => t.id)).toEqual(["neutral-blue", "teal"]); + }); + + it("shows ONLY the allowlisted themes when set (exclusive), including hidden ones", () => { + const ids = selectableThemes(THEMES, new Set(["meshmapper_dark", "meshmapper_light"])).map((t) => t.id); + expect(ids).toEqual(["meshmapper_dark", "meshmapper_light"]); + }); + + it("allowlist excludes non-listed defaults", () => { + expect(selectableThemes(THEMES, new Set(["teal"])).map((t) => t.id)).toEqual(["teal"]); + }); + + it("matches allowlist ids case-insensitively", () => { + const themes = [{ id: "MeshMapper_Dark", hidden: true }]; + expect(selectableThemes(themes, new Set(["meshmapper_dark"])).map((t) => t.id)).toEqual(["MeshMapper_Dark"]); + }); +});