From 5ac36ceb2b01da7e457793400b9c97d0d1c2481d Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:56:08 -0400 Subject: [PATCH 1/5] v1.3.0 (#27) 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 * Restore files clobbered by the main merge * Bump version to 1.2.1 * Drop empty observer telemetry frames and show airtime as duration * Back out frontend zero-frame filter; fixing at the source instead * Add 'view path on map' popup to the packet analyzer * Collapse the path map attribution to a bare (i) by default * Add path-map deep links, per-observer propagation, and tappable nodes * Keep path map at a fixed height on narrow screens * Show packet source and destination on the path map and analyzer * Add top advertisers to Mesh stats and a Talkers leaderboard tab * Add public key prefix search to the nodes table * Show device clock drift on the node detail panel * Only plot unambiguously-resolved path endpoints on the map * Fix public-key node search field switching and non-hex input * Add tests for path endpoint, trace, and clock-drift edge cases * Refresh stale radio-freq and telemetry comments for current backend * Send multi-select packet filters to the server * Group top advertisers and talkers on the Talkers tab * Lay out Talkers side by side and label advertiser node types * Mark each top advertiser with its IATA * Pin overview sparklines to a fixed 24h window The Observations and Active observers KPI cards show a fixed 24h snapshot (from /stats/overview), but their sparklines were fed by the range-driven observations series. Switching to 7d/30d reshaped the sparklines even though the numbers beside them stayed at 24h, since aggregateByHour drops empty hours and slice(-24) then spans a different set of hours. Give the sparklines their own dedicated 24h observations series so the whole top row is a consistent 24h snapshot; the range selector now only drives the charts below. React Query dedupes the extra call when the selector is already on 24h. * Split top advertisers into flood and direct adverts Surface each advertiser's flood vs direct advert counts with a per-day rate for the selected window, as a sortable table on the Talkers tab. * Add a Clock Drift analytics tab List repeaters and room servers whose advert-derived clock has drifted past the server threshold, worst first, in a sortable table. It's each node's latest reading, so there's no time range to pick. * Add a toggleable IATA border layer to the map Draw each active IATA's GeoJSON region border as an outline over a faint fill beneath the node markers, behind a Map Settings toggle that's off by default and shareable via a URL param. IATAs with no border answer 204 and are skipped. * Carry path and endpoint fields from WS observations into packet summaries * Extract usePacketDetail and share it across the analyzer and overlay * Split ?hash into row expansion and ?analyze for the analyzer drawer * Fix ?path deep link when ?analyze is absent * Add a pure path-summary model for the packet table's path line * Cover pathBytes-absent and unresolved-endpoint cases in path summary tests * Render the packet path line with truncation and confidence tints * Cover endpoint pairs, high tint, and unresolved-run chips in PacketPathLine * Add the two-line packet table row * Restore n/a fallback in observer and IATA cells, scope the covering test * Add the sticky packet table header * Add the nested observation table for expanded packet rows * Fix PathData sizing and add null-SNR test to ObservationTable * Add the expanded packet region with skeleton, error and empty states * Reorder PacketExpansion to show errors before empty state * Wire the packet table and expandable rows into the virtual list * Carry the picked observation into the analyzer and finish two other spec gaps - Stop resetting selectedObservationId when opening the analyzer, so picking an observation inside an expanded row lands the drawer on it instead of observations[0] - Disable View path on map in the expanded row when the detail has no resolvable path, matching the drawer's existing button - Strip a ?hash deep link once the first page has loaded and it matches no packet * Stop stripping ?hash on mount, it breaks deep links to older packets The analyzer drawer and path-map restore both read the live ?hash and fetch by hash directly, independent of whether the packet is in the loaded list. Deleting the param once the first page settles without a match unmounts the drawer and can drop path-map restores, for any shared link more than a couple of minutes old. * Keep the packet card layout below md * Keep the widest heard window when a live packet is re-observed * Close the analyzer on a mobile tab change and add an expanded-row copy link * Drop a stale doc pointer from two map comments * Collapse the packet row to a single line with hops and endpoints The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column. * Open the analyzer from an observation and add a hash size column Drops the Open analyzer and Copy link buttons from the expansion; the analyzer popup keeps its own copy link. Table rows butt up against each other now, so the whole strip is a click target. * Filter the region picker by IATA or airport name Typing narrows both groups: airports match on code or display name, regions match on their name or on any member code — a code-only match tags the row with the code that hit, so it doesn't look like a stray result. Empty groups drop their headers so the divider never dangles. The panel body moves into its own component so the query dies with the dropdown, and focus returns to the trigger on close. * Animate the live map path from source through to destination Share the endpoint-gating chain builder with the path map so both plot source and destination when the backend resolved them unambiguously. * Preserve linebreaks in message bodies --------- 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> --- src/App.tsx | 117 ++++++-- src/api/client.ts | 41 ++- src/components/AppShell.tsx | 175 +++++++---- src/features/channels/MessagePanel.tsx | 2 +- src/features/map/MapSettingsPanel.tsx | 31 ++ src/features/map/MapView.tsx | 27 +- src/features/map/PacketPathMap.tsx | 133 +++++++++ src/features/map/PacketPathMapModal.tsx | 89 ++++++ src/features/map/map-url.ts | 8 +- src/features/map/packet-flow.ts | 19 +- src/features/map/packet-path.ts | 128 ++++++++ src/features/map/types.ts | 6 + src/features/map/useMapBorders.ts | 92 ++++++ src/features/map/useMapBordersData.ts | 40 +++ src/features/map/useMapPacketFlow.ts | 10 +- src/features/nodes/NodeDetailPanel.tsx | 8 +- src/features/nodes/NodeFilterBar.tsx | 1 + src/features/nodes/NodeTable.tsx | 20 +- src/features/nodes/node-search.ts | 12 + src/features/nodes/types.ts | 8 +- src/features/observers/types.ts | 2 +- src/features/packets/ObservationTable.tsx | 60 ++++ src/features/packets/PacketAnalyzerDrawer.tsx | 52 ++-- .../packets/PacketAnalyzerOverlay.tsx | 11 +- src/features/packets/PacketEndpoints.tsx | 47 +++ src/features/packets/PacketExpansion.tsx | 97 ++++++ src/features/packets/PacketList.tsx | 41 ++- src/features/packets/PacketTableHeader.tsx | 22 ++ src/features/packets/PacketTableRow.tsx | 72 +++++ src/features/packets/PacketVirtualList.tsx | 53 +++- src/features/packets/packet-grid.ts | 7 + src/features/packets/path-summary.ts | 95 ++++++ src/features/packets/payload-renderers.tsx | 70 +++-- src/features/packets/types.ts | 8 +- src/features/packets/usePacketDetail.ts | 16 + src/features/packets/usePacketFilters.ts | 11 +- src/features/packets/usePackets.ts | 14 +- src/features/stats/ClockDriftTab.tsx | 71 +++++ src/features/stats/MeshTab.tsx | 8 +- src/features/stats/StatsOverview.tsx | 6 +- src/features/stats/StatsSubHeader.tsx | 24 +- src/features/stats/TalkersTab.tsx | 90 ++++++ src/features/stats/chartOptions.ts | 25 +- src/features/stats/transforms.ts | 7 +- src/features/stats/types.ts | 37 ++- src/features/stats/useStats.ts | 31 ++ src/index.css | 19 +- src/lib/formatters.ts | 27 +- src/types/api.ts | 12 + src/types/ws.ts | 7 +- .../App.analyzerObservationCarryOver.test.tsx | 138 +++++++++ tests/App.packetUrlContract.test.tsx | 189 ++++++++++++ tests/App.pathLinkRestore.test.tsx | 52 ++++ tests/api/client.test.ts | 123 +++++++- tests/components/AppShell.test.tsx | 167 ++++++++++- tests/features/channels/MessagePanel.test.tsx | 29 +- .../features/map/PacketPathMapModal.test.tsx | 109 +++++++ tests/features/map/map-url.test.ts | 12 +- tests/features/map/packet-flow.test.ts | 22 +- tests/features/map/packet-path.test.ts | 205 +++++++++++++ tests/features/map/useMapBordersData.test.ts | 31 ++ tests/features/nodes/NodeDetailPanel.test.tsx | 27 ++ tests/features/nodes/node-search.test.ts | 23 ++ .../packets/ObservationTable.test.tsx | 99 +++++++ .../packets/PacketAnalyzerDrawer.test.tsx | 105 ++++++- .../features/packets/PacketEndpoints.test.tsx | 67 +++++ .../features/packets/PacketExpansion.test.tsx | 176 +++++++++++ tests/features/packets/PacketList.test.tsx | 225 ++++++++++---- .../packets/PacketTableHeader.test.tsx | 50 ++++ .../features/packets/PacketTableRow.test.tsx | 119 ++++++++ .../packets/PacketVirtualList.test.tsx | 280 ++++++++++++++++++ tests/features/packets/path-summary.test.ts | 121 ++++++++ .../packets/payload-renderers.test.tsx | 73 +++++ .../features/packets/usePacketDetail.test.tsx | 31 ++ .../features/packets/usePacketFilters.test.ts | 23 +- tests/features/packets/usePackets.test.tsx | 145 ++++++++- tests/features/stats/chart-options.test.ts | 20 ++ tests/lib/formatters.test.ts | 52 ++++ 78 files changed, 4440 insertions(+), 282 deletions(-) create mode 100644 src/features/map/PacketPathMap.tsx create mode 100644 src/features/map/PacketPathMapModal.tsx create mode 100644 src/features/map/packet-path.ts create mode 100644 src/features/map/useMapBorders.ts create mode 100644 src/features/map/useMapBordersData.ts create mode 100644 src/features/nodes/node-search.ts create mode 100644 src/features/packets/ObservationTable.tsx create mode 100644 src/features/packets/PacketEndpoints.tsx create mode 100644 src/features/packets/PacketExpansion.tsx create mode 100644 src/features/packets/PacketTableHeader.tsx create mode 100644 src/features/packets/PacketTableRow.tsx create mode 100644 src/features/packets/packet-grid.ts create mode 100644 src/features/packets/path-summary.ts create mode 100644 src/features/packets/usePacketDetail.ts create mode 100644 src/features/stats/ClockDriftTab.tsx create mode 100644 src/features/stats/TalkersTab.tsx create mode 100644 tests/App.analyzerObservationCarryOver.test.tsx create mode 100644 tests/App.packetUrlContract.test.tsx create mode 100644 tests/App.pathLinkRestore.test.tsx create mode 100644 tests/features/map/PacketPathMapModal.test.tsx create mode 100644 tests/features/map/packet-path.test.ts create mode 100644 tests/features/map/useMapBordersData.test.ts create mode 100644 tests/features/nodes/node-search.test.ts create mode 100644 tests/features/packets/ObservationTable.test.tsx create mode 100644 tests/features/packets/PacketEndpoints.test.tsx create mode 100644 tests/features/packets/PacketExpansion.test.tsx create mode 100644 tests/features/packets/PacketTableHeader.test.tsx create mode 100644 tests/features/packets/PacketTableRow.test.tsx create mode 100644 tests/features/packets/PacketVirtualList.test.tsx create mode 100644 tests/features/packets/path-summary.test.ts create mode 100644 tests/features/packets/usePacketDetail.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 73637fb..d64fd9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; import { BrowserRouter, useSearchParams } from "react-router-dom"; -import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion"; import { ALL_REGIONS, @@ -18,6 +18,7 @@ import { SplashScreen } from "./components/SplashScreen"; import { PacketList } from "./features/packets/PacketList"; import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer"; import { PacketAnalyzerOverlay } from "./features/packets/PacketAnalyzerOverlay"; +import { PacketPathMapModal } from "./features/map/PacketPathMapModal"; import { NodeTable } from "./features/nodes/NodeTable"; import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel"; import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay"; @@ -26,9 +27,10 @@ import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; -import { getPacketDetail } from "./api/client"; +import { usePacketDetail } from "./features/packets/usePacketDetail"; import { WsManager } from "./api/ws-manager"; import { WS_URL, ENABLED_TABS } from "./lib/constants"; +import type { PacketDetail } from "./types/api"; // 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,6 +98,29 @@ function RegionUrlSync() { return null; } +// Restores a shared "?path" link once its detail arrives. A copied path link carries ?hash without +// ?analyze (PacketPathMapModal's Copy Link strips it), so this can't reuse the analyzer drawer's fetch +// and needs its own — sharing usePacketDetail's query cache means that costs nothing extra when both +// params are present. +export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore }: { + initialPath: string | null; + hash: string | null; + analyzerDetail: PacketDetail | undefined; + onRestore: (detail: PacketDetail, key: string) => void; +}) { + const { data: pathLinkDetail } = usePacketDetail(initialPath ? hash : null); + const handledRef = useRef(false); + + useEffect(() => { + const detail = analyzerDetail ?? pathLinkDetail; + if (!initialPath || !detail || handledRef.current) return; + handledRef.current = true; + onRestore(detail, initialPath); + }, [initialPath, analyzerDetail, pathLinkDetail, onRestore]); + + return null; +} + // 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, @@ -127,8 +152,9 @@ function AppInner() { // 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")); + // ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button) + // ?analyze=1 is a boolean flag; the hash always lives in ?hash, so ?analyze alone opens nothing + const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = 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 @@ -137,35 +163,44 @@ function AppInner() { const [overlayNodeId, setOverlayNodeId] = useState(null); // packet analyzer shown as a modal over the node panel (clicking a node's observation row) const [overlayPacketHash, setOverlayPacketHash] = useState(null); + // packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map") + const [pathMapDetail, setPathMapDetail] = useState(null); + // Frozen together: the restore must fetch the hash the link asked for, even if the user clicks a + // different row before it resolves. + const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") })); + const [pathMapInitialKey, setPathMapInitialKey] = useState(null); - // short staleTime: observations keep accruing, so reopening the analyzer should show them - // instead of a snapshot frozen at first open - const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({ - queryKey: ["packet-detail", analyzerHash], - queryFn: () => getPacketDetail(analyzerHash!), - enabled: !!analyzerHash, - staleTime: 30_000, - }); + const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); + const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); - const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({ - queryKey: ["packet-detail", overlayPacketHash], - queryFn: () => getPacketDetail(overlayPacketHash!), - enabled: !!overlayPacketHash, - staleTime: 30_000, - }); + const handlePathLinkRestore = useCallback((detail: PacketDetail, key: string) => { + setPathMapDetail(detail); + setPathMapInitialKey(key); + }, []); + + // "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own + const handleViewPath = useCallback((detail: PacketDetail) => { + setPathMapDetail(detail); + setPathMapInitialKey(null); + }, []); const handleAnalyze = useCallback((hash: string | null) => { - setAnalyzerHash(hash); - setSelectedObservationId(null); - }, []); + // No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer. + setSearchParams((p) => { + const n = new URLSearchParams(p); + if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); } + else n.delete("analyze"); + return n; + }, { replace: true }); + }, [setSearchParams]); const handleTabChange = (tab: string) => { setOverlayNodeId(null); setOverlayPacketHash(null); - // On mobile a detail panel fills the screen, so leaving its tab must close it; desktop side - // panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this. + setPathMapDetail(null); + // On mobile a detail panel (and the analyzer) fills the screen, so leaving its tab must close it; + // desktop side panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this. if (isMobile) { - setAnalyzerHash(null); setSelectedObservationId(null); setSelectedNodeId(null); setSelectedObserverId(null); @@ -173,6 +208,8 @@ function AppInner() { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); + // the analyzer is URL-backed, so its mobile close lives here rather than above + if (isMobile) next.delete("analyze"); // stats sub-state shouldn't haunt the URL on other tabs if (tab !== "Analytics") { next.delete("statsTab"); @@ -188,10 +225,11 @@ function AppInner() { setOverlayNodeId(null); setOverlayPacketHash(null); setSelectedObserverId(null); + setPathMapDetail(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 + // analyzer's ?analyze 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) => { @@ -236,7 +274,15 @@ function AppInner() { }, []); const tabContent: Record = { - Packets: , + Packets: ( + + ), Nodes: , Observers: , Routes: , @@ -253,6 +299,12 @@ function AppInner() { +
@@ -268,6 +320,7 @@ function AppInner() { onSelectObservation={setSelectedObservationId} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} + onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -302,6 +355,18 @@ function AppInner() { handleTabChange("Observers"); setSelectedObserverId(observerId); }} + onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} + inactive={!!pathMapDetail} + /> + )} + {pathMapDetail && ( + { + setPathMapDetail(null); + setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true }); + }} /> )}
diff --git a/src/api/client.ts b/src/api/client.ts index 116ca58..3e29476 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,11 +9,17 @@ import type { PayloadBreakdownItem, TopNode, TopObserver, + TopAdvertiser, + TopTalker, RadioPreset, ScopeStats, ObserverTelemetry, NodeTypeCount, + ClockDriftEntry, } from "../features/stats/types"; +import type { Feature, Polygon, MultiPolygon } from "geojson"; + +export type IataBorder = Feature; // typed fetch wrapper with query params @@ -58,15 +64,15 @@ function iatasParam(iatas?: string[]): string | undefined { export function getPackets( iatas: string[] | undefined, - params?: { cursor?: number; limit?: number; payloadType?: number; routeType?: number; scope?: string }, + params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: 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, + payloadTypes: params?.payloadTypes?.length ? params.payloadTypes.join(",") : undefined, + routeTypes: params?.routeTypes?.length ? params.routeTypes.join(",") : undefined, + scopes: params?.scopes?.length ? params.scopes.join(",") : undefined, }); } @@ -78,6 +84,17 @@ export function getIatas(): Promise { return request("/iatas"); } +// An IATA's GeoJSON border, or null when none is configured. Can't use request(): the endpoint +// answers 204 (empty body) or a literal `null` for "no border", and request() always parses JSON. +export async function getIataBorder(iata: string): Promise { + const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin); + const res = await fetch(url.toString()); + if (res.status === 204) return null; + if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText); + const body = await res.json(); + return (body ?? null) as IataBorder | null; +} + export function getRegions(): Promise { return request("/regions"); } @@ -209,6 +226,7 @@ export function getNodesPage( limit?: number; type?: string; name?: string; + pubkeyPrefix?: string; // case-insensitive hex prefix; server matches and validates supportsMultibytePaths?: "true" | "false"; supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) @@ -220,6 +238,7 @@ export function getNodesPage( limit: params?.limit ?? DEFAULT_PAGE_SIZE, typeName: params?.type, name: params?.name, + pubkeyPrefix: params?.pubkeyPrefix, supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, @@ -282,6 +301,14 @@ export function getTopObservers(iatas?: string[], since?: number, limit = 10): P return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); } +export function getTopAdvertisers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-advertisers", { iatas: iatasParam(iatas), since, limit }); +} + +export function getTopTalkers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-talkers", { iatas: iatasParam(iatas), since, limit }); +} + export function getRadioPresets(iatas?: string[]): Promise { return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); } @@ -290,6 +317,12 @@ export function getStatsNodeTypes(iatas?: string[]): Promise { return request("/stats/node-types", { iatas: iatasParam(iatas) }); } +// Repeaters/room servers whose clock has drifted past the server threshold, worst-first. Not +// time-windowed and top-N only (no cursor), so callers pass a generous limit and page client-side. +export function getClockDrift(iatas?: string[], limit = 100): Promise { + return request("/stats/clock-drift", { iatas: iatasParam(iatas), limit }); +} + // renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes // aggregate (packet/observer/node counts), reported globally regardless of the active region. export function getStatsScopes(): Promise { diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 11a0254..bcff0da 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState, useEffect } from "react"; +import { type ReactNode, useState, useEffect, useMemo, useRef } from "react"; import { ErrorBoundary } from "./ErrorBoundary"; import { useQuery } from "@tanstack/react-query"; import { useRegionSelection, useRegions } from "../hooks/useRegion"; @@ -84,8 +84,43 @@ function regionSummaryLabel(selection: RegionSelection): string { // Grouped multi-select: regions (each expands to its member IATAs) on top, then individual IATAs. // Toggling keeps the dropdown open so several can be picked; "All Regions" clears the selection. function RegionSelector() { + const { selection } = useRegionSelection(); + + return ( + ( + + )} + > + {() => } + + ); +} + +// Split out from RegionSelector so the filter query lives and dies with the open panel. +function RegionSelectorPanel() { const { selection, setSelection } = useRegionSelection(); const { regions } = useRegions(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + // Take focus for typing, then hand it back on close — same restore rule as useFocusTrap. + useEffect(() => { + const restoreTo = document.activeElement as HTMLElement | null; + inputRef.current?.focus(); + return () => { + if (restoreTo && restoreTo !== document.body && document.contains(restoreTo)) restoreTo.focus(); + }; + }, []); const { data: iatas, isError: iatasError } = useQuery({ queryKey: ["iatas"], @@ -109,63 +144,99 @@ function RegionSelector() { }); }; + const q = query.trim().toLowerCase(); + + // A region matches on its name or on any member code. A code-only match carries those codes so the + // row can show why it surfaced — otherwise it reads as a stray result. + const shownRegions = useMemo(() => { + if (!q) return regions.map((region) => ({ region, matched: [] as string[] })); + return regions.flatMap((region) => { + if (region.name.toLowerCase().includes(q)) return [{ region, matched: [] as string[] }]; + const matched = region.iatas.filter((code) => code.toLowerCase().includes(q)); + return matched.length > 0 ? [{ region, matched }] : []; + }); + }, [regions, q]); + + // displayName is the closest thing to a city the API carries, and it's absent for IATAs the server + // auto-created from packet traffic — those stay reachable by code. + const shownIatas = useMemo(() => { + if (!iatas || !q) return iatas ?? []; + return iatas.filter( + (i) => i.iata.toLowerCase().includes(q) || (i.displayName ?? "").toLowerCase().includes(q), + ); + }, [iatas, q]); + + const showAll = !q || "all regions".includes(q); + const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed + const hasRowsAbove = showAll || shownRegions.length > 0; + return ( - ( + <> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + // Escape empties the box first; only a second press reaches Dropdown's close handler. + if (e.key === "Escape" && query) { + e.stopPropagation(); + setQuery(""); + } + }} + placeholder="Filter IATA or name…" + className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim" + /> +
+ + {showAll && ( )} - > - {() => ( + + {shownRegions.length > 0 && ( <> - +
Regions
+ {shownRegions.map(({ region, matched }) => { + const checked = selection.regions.includes(region.slug); + return ( + + ); + })} + + )} - {regions.length > 0 && ( - <> -
Regions
- {regions.map((r) => { - const checked = selection.regions.includes(r.slug); - return ( - - ); - })} - - )} - -
IATA
+ {showIataGroup && ( + <> +
IATA
{iatas ? ( - iatas.map((i) => { + shownIatas.map((i) => { const checked = selection.iatas.includes(i.iata); return (
-
{msg.content}
+
{msg.content}
); } diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index c8d208a..8f86744 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -19,6 +19,23 @@ const NEIGHBOR_OPTIONS = [ { value: "selected", label: "Selected" }, { value: "off", label: "Off" }, ]; +const BORDER_OPTIONS = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, +]; + +// Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme. +function BorderLegend() { + return ( +
+ + IATA region outline +
+ ); +} // Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors // (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. @@ -49,6 +66,8 @@ interface MapSettingsPanelProps { onClusteredChange: (c: boolean) => void; neighborLines: NeighborLinesMode; onNeighborLinesChange: (mode: NeighborLinesMode) => void; + borders: boolean; + onBordersChange: (on: boolean) => void; // builds deep-link params for the current view, evaluated at copy time (reads the live camera) buildShareParams: () => Record; } @@ -62,6 +81,8 @@ export function MapSettingsPanel({ onClusteredChange, neighborLines, onNeighborLinesChange, + borders, + onBordersChange, buildShareParams, }: MapSettingsPanelProps) { const isMobile = useIsMobile(); @@ -134,6 +155,16 @@ export function MapSettingsPanel({ /> {neighborLines === "selected" && } +
+ onBordersChange(v === "on")} + className="w-full" + /> + {borders && } +
localStorage -> default; the URL wins for this session but is never written back to - // localStorage, so a shared link can't clobber the visitor's saved prefs. See docs/superpowers/specs. + // localStorage, so a shared link can't clobber the visitor's saved prefs. const [searchParams] = useSearchParams(); const [urlView] = useState(() => parseMapView(searchParams)); @@ -87,6 +89,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp // live packet-flow animation: opt-in per session (off by default, not persisted; a deep link can seed it) const [packetFlow, setPacketFlow] = useState(() => urlView.flow ?? false); + // IATA region borders overlay, off by default; seeded URL -> localStorage like the other toggles + const [borders, setBorders] = useState(() => urlView.borders ?? localStorage.getItem(MAP_BORDERS_STORAGE_KEY) === "on"); + const handleBordersChange = useCallback((on: boolean) => { + setBorders(on); + localStorage.setItem(MAP_BORDERS_STORAGE_KEY, on ? "on" : "off"); + }, []); + // A deep-link camera opens the map here and suppresses the initial region fit (see useMapLibre). const initialCamera = useMemo( () => (urlView.center ? { center: urlView.center, zoom: urlView.zoom ?? DEFAULT_ZOOM } : undefined), @@ -167,6 +176,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null; }, [iatas, selectedIatas]); + // Borders to draw: the selected region's IATAs, or every IATA for "All" (most have none configured, + // which resolves to a 204 and is dropped). Only fetched while the layer is toggled on. + const borderIatas = useMemo(() => { + const all = (iatas ?? []).map((i) => i.iata); + return selectedIatas && selectedIatas.length > 0 ? all.filter((c) => selectedIatas.includes(c)) : all; + }, [iatas, selectedIatas]); + const borderData = useMapBordersData(borderIatas, borders); + const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome @@ -183,12 +200,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp neighborLines, styleId, flow: packetFlow, + borders, }; return { tab: "Map", ...buildMapParams(snapshot) }; - }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow]); + }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); + useMapBorders(mapRef, isReady, borderData, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); return ( @@ -206,6 +225,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp onClusteredChange={handleClusteredChange} neighborLines={neighborLines} onNeighborLinesChange={handleNeighborLinesChange} + borders={borders} + onBordersChange={handleBordersChange} buildShareParams={buildShareParams} /> setPacketFlow((v) => !v)} /> diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx new file mode 100644 index 0000000..87d6cea --- /dev/null +++ b/src/features/map/PacketPathMap.tsx @@ -0,0 +1,133 @@ +// src/features/map/PacketPathMap.tsx +import { useEffect, useRef, useState } from "react"; +import maplibregl from "maplibre-gl"; +import type { + Map as MapLibreMap, + GeoJSONSource, + LineLayerSpecification, + CircleLayerSpecification, + SymbolLayerSpecification, +} from "maplibre-gl"; +import type { Point } from "geojson"; +import type { PacketPath } from "./packet-path"; +import { packetPathsToFeatures } from "./packet-path"; +import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types"; + +// Private ids — this map instance is dedicated to the popup, so they can't collide with the main map. +const LINE_SOURCE = "pp-lines"; +const LINE_LAYER = "pp-lines"; +const NODE_SOURCE = "pp-nodes"; +const NODE_LAYER = "pp-nodes"; +const NODE_LABEL_LAYER = "pp-node-labels"; + +function paletteVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +// A self-contained MapLibre map that draws a packet's resolved path(s). Owns its own instance so it +// never touches the Map tab's map; the pure builders shape the data, this wires it to GL. +export function PacketPathMap({ paths, selectedKey, styleId }: { + paths: PacketPath[]; + selectedKey: string | null; + styleId: string; +}) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const [ready, setReady] = useState(false); + + // build the map once (styleId is read at creation; the popup doesn't hot-swap basemaps) + useEffect(() => { + if (mapRef.current || !containerRef.current) return; + const map = new maplibregl.Map({ + container: containerRef.current, + style: resolveMapStyle(styleId).url, + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + mapRef.current = map; + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); + map.addControl(new maplibregl.AttributionControl({ compact: true })); + // start the attribution as a bare (i) instead of the wide expanded bar it pops open on load — + // on mobile that bar overlaps the observer list beneath the map (same trick as useMapLibre) + const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib"); + attrib?.classList.add("maplibregl-compact"); + attrib?.classList.remove("maplibregl-compact-show"); + // click a path node → popup with its name and raw-decimal coords; pointer cursor on hover + map.on("mouseenter", NODE_LAYER, () => { map.getCanvas().style.cursor = "pointer"; }); + map.on("mouseleave", NODE_LAYER, () => { map.getCanvas().style.cursor = ""; }); + map.on("click", NODE_LAYER, (e) => { + const f = e.features?.[0]; + if (!f) return; + const [lng, lat] = (f.geometry as Point).coordinates as [number, number]; + const el = document.createElement("div"); + const name = document.createElement("div"); + name.className = "pp-popup-name"; + name.textContent = (f.properties?.title as string) ?? ""; + const coords = document.createElement("div"); + coords.className = "pp-popup-coords"; + coords.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + el.append(name, coords); + // a fresh popup per click; closeOnClick removes the previous one on the same click + new maplibregl.Popup({ closeButton: false, closeOnClick: true, offset: 10 }) + .setLngLat([lng, lat]).setDOMContent(el).addTo(map); + }); + const onLoad = () => setReady(true); + map.on("load", onLoad); + return () => { + map.remove(); + mapRef.current = null; + setReady(false); + }; + }, [styleId]); + + // (re)build layers, push data, and frame the shown paths whenever data or selection changes + useEffect(() => { + const map = mapRef.current; + if (!map || !ready) return; + const { lines, points, bounds } = packetPathsToFeatures(paths, selectedKey); + + if (!map.getSource(LINE_SOURCE)) map.addSource(LINE_SOURCE, { type: "geojson", data: lines }); + if (!map.getLayer(LINE_LAYER)) { + map.addLayer({ + id: LINE_LAYER, type: "line", source: LINE_SOURCE, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": ["get", "color"], "line-width": 2.5, "line-opacity": 0.9 }, + } as LineLayerSpecification); + } + if (!map.getSource(NODE_SOURCE)) map.addSource(NODE_SOURCE, { type: "geojson", data: points }); + if (!map.getLayer(NODE_LAYER)) { + map.addLayer({ + id: NODE_LAYER, type: "circle", source: NODE_SOURCE, + paint: { + "circle-radius": ["case", ["==", ["get", "endpoint"], "mid"], 4, 6], + "circle-color": ["get", "color"], + // white ring marks the observer-end node; every other node gets a dark ring + "circle-stroke-width": 2, + "circle-stroke-color": ["case", ["==", ["get", "endpoint"], "end"], "#ffffff", "#00000088"], + }, + } as CircleLayerSpecification); + } + if (!map.getLayer(NODE_LABEL_LAYER)) { + map.addLayer({ + id: NODE_LABEL_LAYER, type: "symbol", source: NODE_SOURCE, + layout: { "text-field": ["get", "label"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, + paint: { + "text-color": paletteVar("--palette-text-bright", "#e5e7eb"), + "text-halo-color": paletteVar("--palette-bg-base", "#0a0a0a"), + "text-halo-width": 1.4, + }, + } as SymbolLayerSpecification); + } + + (map.getSource(LINE_SOURCE) as GeoJSONSource).setData(lines); + (map.getSource(NODE_SOURCE) as GeoJSONSource).setData(points); + + if (bounds.length) { + const b = bounds.reduce((acc, p) => acc.extend(p), new maplibregl.LngLatBounds(bounds[0], bounds[0])); + map.fitBounds(b, { padding: 60, maxZoom: IATA_ZOOM }); + } + }, [ready, paths, selectedKey]); + + return
; +} diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx new file mode 100644 index 0000000..e9c5d4b --- /dev/null +++ b/src/features/map/PacketPathMapModal.tsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from "react"; +import type { PacketDetail } from "../../types/api"; +import { ModalOverlay } from "../../components/ModalOverlay"; +import { CloseButton } from "../../components/CloseButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; +import { formatPropagation } from "../../lib/formatters"; +import { buildPacketPaths } from "./packet-path"; +import { PacketPathMap } from "./PacketPathMap"; +import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; + +// Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route; +// clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it +// returns the user exactly where they were. +function Row({ active, color, label, meta, onClick }: { + active: boolean; color?: string; label: string; meta?: string; onClick: () => void; +}) { + return ( + + ); +} + +export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { + detail: PacketDetail; + onClose: () => void; + initialSelectedKey?: string | null; +}) { + const paths = useMemo(() => buildPacketPaths(detail), [detail]); + const [selectedKey, setSelectedKey] = useState( + // deep-link value that matches a known path isolates it; anything else (incl. "all") shows All + () => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null), + ); + const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( + +
+
+ Packet Path +
+ ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })} + ariaLabel="Copy path link" + /> + +
+
+ +
+
+ +
+
+
+ setSelectedKey(null)} /> +
+ {paths.map((p) => ( + setSelectedKey(p.key)} + /> + ))} +
+
+
+
+ ); +} diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index d294318..6f9a522 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -1,6 +1,6 @@ // 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. +// malformed link degrades to the normal view rather than breaking. import { MAP_STYLES, type NeighborLinesMode } from "./types"; import { NODE_TYPE_NAMES } from "../../lib/node-types"; @@ -13,6 +13,7 @@ export interface ParsedMapView { neighborLines?: NeighborLinesMode; styleId?: string; flow?: boolean; + borders?: boolean; } // The live map state a copy-link snapshot is built from (every field concrete). @@ -24,6 +25,7 @@ export interface MapViewSnapshot { neighborLines: NeighborLinesMode; styleId: string; flow: boolean; + borders: boolean; } const NEIGHBOR_MODES: NeighborLinesMode[] = ["on", "selected", "off"]; @@ -78,6 +80,9 @@ export function parseMapView(params: URLSearchParams): ParsedMapView { const flow = parseBool(params.get("flow")); if (flow !== undefined) view.flow = flow; + const borders = parseBool(params.get("borders")); + if (borders !== undefined) view.borders = borders; + return view; } @@ -100,5 +105,6 @@ export function buildMapParams(view: MapViewSnapshot): Record (hop?.confidence === "high" ? hop : undefined); + return [confident(source), ...path, confident(destination)].filter((hop): hop is ResolvedHop => hop != null); +} // 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. diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts new file mode 100644 index 0000000..b0d5553 --- /dev/null +++ b/src/features/map/packet-path.ts @@ -0,0 +1,128 @@ +import type { Feature, FeatureCollection, LineString, Point } from "geojson"; +import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; +import { PayloadType } from "../../types/enums"; +import { packetChain } from "./packet-flow"; + +export interface PathPoint { + id: string; + name?: string; + lng: number; + lat: number; +} + +export interface PacketPath { + key: string; // observerId, or "trace" + label: string; // observer name, a truncated observer id, or "Trace route" + propagationMs?: number; // packet's propagation to this observer (ms); absent for the trace route + color: string; + points: PathPoint[]; +} + +// Distinct, saturated hues that read on both the dark and light basemaps. Local constants (like +// PACKET_FLOW_COLOR), not theme tokens — the selector swatch reuses each path's color. +export const PATH_COLORS: string[] = [ + "#ff6b35", // orange + "#00b4d8", // cyan + "#22c55e", // green + "#e879f9", // pink + "#eab308", // yellow + "#3b82f6", // blue + "#ef4444", // red + "#a78bfa", // violet +]; + +// The located nodes on a resolved path — first candidate per hop that has coords, deduped by id, in +// order. Modelled on resolvedPathNodes() in packet-flow.ts, but keeps each node's name for labels. +function pathPoints(hops: ResolvedHop[]): PathPoint[] { + const seen = new Set(); + const out: PathPoint[] = []; + for (const hop of hops) { + 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, name: node.name, lng: node.longitude!, lat: node.latitude! }); + } + } + return out; +} + +function observerLabel(obs: Observation): string { + return obs.observerName ?? obs.observerId.slice(0, 8); +} + +// One drawable path per observation (and the trace route for TRACE packets) that resolves to >=2 +// located hops, keyed by observerId and sorted fastest-first. Colors are assigned after sorting so +// the selector swatch matches the drawn line. +export function buildPacketPaths(detail: PacketDetail): PacketPath[] { + const raw: Omit[] = []; + const add = (key: string, label: string, propagationMs: number | undefined, points: PathPoint[]) => { + if (points.length < 2) return; + raw.push({ key, label, propagationMs, points }); + }; + + const isTrace = detail.header.payloadType === PayloadType.TRACE; + // TRACE observations now resolve to the same hops as detail.resolvedRoute, so their per-observation + // lines would just duplicate the single "Trace route" below — draw only that one for traces. + if (!isTrace) { + for (const obs of detail.observations) { + // full chain: source → relay hops → destination; missing/unlocated hops drop out in pathPoints. + const chain = packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination); + add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); + } + } + if (isTrace && detail.resolvedRoute) { + add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute)); + } + + // fastest first; missing propagation (incl. the trace route) sorts last + raw.sort((a, b) => ((a.propagationMs ?? Infinity) - (b.propagationMs ?? Infinity)) || 0); // || 0: two Infinity props → NaN; keep insertion order + return raw.map((p, i) => ({ ...p, color: PATH_COLORS[i % PATH_COLORS.length]! })); +} + +export interface PathLineProps { + key: string; + color: string; +} + +export interface PathNodeProps { + key: string; + color: string; + label: string; // short label for the map (truncated id when unnamed) + title: string; // untruncated name/id for the click popup + endpoint: "start" | "end" | "mid"; +} + +// Selection: null = every path ("All paths"); a key isolates that one path. Returns the line + node +// FeatureCollections to setData() and the coords to fitBounds over. +export function packetPathsToFeatures( + paths: PacketPath[], + selectedKey: string | null, +): { lines: FeatureCollection; points: FeatureCollection; bounds: [number, number][] } { + const shown = selectedKey ? paths.filter((p) => p.key === selectedKey) : paths; + const lines: Feature[] = []; + const points: Feature[] = []; + const bounds: [number, number][] = []; + + for (const path of shown) { + lines.push({ + type: "Feature", + properties: { key: path.key, color: path.color }, + geometry: { type: "LineString", coordinates: path.points.map((p) => [p.lng, p.lat]) }, + }); + path.points.forEach((pt, i) => { + const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid"; + points.push({ + type: "Feature", + properties: { key: path.key, color: path.color, label: pt.name ?? pt.id.slice(0, 6), title: pt.name ?? pt.id, endpoint }, + geometry: { type: "Point", coordinates: [pt.lng, pt.lat] }, + }); + bounds.push([pt.lng, pt.lat]); + }); + } + + return { + lines: { type: "FeatureCollection", features: lines }, + points: { type: "FeatureCollection", features: points }, + bounds, + }; +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 2cda43d..9298b06 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -98,6 +98,12 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; export type NeighborLinesMode = "on" | "selected" | "off"; +// --- IATA border layer --- +export const IATA_BORDERS_SOURCE_ID = "iata-borders"; +export const IATA_BORDERS_FILL_LAYER_ID = "iata-borders-fill"; // low-alpha fill beneath the markers +export const IATA_BORDERS_LINE_LAYER_ID = "iata-borders-line"; // outline stroke over the fill +export const MAP_BORDERS_STORAGE_KEY = "beacon-map-borders"; + // --- 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"; diff --git a/src/features/map/useMapBorders.ts b/src/features/map/useMapBorders.ts new file mode 100644 index 0000000..bd476d7 --- /dev/null +++ b/src/features/map/useMapBorders.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import type { Map as MapLibreMap, GeoJSONSource, FillLayerSpecification, LineLayerSpecification } from "maplibre-gl"; +import { + IATA_BORDERS_SOURCE_ID, + IATA_BORDERS_FILL_LAYER_ID, + IATA_BORDERS_LINE_LAYER_ID, + NODES_CLUSTER_LAYER_ID, +} from "./types"; +import type { BorderFeatureCollection } from "./useMapBordersData"; + +function paletteVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +// Draws IATA region borders as a low-alpha fill + outline beneath the node markers. Mirrors +// useMapNeighbors: the source/layers re-add themselves after a style switch, the paint tracks the +// palette on theme change, and border data flows through a separate setData effect so toggling the +// layer on/off never rebuilds it. +export function useMapBorders( + mapRef: React.RefObject, + isReady: boolean, + data: BorderFeatureCollection, + themeKey: string, +) { + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // build source + fill + line, and keep the colour in step with the palette + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const color = paletteVar("--palette-secondary", "#A78BFA"); + const beforeId = map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined; + + if (!map.getSource(IATA_BORDERS_SOURCE_ID)) { + map.addSource(IATA_BORDERS_SOURCE_ID, { type: "geojson", data: dataRef.current }); + } + // fill first so the outline sits on top of it; both go beneath the node markers + if (!map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_FILL_LAYER_ID, + type: "fill", + source: IATA_BORDERS_SOURCE_ID, + paint: { "fill-color": color, "fill-opacity": 0.08 }, + } as FillLayerSpecification, + beforeId, + ); + } + if (!map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_LINE_LAYER_ID, + type: "line", + source: IATA_BORDERS_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": color, "line-width": 1.5, "line-opacity": 0.8 }, + } as LineLayerSpecification, + beforeId, + ); + } + map.setPaintProperty(IATA_BORDERS_FILL_LAYER_ID, "fill-color", color); + map.setPaintProperty(IATA_BORDERS_LINE_LAYER_ID, "line-color", color); + (map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource).setData(dataRef.current); + }, [mapRef, isReady, themeKey]); + + // push new border data as the toggle / region changes + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const src = map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(data); + }, [mapRef, isReady, data]); + + // remove layers (before the source) on unmount; runs before useMapLibre tears the map down + useEffect(() => { + const map = mapRef.current; + return () => { + if (!map) return; + try { + if (map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) map.removeLayer(IATA_BORDERS_LINE_LAYER_ID); + if (map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) map.removeLayer(IATA_BORDERS_FILL_LAYER_ID); + if (map.getSource(IATA_BORDERS_SOURCE_ID)) map.removeSource(IATA_BORDERS_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts new file mode 100644 index 0000000..f89f326 --- /dev/null +++ b/src/features/map/useMapBordersData.ts @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import type { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson"; +import { getIataBorder, type IataBorder } from "../../api/client"; + +export type BorderProps = { iata: string; [key: string]: unknown }; +export type BorderFeatureCollection = FeatureCollection; + +// Merge each IATA's border into one collection, dropping the ones with no border and stamping the +// IATA code onto every feature so the layer can style/label per region. +export function mergeBorders(entries: { iata: string; border: IataBorder | null }[]): BorderFeatureCollection { + const features = entries.flatMap((e) => + e.border + ? [{ ...e.border, properties: { ...(e.border.properties ?? {}), iata: e.iata } } as Feature] + : [], + ); + return { type: "FeatureCollection", features }; +} + +// Fetch the border for each active IATA (only while `enabled`), then merge into one collection. +// Borders are static, so each is cached indefinitely and most IATAs simply have none (204 -> null). +export function useMapBordersData(iataCodes: string[], enabled: boolean): BorderFeatureCollection { + const results = useQueries({ + queries: iataCodes.map((iata) => ({ + queryKey: ["iata-border", iata], + queryFn: () => getIataBorder(iata), + enabled, + staleTime: Infinity, + })), + }); + + // useQueries returns a fresh array each render; a border is immutable once fetched, so a signature + // of which IATAs have resolved one is enough to keep the collection reference stable between renders. + const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}`).join("|"); + return useMemo( + () => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))), + // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded + [sig], + ); +} diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index b4e815f..ed3ed9a 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -2,7 +2,7 @@ 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 { packetChain, resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; import { PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_TRAIL_LAYER_ID, @@ -173,9 +173,11 @@ export function useMapPacketFlow( if (!enabled) return; const map = mapRef.current; const unsub = wsManager.onPacketObservation((data) => { - const resolved = data.observation?.resolvedPath; - if (!resolved) return; - const nodes = resolvedPathNodes(resolved); + const obs = data.observation; + // resolvedPath is opt-in and the toggle above lands a beat after connect, but the endpoints + // always ship — bail rather than animate a bare source→destination hop that never happened. + if (!obs?.resolvedPath) return; + const nodes = resolvedPathNodes(packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination)); 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({ diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index f3bf209..4debae6 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -5,7 +5,7 @@ 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 { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import type { NodeObservation, NodeNeighbor } from "./types"; @@ -161,6 +161,12 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o } /> } /> {node.lastAdvertAt != null && } />} + {node.clockDriftSeconds != null && ( + {formatClockDrift(node.clockDriftSeconds)}} + /> + )}
diff --git a/src/features/nodes/NodeFilterBar.tsx b/src/features/nodes/NodeFilterBar.tsx index d93b475..24d5dfd 100644 --- a/src/features/nodes/NodeFilterBar.tsx +++ b/src/features/nodes/NodeFilterBar.tsx @@ -12,6 +12,7 @@ const MULTIBYTE_OPTIONS = [ const SEARCH_FIELDS: SearchFieldOption[] = [ { value: "name", label: "Name" }, + { value: "pubkey", label: "Public Key" }, ]; // "" means no filter (Any) diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 35f0a77..60179f7 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -14,6 +14,7 @@ import { ObserverIcon } from "../../components/ObserverIcon"; import { DataTable, type Column } from "../../components/DataTable"; import { LoadingPill } from "../../components/LoadingPill"; import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; +import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; import type { NodeSummary } from "./types"; import type { CursorPage } from "../../types/api"; @@ -138,9 +139,19 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable useTick(); + // switching the field flips what the box means (a name vs a hex prefix), so stale text mustn't carry over + const handleSearchFieldChange = useCallback((field: string) => { + setSearchField(field); + setSearch(""); + }, []); + + // derive the actual server params (name vs pubkeyPrefix, hex-guarded) and key the query on THOSE, + // so toggling the field with an empty box is a no-op and a name never gets sent as a hex prefix + const { name: nameParam, pubkeyPrefix: pubkeyPrefixParam } = nodeSearchParams(searchField, search); + const queryKey = useMemo( - () => ["nodes", regionKey, typeFilter, pathsFilter, tracesFilter, search, searchField], - [regionKey, typeFilter, pathsFilter, tracesFilter, search, searchField], + () => ["nodes", regionKey, typeFilter, pathsFilter, tracesFilter, nameParam, pubkeyPrefixParam], + [regionKey, typeFilter, pathsFilter, tracesFilter, nameParam, pubkeyPrefixParam], ); // page the region's nodes 50 at a time (filters stay server-side, in the query key); rows stream @@ -151,7 +162,8 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable getNodesPage(iatas, { cursor, type: typeFilter || undefined, - name: searchField === "name" ? search || undefined : undefined, + name: nameParam, + pubkeyPrefix: pubkeyPrefixParam, supportsMultibytePaths: pathsFilter || undefined, supportsMultibyteTraces: tracesFilter || undefined, }), @@ -188,7 +200,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable search={search} onSearchChange={setSearch} searchField={searchField} - onSearchFieldChange={setSearchField} + onSearchFieldChange={handleSearchFieldChange} typeFilter={typeFilter} onTypeChange={setTypeFilter} pathsFilter={pathsFilter} diff --git a/src/features/nodes/node-search.ts b/src/features/nodes/node-search.ts new file mode 100644 index 0000000..aa2a3c5 --- /dev/null +++ b/src/features/nodes/node-search.ts @@ -0,0 +1,12 @@ +// Maps the Nodes-table search box (one shared input + a field selector) to the server params. +// The Public Key field is a hex prefix; non-hex input is dropped rather than sent, because the +// backend 400s on a non-hex pubkeyPrefix — so a stray character shows the unfiltered list instead +// of erroring the whole table. +export function nodeSearchParams(searchField: string, search: string): { name?: string; pubkeyPrefix?: string } { + const value = search.trim(); + if (searchField === "pubkey") { + const hex = value.toLowerCase(); + return { pubkeyPrefix: /^[0-9a-f]+$/.test(hex) ? hex : undefined }; + } + return { name: value || undefined }; +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index a748f44..02ca6e1 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -11,7 +11,7 @@ export interface NodeSummary { name: string | null; lat: number | null; lng: number | null; - radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown + radio?: string; // compact "freq,bw,sf" string, e.g. "915,250,11"; absent when unknown defaultScope?: string; // most recently matched transport scope name, e.g. "#bc" iatas: NodeIATA[]; knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node @@ -31,6 +31,12 @@ export interface Node extends NodeSummary { firstSeen: number; // epoch ms lastSeen: number; // epoch ms metadata: Record | null; + // Clock drift, repeaters/room servers only; absent for other types or before a qualifying advert. + // Device minus server time in seconds (+ve = device ahead). clockCheckedAt == lastAdvertAt. + // clockOutOfSync is the server's verdict against its threshold — don't recompute it client-side. + clockDriftSeconds?: number; + clockOutOfSync?: boolean; + clockCheckedAt?: number; // epoch ms } // First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination). diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index 60c67f1..ec65713 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -4,7 +4,7 @@ export interface ObserverSummary { observerType?: string; iata: string; status: "online" | "offline"; - radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown + radio?: string; // compact "freq,bw,sf" string, e.g. "915,250,11"; absent when unknown scopes?: string[]; // transport scopes this observer forwards, e.g. ["#bc", "#west"] // epoch ms; not in REST list responses — patched in from WS status events for recency derivation lastStatusAt?: number; diff --git a/src/features/packets/ObservationTable.tsx b/src/features/packets/ObservationTable.tsx new file mode 100644 index 0000000..f81b389 --- /dev/null +++ b/src/features/packets/ObservationTable.tsx @@ -0,0 +1,60 @@ +import type { Observation } from "../../types/api"; +import { formatSnr, formatPropagation, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { PathData } from "./PathData"; + +interface Props { + observations: Observation[]; + selectedId: number | null; + onSelect: (id: number) => void; +} + +// Per-observer readings vary by distance; presentational component owned by caller. +export function ObservationTable({ observations, selectedId, onSelect }: Props) { + return ( + + + + + + + + + + + + + + + {observations.map((o) => { + const level = snrLevel(o.snr); + return ( + onSelect(o.id)} + className={`cursor-pointer border-t border-border-subtle ${o.id === selectedId ? "bg-primary/8" : "hover:bg-bg-raised/40"}`} + > + + + + + + + + + + ); + })} + +
ObserverIATAHeardSNRRSSIPropHopsPath
{o.observerName ?? o.observerId.slice(0, 8)}{o.iata} + {formatSnr(o.snr)} + {o.rssi ?? "—"}{formatPropagation(o.propagationTimeMs)}{o.pathLength.hopCount} + {o.pathBytes ? ( + + ) : ( + — + )} +
+ ); +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index e6d6fec..18a7fb3 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; @@ -14,6 +14,7 @@ import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBre import { PayloadBreakdown } from "./payload-renderers"; import { ObservationCard } from "./ObservationCard"; import { PathData } from "./PathData"; +import { buildPacketPaths } from "../map/packet-path"; function decodePayloadHex(encoded: string): string | null { try { @@ -32,19 +33,22 @@ interface PacketAnalyzerDrawerProps { onClose: () => void; onSelectObservation?: (id: number) => void; onViewNode?: (nodeId: string) => void; + onViewPath?: () => void; loading?: boolean; } // side panel (full-screen on mobile) showing packet structure and payload breakdown -export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, loading }: PacketAnalyzerDrawerProps) { +export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, loading }: PacketAnalyzerDrawerProps) { const [, setSearchParams] = useSearchParams(); - // drop ?hash so the closed analyzer can't reopen on reload and the packet row deselects + const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); + + // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { setSearchParams((p) => { const n = new URLSearchParams(p); - n.delete("hash"); + n.delete("analyze"); return n; }, { replace: true }); onClose(); @@ -64,11 +68,11 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o const headerHex = rawHex.slice(0, 2); return ( -
+
Packet Analyzer
- {detail && } + {detail && }
@@ -115,6 +119,22 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
+
+ +
+ {selectedObs && ( @@ -181,22 +201,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o )} - {/* Path data — for TRACE the path bytes are per-hop SNR samples, so show them raw */} + {/* Path data — TRACE's pathBytes are now its trace path hashes (matching hashSize/hopCount + and resolvedPath), so it resolves through PathData like every other type. */} {selectedObs?.pathBytes && ( - {detail.header.payloadType === PayloadType.TRACE ? ( - <> -
Path SNR Data
-
- {selectedObs.pathBytes.toUpperCase()} -
- - ) : ( - <> -
Path Data
- - - )} +
Path Data
+
)} @@ -212,7 +222,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
- +
)} diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx index ddbd3b8..ec10bf8 100644 --- a/src/features/packets/PacketAnalyzerOverlay.tsx +++ b/src/features/packets/PacketAnalyzerOverlay.tsx @@ -6,11 +6,13 @@ import { ModalOverlay } from "../../components/ModalOverlay"; // Packet analyzer floated over a node detail panel (mirror of NodeDetailOverlay). The node detail it // can stack on top gets no onAnalyzePacket, so the overlay chain stops there instead of recursing. -export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver }: { +export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver, onViewPath, inactive = false }: { detail: PacketDetail | undefined; loading?: boolean; onClose: () => void; onViewObserver: (observerId: string) => void; + onViewPath?: () => void; + inactive?: boolean; }) { const [selectedObservationId, setSelectedObservationId] = useState(null); const [viewNodeId, setViewNodeId] = useState(null); @@ -19,15 +21,15 @@ export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver function onKey(e: KeyboardEvent) { // peel back one layer at a time: the nested node overlay handles its own Escape, so only // close the analyzer once nothing is stacked above it - if (e.key === "Escape" && !viewNodeId) onClose(); + if (e.key === "Escape" && !viewNodeId && !inactive) onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose, viewNodeId]); + }, [onClose, viewNodeId, inactive]); return ( <> - + {viewNodeId && ( diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx new file mode 100644 index 0000000..33e5822 --- /dev/null +++ b/src/features/packets/PacketEndpoints.tsx @@ -0,0 +1,47 @@ +import type { PacketSummary } from "../../types/api"; +import type { PathConfidence } from "../../types/enums"; +import { buildPathSummary, type PathChip } from "./path-summary"; + +// Same three-state vocabulary PathData uses in the analyzer. +const CONFIDENCE_CLASSES: Record = { + high: "bg-green/8 text-green", + ambiguous: "bg-warn/8 text-warn", + none: "bg-text-muted/8 text-text-dim", +}; + +function Chip({ chip }: { chip: PathChip }) { + if (chip.kind === "hex") { + return {chip.label}; + } + if (chip.kind === "unresolved-run") { + return ( + + {chip.count === 1 ? "?" : `?×${chip.count}`} + + ); + } + return ( + + {chip.label} + + ); +} + +const Na = () => n/a; + +// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them +// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed +// endpoint at all (GRP_TXT/GRP_DATA/TRACE). +export function PacketEndpoints({ packet }: { packet: PacketSummary }) { + const { source, destination } = buildPathSummary(packet); + // One n/a for the pair reads better than "n/a → n/a" on every historical row. + if (!source && !destination) return ; + + return ( + + {source ? : } + → + {destination ? : } + + ); +} diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx new file mode 100644 index 0000000..043d10d --- /dev/null +++ b/src/features/packets/PacketExpansion.tsx @@ -0,0 +1,97 @@ +import { useCallback, useMemo } from "react"; +import type { PacketSummary } from "../../types/api"; +import { formatPropagation } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { usePacketDetail } from "./usePacketDetail"; +import { ObservationTable } from "./ObservationTable"; +import { buildPacketPaths } from "../map/packet-path"; + +// Roughly what fits the scroll cap; observations are unbounded server-side. +const SKELETON_ROW_CAP = 12; + +const ACTION_BUTTON_CLASS = + "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; + +interface Props { + packet: PacketSummary; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Expanded region under a packet row: a summary-driven timing strip (instant, no fetch wait) plus +// the per-observer table, which does wait on usePacketDetail. +export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { + const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + const observer = packet.latestObserver; + // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is + // already in ms for formatPropagation -- no *1000 here. + const spread = packet.lastHeardAt - packet.firstHeardAt; + const ready = !isLoading && !isError; + const hasPath = useMemo(() => (data ? buildPacketPaths(data).length > 0 : false), [data]); + // The summary already knows the count is zero, so skip the fetch-driven states entirely rather + // than showing a blank (0-row) skeleton while it loads. + const noObservations = packet.observationCount === 0; + const emptyState =
No observations
; + // Picking an observation is the way into the analyzer — it opens on the one you clicked. + const handleSelectObservation = useCallback( + (id: number) => { + onSelectObservation(id); + onOpenAnalyzer(); + }, + [onSelectObservation, onOpenAnalyzer], + ); + + return ( +
+
+ + observer{" "} + {observer + ? {observer.displayName ?? observer.id.slice(0, 8)} + : n/a} + + first + last + spread {formatPropagation(spread)} + +
+ +
+ {isError ? ( +
+ Failed to load observations + +
+ ) : noObservations ? ( + emptyState + ) : isLoading ? ( +
+ {Array.from({ length: Math.min(packet.observationCount, SKELETON_ROW_CAP) }).map((_, i) => ( +
+ ))} +
+ ) : data && data.observations.length === 0 ? ( + emptyState + ) : data ? ( + + ) : null} +
+
+ ); +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 7413a4a..0340b6b 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; +import { usePacketDetail } from "./usePacketDetail"; import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; import { useScopes } from "../../hooks/useScopes"; import { useRegion } from "../../hooks/useRegion"; @@ -11,6 +13,8 @@ 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"; +import type { PacketDetail } from "../../types/api"; +import type { WsPacketObservation } from "../../types/ws"; // filter options and storage keys @@ -27,12 +31,16 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; onAnalyze: (hash: string | null) => void; + onViewPath: (detail: PacketDetail) => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // main packet view: filters, banner, virtual list -export function PacketList({ wsManager, onAnalyze }: PacketListProps) { +export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); 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]); @@ -68,20 +76,39 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { [allPackets, filters, observersByHash], ); - // ?hash is the source of truth — the analyzer drawer clears it on close, deselecting the row + // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). const expandedHash = searchParams.get("hash"); const handleToggleExpand = useCallback((hash: string) => { const next = expandedHash === hash ? null : hash; - onAnalyze(next); setSearchParams((p) => { const n = new URLSearchParams(p); if (next) n.set("hash", next); else n.delete("hash"); return n; }, { replace: true }); - }, [expandedHash, setSearchParams, onAnalyze]); + }, [expandedHash, setSearchParams]); - useWsPacketHandler(wsManager, handlePacketObservation); + // Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request. + const { data: expandedDetail } = usePacketDetail(expandedHash); + + const handleOpenAnalyzer = useCallback(() => { + if (expandedHash) onAnalyze(expandedHash); + }, [expandedHash, onAnalyze]); + + const handleViewPath = useCallback(() => { + if (expandedDetail) onViewPath(expandedDetail); + }, [expandedDetail, onViewPath]); + + // Refetch only the open row's detail, so its observation table keeps pace with the count ticking + // up beside it. Every other observation just lands in the list. + const handleObservation = useCallback((data: WsPacketObservation["data"]) => { + handlePacketObservation(data); + if (data.packetHash === expandedHash) { + queryClient.invalidateQueries({ queryKey: ["packet-detail", expandedHash] }); + } + }, [handlePacketObservation, expandedHash, queryClient]); + + useWsPacketHandler(wsManager, handleObservation); useWsLaggedHandler(wsManager, handleLagged); const bannerCount = isScrolledAway ? newPacketCount : 0; @@ -177,6 +204,10 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { onAtTopChange={setIsAtTop} expandedHash={expandedHash} onToggleExpand={handleToggleExpand} + onOpenAnalyzer={handleOpenAnalyzer} + onViewPath={handleViewPath} + selectedObservationId={selectedObservationId} + onSelectObservation={onSelectObservation} /> )} + + Hash + Type + Route + Obs + Hops + Hash Size + Src → Dst + IATA + Age +
+ ); +} diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx new file mode 100644 index 0000000..95a430f --- /dev/null +++ b/src/features/packets/PacketTableRow.tsx @@ -0,0 +1,72 @@ +import { formatHex } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { Badge } from "../../components/Badge"; +import { ScopeTag } from "../../components/ScopeTag"; +import { payloadTypeVariant } from "../../components/badge-utils"; +import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums"; +import type { PacketSummary } from "../../types/api"; +import { GRID_TEMPLATE } from "./packet-grid"; +import { PacketEndpoints } from "./PacketEndpoints"; + +interface PacketTableRowProps { + packet: PacketSummary; + expanded: boolean; + isFresh?: boolean; + onToggle: () => void; +} + +// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the +// expansion instead, which frees the wide column for the packet's endpoints. +export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { + // ?? not ||, so a legitimate 0-hop direct packet still shows its count + const pathLength = packet.latestObserver?.pathLength; + const na = n/a; + + return ( +
+ +
+ ); +} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 80fdb78..4cc15ad 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -1,8 +1,12 @@ import { useRef, useCallback, useLayoutEffect } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { PacketSummary } from "../../types/api"; +import { PacketTableHeader } from "./PacketTableHeader"; +import { PacketTableRow } from "./PacketTableRow"; import { PacketRow } from "./PacketRow"; +import { PacketExpansion } from "./PacketExpansion"; import { useFreshHashes } from "./useFreshHashes"; +import { useIsMobile } from "../../hooks/useMediaQuery"; import { SCROLL_TOP_THRESHOLD_PX, SCROLL_BOTTOM_THRESHOLD_PX, @@ -18,6 +22,11 @@ interface PacketVirtualListProps { onAtTopChange: (isAtTop: boolean) => void; expandedHash: string | null; onToggleExpand: (hash: string) => void; + // only the expanded row renders an expansion, so these need no hash argument + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // virtualized scroll list with fresh-item highlighting and infinite load @@ -31,16 +40,22 @@ export function PacketVirtualList({ onAtTopChange, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, + selectedObservationId, + onSelectObservation, }: PacketVirtualListProps) { const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); + const isMobile = useIsMobile(); const atTopRef = useRef(true); const prevFirstKeyRef = useRef(packets[0]?.packetHash); const virtualizer = useVirtualizer({ count: packets.length, getScrollElement: () => parentRef.current, - estimateSize: () => 64, // rough -- rows vary a lot when expanded, tanstack remeasures + // a collapsed row: one grid line on desktop, a taller card below md. Expanded rows are remeasured. + estimateSize: () => (isMobile ? 64 : 37), overscan: 10, getItemKey: (index) => packets[index]?.packetHash ?? index, }); @@ -84,16 +99,19 @@ export function PacketVirtualList({ className="flex-1 overflow-y-auto px-4 pb-10" onScroll={handleScroll} > +
{virtualizer.getVirtualItems().map((virtualRow) => { const packet = packets[virtualRow.index]; if (!packet) return null; + const expanded = expandedHash === packet.packetHash; return (
-
- onToggleExpand(packet.packetHash)} - /> + {/* cards need breathing room; table rows butt up so the whole strip is a click target */} +
+ {isMobile ? ( + onToggleExpand(packet.packetHash)} + /> + ) : ( + onToggleExpand(packet.packetHash)} + /> + )} + {expanded && ( + + )}
); diff --git a/src/features/packets/packet-grid.ts b/src/features/packets/packet-grid.ts new file mode 100644 index 0000000..c077378 --- /dev/null +++ b/src/features/packets/packet-grid.ts @@ -0,0 +1,7 @@ +// One track list shared by the sticky header and every row, so columns stay aligned. Keep every +// track font- and content-independent: the header and the rows are separate grids, so a `ch` track +// resolves against each one's own font size (9px vs 11px) and an `auto` track against its own text +// ("HASH" vs "4AE77F09") — either silently drifts the two apart. +// Endpoints take minmax(0,1fr) so they absorb every bit of squeeze and truncate, rather than +// pushing IATA and Age off the edge on a narrow viewport. +export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem 4rem minmax(0,1fr) 3.5rem 5rem"; diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts new file mode 100644 index 0000000..8df354d --- /dev/null +++ b/src/features/packets/path-summary.ts @@ -0,0 +1,95 @@ +import type { PacketSummary, ResolvedHop } from "../../types/api"; +import type { PathConfidence } from "../../types/enums"; +import { PayloadType } from "../../types/enums"; + +// How many hops line 2 is allowed to spend on chips before it truncates. An unresolved run costs +// its own length, so "?×4" uses four of the five. +export const MAX_PATH_HOPS_SHOWN = 5; + +export type PathChip = + | { kind: "node"; label: string; confidence: PathConfidence } + | { kind: "hex"; label: string } + | { kind: "unresolved-run"; count: number }; + +export interface PathSummary { + hopLabel: string; + chips: PathChip[]; + overflow: number; // hops not represented by a visible chip + source: PathChip | null; + destination: PathChip | null; + isNa: boolean; +} + +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; + +function hopChip(hop: ResolvedHop): PathChip | null { + if (hop.confidence === "none") return null; + const node = hop.nodes[0]; + const label = node?.name ?? node?.publicKey.slice(0, 8) ?? "?"; + return { kind: "node", label, confidence: hop.confidence }; +} + +// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. +function endpointChip(hop: ResolvedHop | undefined): PathChip | null { + if (!hop) return null; + return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; +} + +function chipsFromResolved(path: ResolvedHop[]): PathChip[] { + const out: PathChip[] = []; + for (const hop of path) { + const chip = hopChip(hop); + if (chip) { out.push(chip); continue; } + const last = out[out.length - 1]; + if (last?.kind === "unresolved-run") last.count += 1; + else out.push({ kind: "unresolved-run", count: 1 }); + } + return out; +} + +function chipsFromHex(pathBytes: string, hashSize: number): PathChip[] { + const width = hashSize * 2; + const out: PathChip[] = []; + for (let i = 0; i < pathBytes.length; i += width) { + out.push({ kind: "hex", label: pathBytes.slice(i, i + width) }); + } + return out; +} + +export function buildPathSummary(packet: PacketSummary): PathSummary { + const observer = packet.latestObserver; + const length = observer?.pathLength; + // No hashSize means no way to split pathBytes — never guess a chunk width. + if (!observer || !length) return NA; + + const { hopCount, hashSize } = length; + const source = endpointChip(observer.resolvedSource); + const destination = endpointChip(observer.resolvedDestination); + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + + if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; + + // TRACE repurposes the path field to carry per-hop SNR samples, so its bytes are not hashes. + // Only the detail endpoint swaps in real trace hashes; the list and WS never do. + if (packet.payloadType === PayloadType.TRACE) return base; + + const bytes = observer.pathBytes; + const resolved = observer.resolvedPath; + const all = resolved?.length + ? chipsFromResolved(resolved) + : bytes && bytes.length === hopCount * hashSize * 2 + ? chipsFromHex(bytes, hashSize) + : []; + if (all.length === 0) return base; + + const chips: PathChip[] = []; + let spent = 0; + for (const chip of all) { + const cost = chip.kind === "unresolved-run" ? chip.count : 1; + if (chips.length > 0 && spent + cost > MAX_PATH_HOPS_SHOWN) break; + chips.push(chip); + spent += cost; + } + + return { ...base, chips, overflow: Math.max(0, hopCount - spent) }; +} diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index e36e3cc..00dade5 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -13,6 +13,14 @@ interface PayloadProps { payload: Record; } +// the packet's resolved endpoints + node-open callback, threaded from the analyzer through the +// envelope/anon renderers so From/To/Dest hashes resolve to node blocks like path hops do. +interface EndpointProps { + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; + onViewNode?: (nodeId: string) => void; +} + function SectionLabel({ children }: { children: React.ReactNode }) { return
{children}
; } @@ -80,11 +88,11 @@ function EncryptedIndicator() { ); } -function EncryptedEnvelope({ payload, headerSlot, children }: { +function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; headerSlot?: ReactNode; children: (decrypted: Record) => ReactNode; -}) { +} & EndpointProps) { const destinationHash = payload.destinationHash as string | undefined; const sourceHash = payload.sourceHash as string | undefined; const cipherMac = payload.cipherMac as string | undefined; @@ -96,13 +104,19 @@ function EncryptedEnvelope({ payload, headerSlot, children }: {
{destinationHash && ( - To + To + {resolvedDestination + ? + : } ({destinationHash.length / 2}B) )} {sourceHash && ( - From + From + {resolvedSource + ? + : } ({sourceHash.length / 2}B) )} @@ -300,8 +314,9 @@ function GroupTextPayload({ payload }: PayloadProps) { )} {decrypted.content != null && (
- Message - {String(decrypted.content)} + {/* label sits above the body so every line of a multi-line message shares a left edge */} +
Message
+
{String(decrypted.content)}
)} {decrypted.sentAt != null && ( @@ -316,13 +331,13 @@ function GroupTextPayload({ payload }: PayloadProps) { ); } -function TextPayload({ payload }: PayloadProps) { +function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.message != null && ( -
{String(d.message)}
+
{String(d.message)}
)}
{d.timestamp != null && } @@ -335,9 +350,9 @@ function TextPayload({ payload }: PayloadProps) { ); } -function RequestPayload({ payload }: PayloadProps) { +function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.requestTypeName != null && ( @@ -357,16 +372,16 @@ function RequestPayload({ payload }: PayloadProps) { ); } -function ResponsePayload({ payload }: PayloadProps) { +function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.tag != null && ( {String(d.tag)} )} {d.content != null && ( -
{String(d.content)}
+
{String(d.content)}
)}
)} @@ -387,9 +402,9 @@ function AckPayload({ payload }: PayloadProps) { ); } -function PathPayload({ payload }: PayloadProps) { +function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => } ); @@ -652,15 +667,18 @@ function GenericPayload({ payload }: PayloadProps) { ); } -function AnonReqPayload({ payload }: PayloadProps) { +function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadProps & EndpointProps) { const destination = payload.destination as number | undefined; const ephemeralPubKey = payload.ephemeralPubKey as string | undefined; + const destLabel = destination != null ? `0x${destination.toString(16).toUpperCase().padStart(2, "0")}` : ""; return (
{destination != null && ( - 0x{destination.toString(16).toUpperCase().padStart(2, "0")} + {resolvedDestination + ? + : {destLabel}} )} {ephemeralPubKey && ( @@ -672,21 +690,21 @@ function AnonReqPayload({ payload }: PayloadProps) { // routes payload.type to the right renderer -export function PayloadBreakdown({ payload, resolvedRoute, onViewNode }: { +export function PayloadBreakdown({ payload, resolvedRoute, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; resolvedRoute?: ResolvedHop[]; // trace packets only — packet-level, not part of parsedPayload - onViewNode?: (nodeId: string) => void; -}) { +} & EndpointProps) { + const endpoints: EndpointProps = { resolvedSource, resolvedDestination, onViewNode }; switch (payload.type) { case "ADVERT": return ; case "TRACE": return ; case "GROUP_TEXT": return ; - case "TEXT_MESSAGE": return ; - case "REQUEST": return ; - case "RESPONSE": return ; - case "ANON_REQUEST": return ; + case "TEXT_MESSAGE": return ; + case "REQUEST": return ; + case "RESPONSE": return ; + case "ANON_REQUEST": return ; case "ACK": return ; - case "PATH": return ; + case "PATH": return ; case "CONTROL": return ; case "DISCOVER_REQ": return ; case "DISCOVER_RESP": return ; diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts index e563c10..351f9ac 100644 --- a/src/features/packets/types.ts +++ b/src/features/packets/types.ts @@ -11,11 +11,11 @@ export interface PacketFilterState { searchField: SearchField; } -// Filters /packets history can apply server-side (each accepts a single value per request) +// Filters /packets history can apply server-side; each accepts multiple comma-separated values. export interface PacketServerFilter { - payloadType?: number; - routeType?: number; - scope?: string; + payloadTypes?: number[]; + routeTypes?: number[]; + scopes?: string[]; } export const EMPTY_FILTERS: PacketFilterState = { diff --git a/src/features/packets/usePacketDetail.ts b/src/features/packets/usePacketDetail.ts new file mode 100644 index 0000000..fa3316d --- /dev/null +++ b/src/features/packets/usePacketDetail.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPacketDetail } from "../../api/client"; +import type { PacketDetail } from "../../types/api"; + +// One query per hash shared by the expanded row, the analyzer drawer and the overlay — TanStack +// dedupes, so a row expanded under an open drawer costs a single request. The short staleTime is +// deliberate: observations keep accruing, so reopening should show them rather than the snapshot +// frozen at first open. +export function usePacketDetail(hash: string | null) { + return useQuery({ + queryKey: ["packet-detail", hash], + queryFn: () => getPacketDetail(hash!), + enabled: !!hash, + staleTime: 30_000, + }); +} diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index 5645a6c..e1bb284 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -115,13 +115,14 @@ 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. +// The /packets endpoint accepts comma-separated payloadTypes/routeTypes/scopes, so any selected +// dimension goes server-side and pagination pulls the correctly-filtered set from the full history. +// (observers has no server param, so it stays client-side in matchesFilters, as does the live buffer.) 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]!; + if (filters.payloadTypes.length > 0) serverFilter.payloadTypes = filters.payloadTypes; + if (filters.routeTypes.length > 0) serverFilter.routeTypes = filters.routeTypes; + if (filters.scopes.length > 0) serverFilter.scopes = filters.scopes; return Object.keys(serverFilter).length > 0 ? serverFilter : null; } diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 35e704e..00a54d9 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -70,8 +70,15 @@ class LivePacketStore { const existing = this.hashIndex.get(summary.packetHash); if (existing !== undefined) { + // A WS message only knows its own heardAt, so widen the window rather than replacing it — + // otherwise the expanded row's spread reads 0 for every re-heard packet. + const prev = this.buffer[existing]!; this.buffer = [...this.buffer]; - this.buffer[existing] = summary; + this.buffer[existing] = { + ...summary, + firstHeardAt: Math.min(prev.firstHeardAt, summary.firstHeardAt), + lastHeardAt: Math.max(prev.lastHeardAt, summary.lastHeardAt), + }; } else { this.buffer = [summary, ...this.buffer]; this.rebuildIndex(); @@ -159,6 +166,11 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi id: data.observation.observerId, displayName: data.observation.observerName, iata: data.observation.iata, + pathLength: data.observation.pathLength, + pathBytes: data.observation.pathBytes, + // WS nulls these when the payload type carries no endpoint; the REST shape uses undefined + resolvedSource: data.observation.resolvedSource ?? undefined, + resolvedDestination: data.observation.resolvedDestination ?? undefined, }, }; diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx new file mode 100644 index 0000000..7cc1be8 --- /dev/null +++ b/src/features/stats/ClockDriftTab.tsx @@ -0,0 +1,71 @@ +import { useClockDrift } from "./useStats"; +import { DataTable, type Column } from "../../components/DataTable"; +import { Badge } from "../../components/Badge"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import { formatClockDrift } from "../../lib/formatters"; +import type { ClockDriftEntry } from "./types"; + +// every row is already past the drift threshold; flag the worst (>= 1h off) more urgently +function driftClass(seconds: number) { + return Math.abs(seconds) >= 3600 ? "text-danger" : "text-warn"; +} + +const columns: Column[] = [ + { + header: "Node", + cell: (e) => ( +
+ + {e.nodeName ?? e.nodeId.slice(0, 8)} + + {e.nodeTypeName} +
+ ), + sortValue: (e) => e.nodeName ?? e.nodeId, + }, + { + header: "Drift", + className: "tabular-nums", + cell: (e) => {formatClockDrift(e.clockDriftSeconds)}, + sortValue: (e) => Math.abs(e.clockDriftSeconds), + }, + { + header: "Checked", + cell: (e) => , + sortValue: (e) => e.clockCheckedAt, + }, + { + header: "IATAs", + cell: (e) => ( +
+ {(e.iatas ?? []).map((i) => ( + {i.iata} + ))} +
+ ), + }, +]; + +// Repeaters/room servers whose advert-derived clock has drifted past the server threshold, worst +// first. Not time-windowed (each row is the node's latest reading), so there's no range selector. +export function ClockDriftTab() { + const clockDrift = useClockDrift(); + return ( +
+
+ Repeaters & room servers out of sync · worst first +
+ e.nodeId} + selectedKey={null} + onSelect={() => {}} + isLoading={clockDrift.isLoading} + emptyLabel={clockDrift.isError ? "Failed to load" : "No repeaters out of sync"} + defaultSort={{ header: "Drift", direction: "desc" }} + /> +
+ ); +} diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 47a5585..f43a020 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -34,6 +34,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { useLiveOverview(wsManager); const overview = useStatsOverview(); const observations = useStatsObservations(range); + // top-row KPIs are a fixed 24h snapshot, so their sparklines use a dedicated + // 24h series rather than the range-driven one (deduped by query key when range is 24h) + const overviewObs = useStatsObservations("24h"); const payload = usePayloadBreakdown(range); const topNodes = useTopNodes(10); const topObservers = useTopObservers(range, 8); @@ -102,8 +105,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [scopes.data], ); - const obsSpark = useMemo(() => obs.slice(-24).map((p) => p.observationCount), [obs]); - const observerSpark = useMemo(() => obs.slice(-24).map((p) => p.activeObservers), [obs]); + const kpiObs = useMemo(() => aggregateByHour(overviewObs.data ?? []), [overviewObs.data]); + const obsSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.observationCount), [kpiObs]); + const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); const ov = overview.data; const kpiLoading = overview.isLoading; diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 9397ada..3159c2e 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -3,11 +3,13 @@ import { useSearchParams } from "react-router-dom"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; +import { TalkersTab } from "./TalkersTab"; +import { ClockDriftTab } from "./ClockDriftTab"; import { ObserverTab } from "./ObserverTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -52,6 +54,8 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "mesh" && } + {tab === "talkers" && } + {tab === "clockdrift" && } {tab === "observer" && ( )} diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 7853f9a..b4bc6ca 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -23,6 +23,24 @@ function ObserverIcon() { ); } +function TalkersIcon() { + return ( + + + + + ); +} + +function ClockDriftIcon() { + return ( + + + + + ); +} + function GraphIcon() { return ( @@ -38,6 +56,8 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, + { value: "talkers", label: "Talkers", icon: }, + { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; @@ -83,8 +103,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props />
)} - {/* the graph is topology, not time-series — no range to pick */} - {tab !== "graph" && ( + {/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */} + {tab !== "graph" && tab !== "clockdrift" && ( []>(() => { + const windowMs = RANGE_MS[range]; + // count over the compacted total, then the per-day rate for the same window in muted text + const split = (count: number) => ( + + {formatCount(count)} {formatRatePerDay(count, windowMs)} + + ); + return [ + { + header: "Node", + cell: (a) => ( +
+ + {a.nodeName ?? a.nodeId.slice(0, 8)} + + {a.nodeTypeName} + {a.iata} +
+ ), + sortValue: (a) => a.nodeName ?? a.nodeId, + }, + { header: "Flood", className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount }, + { header: "Direct", className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount }, + ]; + }, [range]); + + const talkerRows = useMemo( + () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), + [topTalkers.data, colors], + ); + const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); + + return ( +
+ Top advertisers · {range}} right={flood · direct}> +
+ a.nodeId} + selectedKey={null} + onSelect={() => {}} + isLoading={topAdvertisers.isLoading} + emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} + /> +
+
+ Top talkers · {range}} + right={by name} + height={leaderboardHeight(talkerRows.length)} + option={talkersOption} + isLoading={topTalkers.isLoading} + isError={topTalkers.isError} + isEmpty={talkerRows.length === 0} + /> +
+ ); +} diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 8cd36b6..9b0e840 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -84,14 +84,15 @@ export function observationsAreaOption( } export function leaderboardOption( - rows: { name: string; value: number; color: string }[], + rows: { name: string; value: number; color: string; iata?: string }[], c: ChartColors, gridLeft = 116, // widen for longer category labels (e.g. radio presets) ): EChartsOption { + const hasIata = rows.some((r) => Boolean(r.iata)); // reserve room for the end-of-bar chip only when needed return { animation: false, backgroundColor: "transparent", - grid: { left: gridLeft, right: 56, top: 6, bottom: 6 }, + grid: { left: gridLeft, right: hasIata ? 96 : 56, top: 6, bottom: 6 }, tooltip: { trigger: "item", ...tooltipStyle(c) }, xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } }, yAxis: { @@ -117,14 +118,30 @@ export function leaderboardOption( type: "bar", barMaxWidth: 22, barCategoryGap: "42%", - data: rows.map((r) => ({ value: r.value, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })), + data: rows.map((r) => ({ value: r.value, iata: r.iata, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })), label: { show: true, position: "right", color: c.textBright, fontFamily: MONO, fontSize: 11, - formatter: (p: { value: number }) => p.value.toLocaleString(), + // count, plus an IataChip-style location marker when the row carries one + formatter: (p: { value: number; data?: { iata?: string } }) => { + const v = p.value.toLocaleString(); + return p.data?.iata ? `{v|${v}} {iata|${p.data.iata}}` : v; + }, + rich: { + v: { color: c.textBright, fontFamily: MONO, fontSize: 11 }, + iata: { + color: c.primary, + backgroundColor: withAlpha(c.primary, 0.1), + fontFamily: MONO, + fontWeight: "bold", + fontSize: 10, + padding: [2, 4], + borderRadius: 3, + }, + }, }, }, ], diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 3b9f641..a6d3256 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -28,9 +28,10 @@ export function formatPreset(preset: string): string { return `${freq} · ${bw}k · SF${sf}`; } -// True if any point carries at least one meaningful (non-null, non-zero) metric. Bots / MQTT bridges -// report telemetry rows that are all zeros (no real radio hardware); those count as "no telemetry" -// so we show an empty state rather than a wall of flat-zero charts. +// True if any point carries at least one meaningful (non-null, non-zero) metric. Stats-less observers +// (bots / MQTT bridges, no real radio hardware) used to report all-zero rows; the backend now drops +// those at ingest, but the non-zero guard stays as a cheap backstop so a stray all-zero row still +// counts as "no telemetry" (empty state) rather than a wall of flat-zero charts. export function hasTelemetry(points: TelemetryPoint[]): boolean { const live = (v: number | null) => v != null && v !== 0; return points.some( diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index a517233..f3c72f7 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -1,5 +1,7 @@ // Response shapes for the /stats/* endpoints and observer telemetry. Verified against beacon-server. +import type { NodeIATA } from "../nodes/types"; + export interface StatsOverview { totalPackets: number; totalObservations: number; @@ -40,6 +42,39 @@ export interface TopObserver { observationCount: number; } +export interface TopAdvertiser { + nodeId: string; + nodeName: string | null; + nodeType: number; + nodeTypeName: string; + iata: string; + advertCount: number; + // advertCount split by route: flood = route type 0/1 (broadcast, no path), direct = 2/3 (routed). + // floodAdvertCount + directAdvertCount === advertCount. + floodAdvertCount: number; + directAdvertCount: number; + lastHeard: number; // epoch ms +} + +// grouped by decrypted sender display-name, not node identity: same-named pubkeys merge, a rename splits +export interface TopTalker { + senderName: string; + messageCount: number; + lastSent: number; // epoch ms +} + +// A repeater/room server whose latest advert-derived clock drift exceeds the server threshold. +// Only out-of-sync nodes appear; the list is ordered worst-drift-first (by magnitude). +export interface ClockDriftEntry { + nodeId: string; + nodeName: string | null; + nodeType: number; + nodeTypeName: string; + clockDriftSeconds: number; // signed; +ve = device ahead of server + clockCheckedAt: number; // epoch ms + iatas?: NodeIATA[]; +} + export interface RadioPreset { preset: string; // "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7" iata: string; @@ -78,7 +113,7 @@ export interface ObserverTelemetry { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "observer" | "graph"; +export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 836f3db..1c5f72c 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -6,9 +6,12 @@ import { getPayloadBreakdown, getTopNodes, getTopObservers, + getTopAdvertisers, + getTopTalkers, getRadioPresets, getStatsScopes, getStatsNodeTypes, + getClockDrift, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; @@ -71,6 +74,24 @@ export function useTopObservers(range: StatsRange, limit = 10) { }); } +export function useTopAdvertisers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-advertisers", regionKey, range, limit], + queryFn: () => getTopAdvertisers(iatas, sinceFor(range), limit), + ...common, + }); +} + +export function useTopTalkers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-talkers", regionKey, range, limit], + queryFn: () => getTopTalkers(iatas, sinceFor(range), limit), + ...common, + }); +} + export function useRadioPresets() { const { iatas, regionKey } = useRegion(); return useQuery({ @@ -90,6 +111,16 @@ export function useNodeTypes() { }); } +// clock drift reflects each node's latest measured drift, not a windowed aggregate, so region-only +export function useClockDrift(limit = 100) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-clock-drift", regionKey, limit], + queryFn: () => getClockDrift(iatas, limit), + ...common, + }); +} + // scopes are reported globally by the backend (no region filter), so the key is region-independent export function useScopes() { return useQuery({ diff --git a/src/index.css b/src/index.css index f822762..cf8b878 100644 --- a/src/index.css +++ b/src/index.css @@ -107,7 +107,7 @@ } /* MapLibre controls ship light chrome; re-skin to the app's dark tokens when the active basemap - is dark (data-dark set on the map container in MapView). Glyphs are baked-in dark SVGs, so they + is dark (data-dark set on each map container — MapView and the packet-path mini-map). Glyphs are baked-in dark SVGs, so they are inverted rather than recolored. Light basemaps (Liberty/Light) keep the default chrome. */ [data-dark="true"] .maplibregl-ctrl-group { background: var(--color-bg-raised); @@ -134,3 +134,20 @@ [data-dark="true"] .maplibregl-ctrl-attrib a { color: var(--color-text-muted); } +/* Path-node popup: dark chrome to match the controls, plus name/coords typography. */ +[data-dark="true"] .maplibregl-popup-content { + background: var(--color-bg-raised); + color: var(--color-text-normal); + border: 1px solid var(--color-border); + box-shadow: none; +} +[data-dark="true"] .maplibregl-popup-anchor-top .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-top-left .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-top-right .maplibregl-popup-tip { border-bottom-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { border-top-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--color-bg-raised); } +.pp-popup-name { font-weight: 600; } +.pp-popup-coords { font-family: ui-monospace, monospace; font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 6bb6fdf..3c1b0b6 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -52,6 +52,21 @@ export function formatUptime(seconds: number): string { return `${m}m`; } +// Signed device-clock drift for the node detail, e.g. "+42s ahead", "-1h 1m behind", "in sync". +// formatUptime floors to whole minutes and is unsigned, so it can't render sub-minute drift. +// +ve = device clock ahead of the server (matches clockDriftSeconds). +export function formatClockDrift(seconds: number): string { + if (seconds === 0) return "in sync"; + const dir = seconds > 0 ? "ahead" : "behind"; + const sign = seconds > 0 ? "+" : "-"; + const s = Math.abs(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const mag = h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`; + return `${sign}${mag} ${dir}`; +} + export function formatBattery(volts: number): string { return `${volts.toFixed(2)}V`; } @@ -68,6 +83,16 @@ export function formatCount(n: number | null | undefined): string { return fmt(1_000_000_000, "B"); } +// Average count per day over a window, e.g. 340 adverts across 7d -> "49/d". Sub-ten rates keep one +// decimal so a handful of events over a long window doesn't round away to "0/d". +export function formatRatePerDay(count: number | null | undefined, windowMs: number): string { + if (count == null || !Number.isFinite(count)) return "—"; + const days = windowMs / 86_400_000; + const rate = days > 0 ? count / days : 0; + const shown = rate >= 10 ? formatCount(Math.round(rate)) : String(Math.round(rate * 10) / 10); + return `${shown}/d`; +} + // clamp negative values from clock skew export function timeAgoMs(epochMs: number): string { const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); @@ -79,7 +104,7 @@ export function timeAgoMs(epochMs: number): string { return `${Math.floor(hours / 24)}d`; } -// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915.0,250,11"). +// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"). // Formats freq/SF/bandwidth like the observer panel ("915 MHz · SF11 · 250 kHz"); the compact // string carries no coding rate, so there's no "CR 4/x" segment. export function formatRadio(radio: string | null | undefined): string | null { diff --git a/src/types/api.ts b/src/types/api.ts index f4b68be..f50ac80 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -12,6 +12,14 @@ export interface LatestObserver { id: string; displayName?: string; iata: string; + // path fields land on the REST list from beacon-server ae0669c, and on live rows via the WS feed; + // resolvedSource/Destination are WS-only for now — the list endpoints leave them nil on purpose. + pathLength?: PathLength; + pathBytes?: string; + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; + // per-hop resolved path, for the REST list once the backend fills it in — nothing populates it today. + resolvedPath?: ResolvedHop[]; } // packet list and detail shapes @@ -69,6 +77,10 @@ export interface Observation { }; sourceBroker: string; resolvedPath: ResolvedHop[]; + // the packet's logical endpoints, resolved from the payload; separate from the relay + // resolvedPath. Absent for payload types with no addressed endpoint (GRP_TXT/GRP_DATA/TRACE). + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; } export interface PacketHeader { diff --git a/src/types/ws.ts b/src/types/ws.ts index bbff970..7701ccd 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,6 +1,6 @@ import type { ChannelMessage } from "../features/channels/types"; import type { NodeIATA } from "../features/nodes/types"; -import type { ResolvedHop } from "./api"; +import type { PathLength, ResolvedHop } from "./api"; // individual server-sent message shapes @@ -60,9 +60,14 @@ export interface WsPacketObservation { rssi: number; snr: number; sourceBroker: string; + pathLength?: PathLength; + pathBytes?: 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; + // the packet's logical endpoints, same shape as REST; null when the payload type carries none. + resolvedSource?: ResolvedHop | null; + resolvedDestination?: ResolvedHop | null; }; }; } diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx new file mode 100644 index 0000000..7c40724 --- /dev/null +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { App } from "../src/App"; +import type { PacketSummary, PacketDetail } from "../src/types/api"; + +// Full-app wiring test for the App.tsx <-> PacketExpansion <-> PacketAnalyzerDrawer coupling. +// Everything below the tab shell is real; only the network boundary and the virtualizer (needs +// layout/ResizeObserver jsdom doesn't have) are faked. + +vi.mock("../src/api/ws-manager", () => { + class WsManager { + connect() {} + disconnect() {} + updateSubscription() {} + onPacketObservation() { return () => {}; } + onLagged() { return () => {}; } + onChannelMessage() { return () => {}; } + onObserverStatus() { return () => {}; } + onNodeUpdate() { return () => {}; } + onStatusChange() { return () => {}; } + getStatus() { return "disconnected"; } + getLastEventTimestamp() { return Date.now(); } + } + return { WsManager }; +}); + +vi.mock("../src/api/client", () => ({ + getRegions: async () => [], + getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), + getIatas: async () => [], + getScopes: async () => [], +})); + +const packet: PacketSummary = { + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 3, +}; + +const detail = { + packetHash: "AA11", + header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 3, + rawPayload: "", decrypted: false, + observations: [ + { id: 1, observerId: "obs1", observerName: "Observer One", iata: "YOW", heardAt: 1700000000000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + { id: 2, observerId: "obs2", observerName: "Observer Two", iata: "YVR", heardAt: 1700000001000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + { id: 3, observerId: "obs3", observerName: "Observer Three", iata: "YYZ", heardAt: 1700000002000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + ], +} as unknown as PacketDetail; + +vi.mock("../src/features/packets/usePackets", () => ({ + usePackets: () => ({ + allPackets: [packet], + observerOptions: [], + newPacketCount: 0, + acknowledgeNewPackets: () => {}, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + observersByHash: new Map(), + handlePacketObservation: () => {}, + handleLagged: () => {}, + laggedCount: 0, + dismissLagged: () => {}, + }), +})); + +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ + data: hash === "AA11" ? detail : undefined, + isLoading: false, + isError: false, + refetch: () => {}, + }), +})); + +interface MockVirtualListProps { + packets: PacketSummary[]; + expandedHash: string | null; + onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Stands in for the virtualizer while keeping the real PacketExpansion mounted, so the row-select +// -> Open analyzer path under test is genuine, not reimplemented in the test. +vi.mock("../src/features/packets/PacketVirtualList", async () => { + const { PacketExpansion } = await import("../src/features/packets/PacketExpansion"); + return { + PacketVirtualList: ({ packets, expandedHash, onToggleExpand, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: MockVirtualListProps) => ( +
+ {packets.map((p) => ( +
+ + {expandedHash === p.packetHash && ( + + )} +
+ ))} +
+ ), + }; +}); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} }); + window.history.pushState({}, "", "/?tab=Packets"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("opening the analyzer from an expanded row", () => { + // Regression: handleAnalyze used to reset selectedObservationId on every open, so picking an + // observation inside the expanded row landed the analyzer on observations[0] instead of the one + // clicked. Selecting an observation is now what opens the analyzer, so the two happen together. + it("keeps the observation selected in the expanded row", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "AA11" })); + fireEvent.click(await screen.findByText("Observer Three")); + + const drawer = await screen.findByTestId("packet-analyzer-drawer"); + expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); + }); +}); diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx new file mode 100644 index 0000000..83dc167 --- /dev/null +++ b/tests/App.packetUrlContract.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { App } from "../src/App"; +import type { PacketSummary, PacketDetail } from "../src/types/api"; + +// The Packets tab's URL contract, exercised through a real App: ?hash expands a row, ?analyze=1 adds +// the drawer on top of it, and a mobile tab change leaves neither behind. Only the network boundary +// and the virtualizer (needs layout/ResizeObserver jsdom doesn't have) are faked. + +vi.mock("../src/api/ws-manager", () => { + class WsManager { + connect() {} + disconnect() {} + updateSubscription() {} + onPacketObservation() { return () => {}; } + onLagged() { return () => {}; } + onChannelMessage() { return () => {}; } + onObserverStatus() { return () => {}; } + onNodeUpdate() { return () => {}; } + onStatusChange() { return () => {}; } + getStatus() { return "disconnected"; } + getLastEventTimestamp() { return Date.now(); } + } + return { WsManager }; +}); + +vi.mock("../src/api/client", () => ({ + getRegions: async () => [], + getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), + getIatas: async () => [], + getScopes: async () => [], + getChannels: async () => [], + getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), +})); + +const packet: PacketSummary = { + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 1, +}; + +const detail = { + packetHash: "AA11", + header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [ + { id: 1, observerId: "obs1", observerName: "Observer One", iata: "YOW", heardAt: 1700000000000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + ], +} as unknown as PacketDetail; + +vi.mock("../src/features/packets/usePackets", () => ({ + usePackets: () => ({ + allPackets: [packet], + observerOptions: [], + newPacketCount: 0, + acknowledgeNewPackets: () => {}, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + observersByHash: new Map(), + handlePacketObservation: () => {}, + handleLagged: () => {}, + laggedCount: 0, + dismissLagged: () => {}, + }), +})); + +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ + data: hash === "AA11" ? detail : undefined, + isLoading: false, + isError: false, + refetch: () => {}, + }), +})); + +interface MockVirtualListProps { + packets: PacketSummary[]; + expandedHash: string | null; + onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Stands in for the virtualizer while keeping the real PacketExpansion mounted, so what ?hash +// expands is the genuine component and not a test stub. +vi.mock("../src/features/packets/PacketVirtualList", async () => { + const { PacketExpansion } = await import("../src/features/packets/PacketExpansion"); + return { + PacketVirtualList: ({ packets, expandedHash, onToggleExpand, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: MockVirtualListProps) => ( +
+ {packets.map((p) => ( +
+ + {expandedHash === p.packetHash && ( + + )} +
+ ))} +
+ ), + }; +}); + +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /max-width/.test(query) ? matches : /hover/.test(query), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("Packets deep links", () => { + it("restores the expanded row and the drawer from ?hash&analyze=1", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + + expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); + expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + }); + + it("expands the row without the drawer from ?hash alone", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=AA11"); + render(); + + expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); + + it("opens nothing when ?analyze=1 arrives without a hash", async () => { + window.history.pushState({}, "", "/?tab=Packets&analyze=1"); + render(); + + expect(await screen.findByRole("button", { name: "AA11" })).toBeInTheDocument(); + expect(screen.queryByTestId("packet-expansion")).not.toBeInTheDocument(); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); +}); + +describe("leaving the Packets tab", () => { + // The drawer is full-screen below md, and it renders on Channels too — so a mobile tab change has + // to drop ?analyze or the analyzer covers the tab the user just asked for. + it("closes the analyzer on mobile", async () => { + setMobile(true); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("tab", { name: "Channels" })[0]!); + + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); + + it("keeps the analyzer open on desktop", async () => { + setMobile(false); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("tab", { name: "Channels" })[0]!); + + expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + }); +}); diff --git a/tests/App.pathLinkRestore.test.tsx b/tests/App.pathLinkRestore.test.tsx new file mode 100644 index 0000000..787a4fe --- /dev/null +++ b/tests/App.pathLinkRestore.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { PathLinkRestore } from "../src/App"; +import type { PacketDetail } from "../src/types/api"; + +const getPacketDetail = vi.fn(); +vi.mock("../src/api/client", () => ({ + getPacketDetail: (hash: string) => getPacketDetail(hash), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +beforeEach(() => getPacketDetail.mockReset()); + +const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; + +describe("PathLinkRestore", () => { + // Regression: PacketPathMapModal's Copy Link strips ?analyze, so a copied path link carries ?hash + // without it — the popup can't rely on the analyzer drawer's fetch and needs its own. + it("restores the path popup from ?hash&?path alone, with no ?analyze", async () => { + getPacketDetail.mockResolvedValue(detail); + const onRestore = vi.fn(); + render( + , + { wrapper }, + ); + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha")); + expect(getPacketDetail).toHaveBeenCalledWith("AA11"); + }); + + it("does not fetch when there is no ?path", () => { + render( + , + { wrapper }, + ); + expect(getPacketDetail).not.toHaveBeenCalled(); + }); + + it("uses the analyzer's already-fetched detail instead of waiting on its own fetch", () => { + const onRestore = vi.fn(); + render( + , + { wrapper }, + ); + expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha"); + }); +}); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 0cdb141..207a36d 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder } from "../../src/api/client"; +import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -23,16 +24,16 @@ afterEach(() => { }); describe("getPackets", () => { - it("forwards the single-value server filters (routeType 0 survives, scope is encoded)", async () => { + it("forwards plural filters as comma-separated values (routeType 0 survives, scope is encoded)", async () => { const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); - await getPackets(["YOW"], { payloadType: 4, routeType: 0, scope: "#bc" }); + await getPackets(["YOW"], { payloadTypes: [2, 4], routeTypes: [0], scopes: ["#bc", "#west"] }); - const url = getUrl(); - expect(url).toContain("/packets"); - expect(url).toContain("payloadType=4"); - expect(url).toContain("routeType=0"); - expect(url).toContain("scope=%23bc"); + const url = new URL(getUrl()); + expect(url.pathname).toContain("/packets"); + expect(url.searchParams.get("payloadTypes")).toBe("2,4"); + expect(url.searchParams.get("routeTypes")).toBe("0"); // single value 0 survives + expect(url.searchParams.get("scopes")).toBe("#bc,#west"); }); it("omits the filter params when none are given", async () => { @@ -40,12 +41,12 @@ describe("getPackets", () => { 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"); + const url = new URL(getUrl()); + expect(url.searchParams.has("payloadTypes")).toBe(false); + expect(url.searchParams.has("routeTypes")).toBe(false); + expect(url.searchParams.has("scopes")).toBe(false); + expect(url.searchParams.get("cursor")).toBe("100"); + expect(url.searchParams.get("limit")).toBe("50"); }); }); @@ -84,6 +85,16 @@ describe("getNodesPage", () => { expect(url).toContain("limit=50"); }); + it("forwards the pubkeyPrefix search param", async () => { + const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); + + await getNodesPage(["YYZ"], { pubkeyPrefix: "a1b2" }); + + const url = getUrl(); + expect(url).toContain("/nodes"); + expect(url).toContain("pubkeyPrefix=a1b2"); + }); + it("forwards the Nodes-table filters (type maps to typeName, multibyte flags)", async () => { const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); @@ -381,6 +392,30 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("15"); }); + it("hits /stats/top-advertisers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-advertisers"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("10"); + }); + + it("hits /stats/top-talkers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopTalkers(["YOW"], 1700000000000, 8); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-talkers"); + expect(url.searchParams.get("iatas")).toBe("YOW"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("8"); + }); + it("hits /stats/node-types with the region's IATAs", async () => { const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]); @@ -390,4 +425,64 @@ describe("stats endpoints", () => { expect(url.pathname).toContain("/stats/node-types"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); + + it("hits /stats/clock-drift with iatas/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getClockDrift(["YOW", "YYZ"], 100); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/clock-drift"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + expect(url.searchParams.get("limit")).toBe("100"); + }); +}); + +describe("getIataBorder", () => { + // this endpoint can 204 (empty body) or send a literal `null`, so mock the status explicitly + function mockStatus(status: number, body: unknown): () => string { + let calledUrl = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calledUrl = url; + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (status === 204) throw new Error("no body to parse"); + return body; + }, + } as Response; + }), + ); + return () => calledUrl; + } + + const feature: Feature = { + type: "Feature", + properties: {}, + geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, + }; + + it("requests /iatas/{iata}/border", async () => { + const getUrl = mockStatus(200, feature); + await getIataBorder("YOW"); + expect(new URL(getUrl()).pathname).toContain("/iatas/YOW/border"); + }); + + it("returns null for a 204 (no border configured) without parsing a body", async () => { + mockStatus(204, undefined); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("treats a literal null body as no border", async () => { + mockStatus(200, null); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("returns the GeoJSON Feature when a border exists", async () => { + mockStatus(200, feature); + await expect(getIataBorder("YOW")).resolves.toEqual(feature); + }); }); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index a62171a..435e682 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "../../src/components/AppShell"; import { RegionProvider } from "../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../src/hooks/region-selection"; -import { getIatas, getRegions } from "../../src/api/client"; +import { getIatas, getRegions, getRegion } from "../../src/api/client"; import type { WsManager } from "../../src/api/ws-manager"; import pkg from "../../package.json"; @@ -36,6 +36,7 @@ function renderShell() { beforeEach(() => { vi.mocked(getIatas).mockReset(); vi.mocked(getRegions).mockReset().mockResolvedValue([]); + vi.mocked(getRegion).mockReset(); }); describe("AppShell", () => { @@ -54,3 +55,167 @@ describe("AppShell", () => { expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); }); }); + +const IATAS = [ + { iata: "YVR", displayName: "Vancouver International" }, + { iata: "YYJ", displayName: "Victoria International" }, + { iata: "YYZ", displayName: "Toronto Pearson" }, + { iata: "XXX" }, // auto-created from packet traffic — no displayName +]; + +const REGIONS = [ + { id: 1, slug: "western-canada", name: "Western Canada", iatas: ["YVR", "YYJ"] }, + { id: 2, slug: "eastern-canada", name: "Eastern Canada", iatas: ["YYZ"] }, + // name and member code both contain "YYJ", so one query exercises both match paths at once + { id: 3, slug: "yyj-corridor", name: "YYJ Corridor", iatas: ["YYJ"] }, +]; + +// Opens the picker and returns the filter input, once both region and IATA lists have landed. +// The trigger is focused first because a real browser click focuses the button; jsdom's does not. +async function openPicker() { + const trigger = screen.getByRole("button", { name: /REGION/ }); + trigger.focus(); + fireEvent.click(trigger); + await waitFor(() => expect(screen.getByText("Western Canada")).toBeInTheDocument()); + return screen.getByPlaceholderText(/Filter/); +} + +describe("region picker filter", () => { + beforeEach(() => { + vi.mocked(getIatas).mockResolvedValue(IATAS); + vi.mocked(getRegions).mockResolvedValue(REGIONS.map(({ id, slug, name }) => ({ id, slug, name }))); + vi.mocked(getRegion).mockImplementation(async (id: number) => REGIONS.find((r) => r.id === id)!); + }); + + it("focuses the filter input when the picker opens", async () => { + renderShell(); + const input = await openPicker(); + expect(input).toHaveFocus(); + }); + + it("hands focus back to the trigger when the panel closes", async () => { + renderShell(); + const input = await openPicker(); + expect(input).toHaveFocus(); + + fireEvent.keyDown(input, { key: "Escape" }); // empty query, so this closes + expect(screen.getByRole("button", { name: /REGION/ })).toHaveFocus(); + }); + + it("narrows the IATA list by code, ignoring surrounding whitespace", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: " yvr " } }); + + expect(screen.getByText("Vancouver International")).toBeInTheDocument(); + expect(screen.queryByText("Toronto Pearson")).not.toBeInTheDocument(); + expect(screen.queryByText("Victoria International")).not.toBeInTheDocument(); + expect(screen.queryByText("No matches")).not.toBeInTheDocument(); + }); + + it("narrows the IATA list by display name", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "toronto" } }); + + expect(screen.getByText("Toronto Pearson")).toBeInTheDocument(); + expect(screen.queryByText("Vancouver International")).not.toBeInTheDocument(); + // no region name or member code contains "toronto" + expect(screen.queryByText("Regions")).not.toBeInTheDocument(); + // nothing above it, so the header must not draw a divider + expect(screen.getByText("IATA")).not.toHaveClass("border-t"); + }); + + it("surfaces a region whose member IATA matches, tagged with the matching code", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "yvr" } }); + + expect(screen.getByText("Western Canada")).toBeInTheDocument(); + expect(screen.getByText("· YVR")).toBeInTheDocument(); + expect(screen.queryByText("Eastern Canada")).not.toBeInTheDocument(); + // something is above it now, so the header does draw a divider + expect(screen.getByText("IATA")).toHaveClass("border-t"); + }); + + it("tags a code match but not a name match, for the same query", async () => { + renderShell(); + const input = await openPicker(); + // "yyj" is in YYJ Corridor's *name* and in Western Canada's *member codes* + fireEvent.change(input, { target: { value: "yyj" } }); + + expect(screen.getByRole("button", { name: /Western Canada/ }).textContent).toContain("· YYJ"); + expect(screen.getByRole("button", { name: /YYJ Corridor/ }).textContent).not.toContain("·"); + }); + + it("omits the matched-code tag when the region matched on its name", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "western" } }); + + expect(screen.getByText("Western Canada")).toBeInTheDocument(); + expect(screen.queryByText("· YVR")).not.toBeInTheDocument(); + // nothing in the IATA group matches, so its header goes too + expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + }); + + it("keeps an IATA with no display name matchable by code", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "xxx" } }); + + // code column plus the displayName fallback, so the row renders the code twice + expect(screen.getAllByText("XXX")).toHaveLength(2); + expect(screen.queryByText("YVR")).not.toBeInTheDocument(); + }); + + it("filters out the All Regions row unless it matches", async () => { + renderShell(); + const input = await openPicker(); + + fireEvent.change(input, { target: { value: "yvr" } }); + expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "all" } }); + expect(screen.getByText("All Regions")).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "" } }); + expect(screen.getByText("All Regions")).toBeInTheDocument(); + }); + + it("reports no matches without leaving a dangling group header", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "zzzz" } }); + + expect(screen.getByText("No matches")).toBeInTheDocument(); + expect(screen.queryByText("Regions")).not.toBeInTheDocument(); + expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); + }); + + it("clears the query on Escape before closing the picker", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "yvr" } }); + + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.getByPlaceholderText(/Filter/)).toHaveValue(""); + expect(screen.getByText("All Regions")).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByPlaceholderText(/Filter/), { key: "Escape" }); + expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); + }); + + it("drops the query when the picker is reopened", async () => { + renderShell(); + const input = await openPicker(); + fireEvent.change(input, { target: { value: "yvr" } }); + + fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + + await waitFor(() => expect(screen.getByPlaceholderText(/Filter/)).toHaveValue("")); + expect(screen.getByText("Eastern Canada")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index 217dd1a..ca7c44c 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -30,9 +30,18 @@ const liveMsgB = { sentAt: 3000, } as ChannelMessage; +const multiLineMsg: ChannelMessage = { + id: 2, + packetHash: "ph-multiline", + channelHash: "ch1", + senderName: "dave", + content: "🟠\nDWD aktuell: WARNUNG vor GEWITTER\nDi 17:37 - Di 19:00", + sentAt: 4000, +}; + vi.mock("../../../src/api/client", () => ({ getChannelMessagesPage: vi.fn(() => - Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB], nextCursor: null, hasMore: false }), + Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB, multiLineMsg], nextCursor: null, hasMore: false }), ), })); @@ -70,3 +79,21 @@ describe("MessagePanel row keys", () => { errorSpy.mockRestore(); }); }); + +describe("MessagePanel multi-line messages", () => { + it("preserves linebreaks in a message body", async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); + + // jsdom doesn't collapse whitespace the way a browser does, so the class is what pins this; + // the normalizer override stops findByText from collapsing the newlines before matching + const body = await screen.findByText(multiLineMsg.content, { normalizer: (s) => s }); + expect(body.className).toContain("whitespace-pre-wrap"); + expect(body.className).toContain("break-words"); + }); +}); diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx new file mode 100644 index 0000000..17595f8 --- /dev/null +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { PacketDetail } from "../../../src/types/api"; +import { PayloadType } from "../../../src/types/enums"; + +// stub the WebGL map; the modal's own logic is the selector + selection state +vi.mock("../../../src/features/map/PacketPathMap", () => ({ + PacketPathMap: ({ selectedKey }: { selectedKey: string | null }) => ( +
{selectedKey ?? "all"}
+ ), +})); + +import { PacketPathMapModal } from "../../../src/features/map/PacketPathMapModal"; + +// this Node/jsdom combo leaves window.localStorage unavailable; stub it so the modal's +// style-preference read doesn't throw. +beforeEach(() => { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); +const detail = { + packetHash: "aabbccdd", + header: { payloadType: PayloadType.TEXT, routeType: 1 }, + observations: [ + { id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], propagationTimeMs: 100 }, + { id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)], propagationTimeMs: 480 }, + ], +} as unknown as PacketDetail; + +describe("PacketPathMapModal", () => { + it("lists All paths plus a row per observer and starts on All", () => { + render( {}} />); + expect(screen.getByText("All paths")).toBeInTheDocument(); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Bravo")).toBeInTheDocument(); + expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + }); + + it("isolates a path when its row is clicked", () => { + render( {}} />); + fireEvent.click(screen.getByText("Bravo")); + expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + }); + + it("shows each observer's propagation", () => { + render( {}} />); + expect(screen.getByText("0.100s")).toBeInTheDocument(); // formatPropagation(100) + expect(screen.getByText("0.480s")).toBeInTheDocument(); + }); + + it("closes from the close button", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Close path map")); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("pre-selects the observer from initialSelectedKey", () => { + render( {}} initialSelectedKey="obs-bravo" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + }); + + it("falls back to All when initialSelectedKey isn't a known path", () => { + render( {}} initialSelectedKey="nope" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + }); + + it("renders a copy-link button", () => { + render( {}} />); + expect(screen.getByRole("button", { name: "Copy path link" })).toBeInTheDocument(); + }); + + describe("copy path link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies the selected path and strips the analyzer", () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=aabb&analyze=1"); + render( {}} />); + fireEvent.click(screen.getByText("Bravo")); + + fireEvent.click(screen.getByRole("button", { name: "Copy path link" })); + + const copied = new URL(writeText.mock.calls[0]![0] as string); + expect(copied.searchParams.get("tab")).toBe("Packets"); + expect(copied.searchParams.get("hash")).toBe(detail.packetHash); + expect(copied.searchParams.get("path")).toBe("obs-bravo"); + expect(copied.searchParams.has("analyze")).toBe(false); // path and analyze are exclusive + }); + + it("copies path=all when nothing is isolated", () => { + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: "Copy path link" })); + expect(new URL(writeText.mock.calls[0]![0] as string).searchParams.get("path")).toBe("all"); + }); + }); +}); diff --git a/tests/features/map/map-url.test.ts b/tests/features/map/map-url.test.ts index 877819a..485bca2 100644 --- a/tests/features/map/map-url.test.ts +++ b/tests/features/map/map-url.test.ts @@ -70,9 +70,15 @@ describe("parseMapView", () => { expect(parseMapView(new URLSearchParams("flow=x"))).toEqual({}); }); + it("reads the iata-borders toggle on/off", () => { + expect(parseMapView(new URLSearchParams("borders=on"))).toEqual({ borders: true }); + expect(parseMapView(new URLSearchParams("borders=off"))).toEqual({ borders: false }); + expect(parseMapView(new URLSearchParams("borders=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", + "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on&borders=on", ); expect(parseMapView(params)).toEqual({ center: [-113.58, 53.31], @@ -82,6 +88,7 @@ describe("parseMapView", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); }); @@ -95,6 +102,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }; it("emits every managed key with rounded camera values", () => { @@ -107,6 +115,7 @@ describe("buildMapParams", () => { neighbor_lines: "on", style: "liberty", flow: "on", + borders: "on", }); }); @@ -124,6 +133,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 05155a7..e390657 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,11 +1,31 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathNodes, posAtHop, trailCoords } from "../../../src/features/map/packet-flow"; +import { packetChain, 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("packetChain", () => { + const relay = hop("r", -75, 45); + + it("wraps the relay hops in a high-confidence source and destination", () => { + const src = hop("s", -70, 40); + const dst = hop("d", -80, 50); + expect(packetChain(src, [relay], dst)).toEqual([src, relay, dst]); + }); + + it("drops ambiguous and unresolved endpoints", () => { + const ambiguous: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk", longitude: -70, latitude: 40 }] }; + expect(packetChain(ambiguous, [relay], { confidence: "none", nodes: [] })).toEqual([relay]); + }); + + it("accepts null or absent endpoints and leaves relay hops untouched", () => { + const ambiguousRelay: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "y", publicKey: "pk", longitude: -76, latitude: 46 }] }; + expect(packetChain(null, [relay, ambiguousRelay], undefined)).toEqual([relay, ambiguousRelay]); + }); +}); + 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)]; diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts new file mode 100644 index 0000000..1c78385 --- /dev/null +++ b/tests/features/map/packet-path.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from "vitest"; +import { buildPacketPaths, PATH_COLORS, packetPathsToFeatures, type PacketPath } from "../../../src/features/map/packet-path"; +import type { Observation, PacketDetail, ResolvedHop } from "../../../src/types/api"; +import { PayloadType } from "../../../src/types/enums"; + +function hop(id: string, lng?: number, lat?: number): ResolvedHop { + const nodes = lng != null && lat != null ? [{ id, publicKey: "pk", longitude: lng, latitude: lat }] : []; + return { confidence: nodes.length ? "high" : "none", nodes }; +} + +function obs(id: number, hops: ResolvedHop[], over: Partial = {}): Observation { + return { + id, observerId: `observer-${id}`, iata: "YYZ", heardAt: 0, + pathLength: { raw: "", hashSize: 1, hopCount: hops.length }, + sourceBroker: "b", resolvedPath: hops, ...over, + } as Observation; +} + +function detail(observations: Observation[], over: Partial = {}): PacketDetail { + return { header: { payloadType: PayloadType.TEXT, routeType: 1 }, observations, ...over } as unknown as PacketDetail; +} + +describe("buildPacketPaths", () => { + it("keys each path by observerId and carries propagation, fastest first", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }), + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", observerName: "Fast", propagationTimeMs: 100 }), + ]); + const paths = buildPacketPaths(d); + expect(paths.map((p) => p.key)).toEqual(["obs-fast", "obs-slow"]); // fastest first + expect(paths[0]).toMatchObject({ key: "obs-fast", label: "Fast", propagationMs: 100, color: PATH_COLORS[0] }); + expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order + }); + + it("sorts observations with missing propagation after timed ones", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), + ]); + const keys = buildPacketPaths(d).map((p) => p.key); + expect(keys[0]).toBe("obs-fast"); + expect(keys[keys.length - 1]).toBe("obs-none"); // missing propagation sorts last + }); + + it("prepends resolvedSource and appends resolvedDestination to the observation line", () => { + const d = detail([ + obs(1, [hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("draws a source->destination line for a directed message with no relay hops", () => { + const d = detail([ + obs(1, [], { + observerId: "obs-1", propagationTimeMs: 50, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "dst"]); + }); + + it("dedupes a resolvedSource that matches the first relay hop", () => { + const d = detail([ + obs(1, [hop("src", -79, 43), hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("skips an unresolved endpoint rather than drawing a misleading line", () => { + const d = detail([ + obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src"), // unlocated — no coords + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); + }); + + it("skips an ambiguous endpoint instead of guessing one of its candidates", () => { + // 1-byte source/dest prefixes resolve to several candidate nodes; the backend flags this + // "ambiguous" so the client shouldn't pick one and draw it as a definitive endpoint. + const ambiguousSource: ResolvedHop = { + confidence: "ambiguous", + nodes: [ + { id: "cand-a", publicKey: "pa", longitude: -71, latitude: 46 }, + { id: "cand-b", publicKey: "pb", longitude: -73, latitude: 48 }, + ], + }; + const d = detail([ + obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: ambiguousSource, + resolvedDestination: hop("dst", -77, 45), // high confidence — still drawn + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); + }); + + it("draws only the trace route for TRACE packets, suppressing per-observation lines", () => { + const d = detail( + [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], + { + header: { payloadType: PayloadType.TRACE, routeType: 1 }, + resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)], + } as unknown as Partial, + ); + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]); + }); + + it("draws nothing for a TRACE with no resolved route", () => { + // per-observation lines are suppressed for TRACE, so without resolvedRoute there is nothing to draw + const d = detail( + [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], + { header: { payloadType: PayloadType.TRACE, routeType: 1 } } as unknown as Partial, + ); + expect(buildPacketPaths(d)).toEqual([]); + }); + + it("uses the first located candidate for an ambiguous relay hop", () => { + const multi: ResolvedHop = { + confidence: "ambiguous", + nodes: [ + { id: "unlocated", publicKey: "p0" }, // no coords — skipped + { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used + ], + }; + const d = detail([ + obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]); + }); + + it("omits observations that resolve to fewer than 2 located hops", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("x")], { observerId: "obs-1" }), + obs(2, [hop("b", -80, 44), hop("c", -76, 46)], { observerId: "obs-2" }), + ]); + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["obs-2"]); + }); + + it("returns empty when nothing is drawable", () => { + expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)], { observerId: "obs-1" })]))).toEqual([]); + }); +}); + +const P: PacketPath[] = [ + { key: "1", label: "A", color: "#111", points: [ + { id: "a", lng: -79, lat: 43 }, { id: "b", lng: -78, lat: 44 }, { id: "c", lng: -77, lat: 45 }, + ] }, + { key: "2", label: "B", color: "#222", points: [ + { id: "d", lng: -80, lat: 46 }, { id: "e", lng: -76, lat: 47 }, + ] }, +]; + +describe("packetPathsToFeatures", () => { + it("emits one line per path and one point per hop, with the path color", () => { + const { lines, points, bounds } = packetPathsToFeatures(P, null); + expect(lines.features).toHaveLength(2); + expect(lines.features[0]!.properties).toEqual({ key: "1", color: "#111" }); + expect(lines.features[0]!.geometry.coordinates).toEqual([[-79, 43], [-78, 44], [-77, 45]]); + expect(points.features).toHaveLength(5); + expect(bounds).toHaveLength(5); + }); + + it("marks first/last/middle hops as start/end/mid", () => { + const { points } = packetPathsToFeatures([P[0]!], null); + expect(points.features.map((f) => f.properties.endpoint)).toEqual(["start", "mid", "end"]); + expect(points.features[0]!.properties.label).toBe("a"); + }); + + it("shows only the selected path when a key is given", () => { + const { lines, points } = packetPathsToFeatures(P, "2"); + expect(lines.features.map((f) => f.properties.key)).toEqual(["2"]); + expect(points.features).toHaveLength(2); + }); + + it("carries the untruncated node identity as title", () => { + const path: PacketPath = { key: "k", label: "L", color: "#111", points: [ + { id: "abcdef123456", lng: -79, lat: 43 }, // no name -> title is the full id + { id: "z", name: "Repeater North", lng: -78, lat: 44 }, // named -> title is the name + ] }; + const { points } = packetPathsToFeatures([path], null); + expect(points.features[0]!.properties.title).toBe("abcdef123456"); + expect(points.features[0]!.properties.label).toBe("abcdef"); // label stays truncated for the map + expect(points.features[1]!.properties.title).toBe("Repeater North"); + }); +}); diff --git a/tests/features/map/useMapBordersData.test.ts b/tests/features/map/useMapBordersData.test.ts new file mode 100644 index 0000000..b7ac299 --- /dev/null +++ b/tests/features/map/useMapBordersData.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { mergeBorders } from "../../../src/features/map/useMapBordersData"; +import type { Feature, Polygon } from "geojson"; + +const poly = (id: number): Feature => ({ + type: "Feature", + properties: { name: `p${id}` }, + geometry: { type: "Polygon", coordinates: [[[id, 0], [id + 1, 0], [id + 1, 1], [id, 0]]] }, +}); + +describe("mergeBorders", () => { + it("drops IATAs with no border and stamps the iata onto each feature's properties", () => { + const fc = mergeBorders([ + { iata: "YOW", border: poly(0) }, + { iata: "YYZ", border: null }, + { iata: "YUL", border: poly(5) }, + ]); + + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features).toHaveLength(2); + expect(fc.features.map((f) => f.properties.iata)).toEqual(["YOW", "YUL"]); + // existing properties and geometry survive the merge + expect(fc.features[0]!.properties.name).toBe("p0"); + expect(fc.features[0]!.geometry).toEqual(poly(0).geometry); + }); + + it("returns an empty FeatureCollection when nothing has a border", () => { + const fc = mergeBorders([{ iata: "YOW", border: null }]); + expect(fc.features).toHaveLength(0); + }); +}); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 19162b6..22992a2 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -86,3 +86,30 @@ describe("NodeDetailPanel neighbors", () => { expect(await screen.findByText("No known neighbors")).toBeInTheDocument(); }); }); + +describe("NodeDetailPanel clock drift", () => { + it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { + mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); + + renderPanel(); + + const drift = await screen.findByText("+7m 12s ahead"); + expect(drift.className).toContain("text-warn"); + }); + + it("shows an in-sync drift in green", async () => { + mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 }); + + renderPanel(); + + const drift = await screen.findByText("+20s ahead"); + expect(drift.className).toContain("text-green"); + }); + + it("omits clock drift entirely when the node reports none", async () => { + renderPanel(); + + await screen.findByText("Timestamps"); + expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/nodes/node-search.test.ts b/tests/features/nodes/node-search.test.ts new file mode 100644 index 0000000..1f85434 --- /dev/null +++ b/tests/features/nodes/node-search.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { nodeSearchParams } from "../../../src/features/nodes/node-search"; + +describe("nodeSearchParams", () => { + it("maps the name field to the name param", () => { + expect(nodeSearchParams("name", "alpha")).toEqual({ name: "alpha" }); + }); + + it("maps the pubkey field to a lowercased hex prefix", () => { + expect(nodeSearchParams("pubkey", "AB12")).toEqual({ pubkeyPrefix: "ab12" }); + }); + + it("drops a non-hex pubkey prefix instead of firing a request the server 400s", () => { + // names/spaces are non-hex; sending them as pubkeyPrefix would 400 the whole table + expect(nodeSearchParams("pubkey", "alice")).toEqual({ pubkeyPrefix: undefined }); + expect(nodeSearchParams("pubkey", "de ad")).toEqual({ pubkeyPrefix: undefined }); + }); + + it("treats blank input as no filter on either field", () => { + expect(nodeSearchParams("name", " ")).toEqual({ name: undefined }); + expect(nodeSearchParams("pubkey", "")).toEqual({ pubkeyPrefix: undefined }); + }); +}); diff --git a/tests/features/packets/ObservationTable.test.tsx b/tests/features/packets/ObservationTable.test.tsx new file mode 100644 index 0000000..d82d95f --- /dev/null +++ b/tests/features/packets/ObservationTable.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { ObservationTable } from "../../../src/features/packets/ObservationTable"; +import type { Observation } from "../../../src/types/api"; + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +describe("ObservationTable", () => { + it("renders one row per observation in the given order", () => { + render( {}} />); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("selects an observation on click", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Observer 1")); + expect(onSelect).toHaveBeenCalledWith(1); + }); + + it("marks the selected row", () => { + render( {}} />); + expect(screen.getByRole("row", { selected: true })).toBeInTheDocument(); + }); + + it("leaves an unselected row's aria-selected false", () => { + render( {}} />); + const rows = screen.getAllByRole("row").slice(1); // drop the header row + expect(rows[0]).toHaveAttribute("aria-selected", "true"); + expect(rows[1]).toHaveAttribute("aria-selected", "false"); + }); + + it("falls back to a truncated observer id when the name is missing", () => { + render( {}} />); + expect(screen.getByText("abcdefgh")).toBeInTheDocument(); + }); + + it("renders em dashes for every absent optional field and no path row", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cells = within(row).getAllByRole("cell"); + expect(cells[3]).toHaveTextContent("—"); // SNR + expect(cells[4]).toHaveTextContent("—"); // RSSI + expect(cells[5]).toHaveTextContent("—"); // Prop + expect(cells[7]).toHaveTextContent("—"); // Path + }); + + it("renders every field when all optional data is present", () => { + render( + {}} + />, + ); + const row = screen.getAllByRole("row")[1]!; + const cells = within(row).getAllByRole("cell"); + expect(cells[3]).toHaveTextContent("6.20"); + expect(cells[4]).toHaveTextContent("-87"); + expect(cells[5]).toHaveTextContent("1.234s"); + expect(cells[6]).toHaveTextContent("1"); + expect(within(cells[7]!).getByText("AB")).toBeInTheDocument(); + }); + + it("colors a good SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-green"); + }); + + it("colors a mid SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-warn"); + }); + + it("colors a bad SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-danger"); + }); + + it("colors a null SNR as dim", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-text-dim"); + }); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8e18df3..8dc1853 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -1,7 +1,9 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; +import type { PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; function LocationProbe() { const location = useLocation(); @@ -9,10 +11,10 @@ function LocationProbe() { } describe("PacketAnalyzerDrawer close", () => { - it("removes ?hash from the URL and calls onClose", () => { + it("removes ?analyze but keeps ?hash, and calls onClose", () => { const onClose = vi.fn(); render( - + , @@ -22,7 +24,102 @@ describe("PacketAnalyzerDrawer close", () => { expect(onClose).toHaveBeenCalledOnce(); const search = screen.getByTestId("search").textContent ?? ""; - expect(search).not.toContain("hash="); + expect(search).not.toContain("analyze="); + expect(search).toContain("hash=abc123"); // row stays expanded expect(search).toContain("tab=Packets"); // other params survive }); }); + +const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + +function makeDetail(resolvedPath: unknown[]): PacketDetail { + return { + packetHash: "abcdef12", + header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TEXT, payloadTypeName: "TXT_MSG", payloadVersion: 1 }, + firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [{ id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: resolvedPath.length }, resolvedPath }], + } as unknown as PacketDetail; +} + +describe("PacketAnalyzerDrawer copy link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies a link that reopens the drawer over the expanded row", () => { + render( + + {}} /> + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy packet link" })); + + const copied = new URL(writeText.mock.calls[0]![0] as string); + expect(copied.searchParams.get("tab")).toBe("Packets"); + expect(copied.searchParams.get("hash")).toBe("abcdef12"); + expect(copied.searchParams.get("analyze")).toBe("1"); // the drawer is part of the shared state + }); +}); + +describe("PacketAnalyzerDrawer view-path button", () => { + it("enables the button and calls onViewPath when a path is drawable", () => { + const onViewPath = vi.fn(); + render( + + {}} onViewPath={onViewPath} /> + , + ); + const btn = screen.getByRole("button", { name: /view path on map/i }); + expect(btn).toBeEnabled(); + fireEvent.click(btn); + expect(onViewPath).toHaveBeenCalledOnce(); + }); + + it("disables the button when no path is drawable", () => { + render( + + {}} onViewPath={() => {}} /> + , + ); + expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); + }); +}); + +describe("PacketAnalyzerDrawer TRACE path data", () => { + // After beacon-server's trace-path fix (7a58a07) a TRACE observation's pathBytes are the trace's + // own path hashes (with matching hashSize/hopCount and a real resolvedPath), not raw SNR bytes — + // so it must render as resolved Path Data, not under the old "Path SNR Data" label. + function traceDetail(): PacketDetail { + return { + packetHash: "abcdef12", + header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TRACE, payloadTypeName: "TRACE", payloadVersion: 1 }, + firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [{ + id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", + pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, + pathBytes: "abcd", + resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], + }], + } as unknown as PacketDetail; + } + + it("renders TRACE path bytes as resolved Path Data, not raw 'Path SNR Data'", () => { + render( + + {}} /> + , + ); + expect(screen.queryByText("Path SNR Data")).not.toBeInTheDocument(); + expect(screen.getByText("Path Data")).toBeInTheDocument(); + // the first trace hash renders as a resolved hop block, tinted green for high confidence + expect(screen.getAllByText("AB").some((el) => el.className.includes("text-green"))).toBe(true); + }); +}); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx new file mode 100644 index 0000000..037489c --- /dev/null +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; +import type { LatestObserver, PacketSummary } from "../../../src/types/api"; + +const pkt = (observer?: LatestObserver): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, latestObserver: observer, +}); + +const obs = (over: Partial = {}): LatestObserver => ({ + id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, +}); + +describe("PacketEndpoints", () => { + it("renders a single n/a when there is no observer at all", () => { + render(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + it("renders both endpoints with the arrow glyph between them", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("DstNode")).toBeInTheDocument(); + expect(screen.getByText("→")).toBeInTheDocument(); + }); + + it("shows n/a for a missing endpoint while the present one still renders", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + // The REST list leaves both nil, so this is the common scrollback case — one n/a, no arrow. + it("collapses to a single n/a when both endpoints are absent", () => { + render(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + }); + + it("tints an ambiguous endpoint with the warn token", () => { + render(); + expect(screen.getByText("Raven").className).toContain("text-warn"); + }); + + it("tints a high-confidence endpoint with the green token", () => { + render(); + expect(screen.getByText("Falcon").className).toContain("text-green"); + }); + + it("renders a bare ? for an endpoint the backend could not resolve", () => { + render(); + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx new file mode 100644 index 0000000..889a7df --- /dev/null +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; +import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; + +const usePacketDetail = vi.fn(); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 3, ...over, +}); + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +// minimal header so buildPacketPaths(data) (View path on map's hasPath check) doesn't crash on a +// partial detail fixture — any non-TRACE payload type does +const header = () => ({ raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.ADVERT, payloadTypeName: "ADVERT", payloadVersion: 1 }); + +const resolvedHop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + +// a detail with a real drawable path (>=2 located hops), for the "hasPath" enabled cases +const detailWithPath = (): PacketDetail => ({ + packetHash: "AA11", + header: header(), + observations: [obs(1, { resolvedPath: [resolvedHop("a", -79, 43), resolvedHop("b", -75, 45)] })], +} as unknown as PacketDetail); + +const props = { + packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, + selectedObservationId: null, onSelectObservation: () => {}, +}; + +beforeEach(() => usePacketDetail.mockReset()); + +describe("PacketExpansion", () => { + it("shows one skeleton row per expected observation while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(3); + }); + + it("caps skeleton rows at the scroll cap for a large observation count", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(12); + }); + + it("shows an error line with retry on failure", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Failed to load observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("shows error state even when summary count is zero", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Failed to load observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + expect(screen.queryByText("No observations")).not.toBeInTheDocument(); + }); + + it("calls refetch when retry is clicked", () => { + const refetch = vi.fn(); + usePacketDetail.mockReturnValue({ isError: true, refetch }); + render(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(refetch).toHaveBeenCalled(); + }); + + it("shows an empty state when the packet has no observations", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [] } }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + }); + + it("shows the empty state immediately when the summary count is zero, without a skeleton", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument(); + }); + + it("renders the timing strip from the summary without waiting for the fetch", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText(/spread/i)).toBeInTheDocument(); + }); + + it("formats the spread as first/last converted from milliseconds, not re-scaled", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("spread 2.500s")).toBeInTheDocument(); + }); + + it("renders the observation table once data resolves", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); + render(); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("wires selectedObservationId and onSelectObservation through to the observation table", () => { + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(rows[1]).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByText("Observer 1")); + expect(onSelectObservation).toHaveBeenCalledWith(1); + }); + + // Clicking an observation opens the analyzer, so a dedicated button would be a second way to do + // the same thing. Copy Link lives in the analyzer popup only. + it("offers neither an Open analyzer nor a Copy link button", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + expect(screen.queryByRole("button", { name: "Open analyzer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy row link" })).not.toBeInTheDocument(); + }); + + it("opens the analyzer on the clicked observation, selecting it first", () => { + const onOpenAnalyzer = vi.fn(); + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); + render(); + + fireEvent.click(screen.getByText("Observer 2")); + + expect(onSelectObservation).toHaveBeenCalledWith(2); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + }); + + it("disables View path on map while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("disables View path on map on error", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("enables View path on map once the fetch resolves with a drawable path", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); + }); + + it("disables View path on map when the loaded detail has no resolvable path", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } }); + render(); + const viewPathBtn = screen.getByRole("button", { name: "View path on map" }); + expect(viewPathBtn).toBeDisabled(); + expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map"); + }); + + it("calls onViewPath when its button is clicked", () => { + const onViewPath = vi.fn(); + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(onViewPath).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 1f40e28..d704489 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { PacketSummary } from "../../../src/types/api"; +import type { PacketSummary, PacketDetail } from "../../../src/types/api"; +import type { WsPacketObservation } from "../../../src/types/ws"; const basePackets = () => ({ allPackets: [] as PacketSummary[], @@ -26,71 +28,113 @@ vi.mock("../../../src/features/packets/usePackets", () => ({ usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); +const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined })); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => usePacketDetail(hash as never), +})); + vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), })); +// capture the packet handler so tests can push a live observation through it +let packetHandler: ((data: WsPacketObservation["data"]) => void) | null = null; vi.mock("../../../src/hooks/useWsHandlers", () => ({ - useWsPacketHandler: () => {}, + useWsPacketHandler: (_manager: unknown, handler: (data: WsPacketObservation["data"]) => void) => { + packetHandler = handler; + }, useWsLaggedHandler: () => {}, })); -// the virtual list needs ResizeObserver in jsdom; stub it down to the expand wiring under test +// the virtual list needs ResizeObserver in jsdom; stub it down to the wiring under test vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ PacketVirtualList: ({ + packets, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, }: { + packets: PacketSummary[]; expandedHash: string | null; onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; }) => (
{String(expandedHash)}
- + + + {packets.map((p) => ( + + ))}
), })); -// stands in for the analyzer drawer's close button, which clears ?hash from outside PacketList -function ExternalHashCloser() { - const [, setSearchParams] = useSearchParams(); - return ( - +const packet = (hash: string): PacketSummary => ({ + packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1, +}); + +const observation = (hash: string): WsPacketObservation["data"] => ({ + packetHash: hash, + packet: { + payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + isFirstObservation: false, observationCount: 2, + }, + observation: { + observerId: "o1", observerName: "Observer 1", iata: "YOW", + heardAt: 1700000001, rssi: -90, snr: 5, sourceBroker: "b1", + }, +}); + +function renderList(url = "/", props: Partial[0]> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + const onAnalyze = props.onAnalyze ?? vi.fn(); + const onViewPath = props.onViewPath ?? vi.fn(); + const onSelectObservation = props.onSelectObservation ?? vi.fn(); + + render( + + + + + , ); + + return { onAnalyze, onViewPath, onSelectObservation, invalidate }; } 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 }); + renderList("/?types=4"); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] }); }); - it("passes null for multi-select so history stays unfiltered", () => { + it("passes a multi-select filter server-side so history stays filtered", () => { usePackets.mockClear(); - renderAt("/?types=2,4"); - expect(usePackets).toHaveBeenLastCalledWith(false, null); + renderList("/?types=2,4"); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [2, 4] }); }); }); @@ -99,14 +143,6 @@ describe("PacketList loading feedback", () => { 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(); @@ -140,24 +176,97 @@ describe("PacketList loading feedback", () => { }); describe("PacketList expanded row", () => { - it("follows the ?hash param so an external analyzer close deselects the row", () => { - const onAnalyze = vi.fn(); - render( - - - - , - ); + afterEach(() => { + usePackets.mockImplementation(basePackets); + usePacketDetail.mockReturnValue({ data: undefined }); + }); - expect(screen.getByTestId("expanded").textContent).toBe("h1"); + it("expands a row from ?hash without opening the analyzer", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); - // analyzer drawer closed elsewhere — row must deselect - fireEvent.click(screen.getByText("close-url")); - expect(screen.getByTestId("expanded").textContent).toBe("null"); + const { onAnalyze } = renderList("/?tab=Packets&hash=AA11"); - // clicking the same row again must reopen, not collapse - fireEvent.click(screen.getByText("toggle-h1")); - expect(screen.getByTestId("expanded").textContent).toBe("h1"); - expect(onAnalyze).toHaveBeenLastCalledWith("h1"); + expect(screen.getByRole("button", { name: /AA11/ })).toHaveAttribute("aria-expanded", "true"); + expect(onAnalyze).not.toHaveBeenCalled(); + }); + + it("clicking a row sets ?hash and does not open the analyzer", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + + const { onAnalyze } = renderList("/?tab=Packets"); + + fireEvent.click(screen.getByRole("button", { name: /AA11/ })); + expect(onAnalyze).not.toHaveBeenCalled(); + }); + + it("routes the expansion's Open analyzer through onAnalyze with the expanded hash", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + + const { onAnalyze } = renderList("/?tab=Packets&hash=AA11"); + + fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + expect(onAnalyze).toHaveBeenCalledWith("AA11"); + }); + + it("hands the loaded detail to onViewPath", () => { + const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + usePacketDetail.mockReturnValue({ data: detail }); + + const { onViewPath } = renderList("/?tab=Packets&hash=AA11"); + + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(onViewPath).toHaveBeenCalledWith(detail); + }); + + it("does not call onViewPath before the detail has loaded", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + + const { onViewPath } = renderList("/?tab=Packets&hash=AA11"); + + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(onViewPath).not.toHaveBeenCalled(); + }); +}); + +describe("PacketList live observation invalidation", () => { + afterEach(() => { + usePackets.mockImplementation(basePackets); + packetHandler = null; + }); + + it("refetches the expanded row's detail when an observation arrives for it", () => { + const handlePacketObservation = vi.fn(); + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation })); + + const { invalidate } = renderList("/?tab=Packets&hash=AA11"); + invalidate.mockClear(); + packetHandler!(observation("AA11")); + + expect(handlePacketObservation).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["packet-detail", "AA11"] }); + }); + + it("leaves the detail query alone for observations on other packets", () => { + const handlePacketObservation = vi.fn(); + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation })); + + const { invalidate } = renderList("/?tab=Packets&hash=AA11"); + invalidate.mockClear(); + packetHandler!(observation("BB22")); + + expect(handlePacketObservation).toHaveBeenCalledTimes(1); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("does not invalidate when no row is expanded", () => { + const handlePacketObservation = vi.fn(); + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation })); + + const { invalidate } = renderList("/?tab=Packets"); + invalidate.mockClear(); + packetHandler!(observation("AA11")); + + expect(invalidate).not.toHaveBeenCalled(); }); }); diff --git a/tests/features/packets/PacketTableHeader.test.tsx b/tests/features/packets/PacketTableHeader.test.tsx new file mode 100644 index 0000000..de254c0 --- /dev/null +++ b/tests/features/packets/PacketTableHeader.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PacketTableHeader } from "../../../src/features/packets/PacketTableHeader"; +import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid"; + +describe("PacketTableHeader", () => { + it("declares every column heading", () => { + render(); + for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Src → Dst", "IATA", "Age"]) { + expect(screen.getByText(h)).toBeInTheDocument(); + } + }); + + it("no longer heads an observer column, which moved into the expansion", () => { + render(); + expect(screen.queryByText("Observer")).not.toBeInTheDocument(); + }); + + it("is hidden below md", () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain("hidden"); + expect(container.firstElementChild?.className).toContain("md:grid"); + }); + + it("applies the shared GRID_TEMPLATE so columns align with the row", () => { + const { container } = render(); + const el = container.firstElementChild as HTMLElement; + expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE); + }); + + it("has exactly 10 cells, one per row column including the chevron spacer", () => { + const { container } = render(); + expect(container.firstElementChild?.children).toHaveLength(10); + }); + + // Regression: the header and the rows are two independent grids. A `ch` track resolves against + // each one's own font size (header 9px vs row 11px) and an `auto`/`min-content` track against its + // own content ("HASH" vs "4AE77F09"), so either kind silently drifts the columns apart. + it("sizes every track in font-independent units so both grids resolve identically", () => { + expect(GRID_TEMPLATE).not.toMatch(/\bch\b/); + expect(GRID_TEMPLATE).not.toMatch(/auto|min-content|max-content|fit-content/); + }); + + it("leaves the leading chevron-alignment cell unlabeled and hidden from screen readers", () => { + const { container } = render(); + const first = container.firstElementChild?.children[0]; + expect(first).toHaveAttribute("aria-hidden"); + expect(first?.textContent).toBe(""); + }); +}); diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx new file mode 100644 index 0000000..27435ae --- /dev/null +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { PacketTableRow } from "../../../src/features/packets/PacketTableRow"; +import type { LatestObserver, PacketSummary, ResolvedHop } from "../../../src/types/api"; + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over, +}); + +const node = (name: string): ResolvedHop => ({ + confidence: "high", + nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }], +}); + +// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here. +const observer = ( + over: { hopCount?: number; hashSize?: number } & Partial> = {}, +): LatestObserver => { + const { hopCount = 2, hashSize = 1, ...rest } = over; + return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize, hopCount }, ...rest }; +}; + +describe("PacketTableRow", () => { + it("exposes one button carrying the expansion state", () => { + render( {}} />); + const btn = screen.getByRole("button"); + expect(btn).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles on click", () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("is a single line, so the row height stays constant for the virtualizer", () => { + const { container } = render( {}} />); + expect(container.querySelectorAll("button")).toHaveLength(1); + expect(screen.queryByText("latest")).not.toBeInTheDocument(); + }); + + it("falls back to n/a in the hops, hash size, endpoint and IATA cells when there is no observer", () => { + render( {}} />); + const row = within(screen.getByRole("button")); + expect(row.getAllByText("n/a")).toHaveLength(4); + }); + + it("shows the hash size alongside the hop count", () => { + render( {}} />); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("no longer shows the observer, which moved into the expansion", () => { + render( {}} />); + expect(screen.queryByText("Cypress Peak")).not.toBeInTheDocument(); + expect(screen.queryByText("abcdef12")).not.toBeInTheDocument(); + expect(screen.getByText("YVR")).toBeInTheDocument(); + }); + + it("shows the hop count, which the REST list carries on every row", () => { + render( {}} />); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("renders resolved endpoints when the WS feed supplied them", () => { + render( + {}} + />, + ); + expect(screen.getByText("Laprairie")).toBeInTheDocument(); + expect(screen.getByText("YUL1")).toBeInTheDocument(); + }); + + // The REST list leaves resolvedSource/Destination nil on purpose, so scrollback rows show one n/a + // for the pair rather than "n/a → n/a". + it("collapses the endpoint cell to a single n/a when neither endpoint resolved", () => { + render( {}} />); + expect(screen.getAllByText("n/a")).toHaveLength(1); + }); + + it("still marks the missing half when only one endpoint resolved", () => { + render( {}} />); + expect(screen.getByText("Laprairie")).toBeInTheDocument(); + expect(screen.getAllByText("n/a")).toHaveLength(1); + }); + + it("reflects the expanded state on the button and chevron", () => { + render( {}} />); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("›")).toHaveClass("rotate-90"); + }); + + it("marks a fresh row with the pulse class", () => { + const { container } = render( {}} />); + expect(container.querySelector(".packet-fresh")).toBeInTheDocument(); + }); + + it("renders a scope tag when the packet has a scope", () => { + render( {}} />); + expect(screen.getByText("#bc")).toBeInTheDocument(); + }); + + it("falls back to the raw payload type name for an unrecognized payload type", () => { + render( {}} />); + expect(screen.getByText("CUSTOM_99")).toBeInTheDocument(); + }); + + it("falls back to Unknown when routeTypeName is empty", () => { + render( {}} />); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/PacketVirtualList.test.tsx b/tests/features/packets/PacketVirtualList.test.tsx new file mode 100644 index 0000000..96f8028 --- /dev/null +++ b/tests/features/packets/PacketVirtualList.test.tsx @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { PacketVirtualList } from "../../../src/features/packets/PacketVirtualList"; +import type { PacketSummary } from "../../../src/types/api"; + +// PacketExpansion fetches through usePacketDetail; stub it so the list renders without a query client. +const usePacketDetail = vi.fn(() => ({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } })); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +const VIEWPORT_H = 1300; +const ROW_H = 60; +const EXPANDED_H = 400; + +// jsdom has no layout and no ResizeObserver, so feed the virtualizer both: offsetHeight answers for +// the viewport and for each measured item (taller once it holds an expansion), and flushResize() +// stands in for the browser noticing a row changed height. +type Observed = { cb: ResizeObserverCallback; targets: Set }; +const observers: Observed[] = []; + +class StubResizeObserver { + private entry: Observed; + constructor(cb: ResizeObserverCallback) { + this.entry = { cb, targets: new Set() }; + observers.push(this.entry); + } + observe(target: Element) { this.entry.targets.add(target); } + unobserve(target: Element) { this.entry.targets.delete(target); } + disconnect() { this.entry.targets.clear(); } +} +vi.stubGlobal("ResizeObserver", StubResizeObserver); + +function flushResize() { + act(() => { + for (const o of observers) { + const entries = [...o.targets].map((target) => ({ target })) as unknown as ResizeObserverEntry[]; + if (entries.length > 0) o.cb(entries, {} as ResizeObserver); + } + }); +} + +Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + if (!this.hasAttribute("data-index")) return VIEWPORT_H; + return this.querySelector("[data-testid='packet-expansion']") ? EXPANDED_H : ROW_H; + }, +}); + +const pkt = (hash: string): PacketSummary => ({ + packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1, +}); + +const many = (n: number) => Array.from({ length: n }, (_, i) => pkt(`AA${i}`)); + +function makeHandlers() { + return { + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + onScrollAwayFromTop: vi.fn(), + onAtTopChange: vi.fn(), + onToggleExpand: vi.fn(), + onOpenAnalyzer: vi.fn(), + onViewPath: vi.fn(), + selectedObservationId: null, + onSelectObservation: vi.fn(), + }; +} + +// the scroll container is the component's root; the spacer under it carries the virtualizer's total size +const scroller = (container: HTMLElement) => container.firstElementChild as HTMLElement; +function totalSize(container: HTMLElement) { + const spacer = scroller(container).lastElementChild as HTMLElement; + const height = parseFloat(spacer.style.height); + expect(Number.isFinite(height)).toBe(true); + return height; +} + +function setScrollMetrics(el: HTMLElement, { scrollHeight, clientHeight, scrollTop }: { + scrollHeight: number; clientHeight: number; scrollTop: number; +}) { + Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight }); + Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight }); + el.scrollTop = scrollTop; +} + +beforeEach(() => { + observers.length = 0; + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } }); +}); + +describe("PacketVirtualList expansion", () => { + it("mounts the expansion inside the measured wrapper", () => { + render(); + + const wrapper = screen.getByTestId("packet-item-AA11"); + expect(wrapper).toHaveAttribute("data-index", "0"); + expect(wrapper.querySelector("[data-testid='packet-expansion']")).not.toBeNull(); + }); + + it("expands only the row named by expandedHash", () => { + render(); + + expect(screen.getAllByTestId("packet-expansion")).toHaveLength(1); + expect(screen.getByTestId("packet-item-AA3").querySelector("[data-testid='packet-expansion']")).not.toBeNull(); + }); + + it("renders no expansion when nothing is expanded", () => { + render(); + + expect(screen.queryByTestId("packet-expansion")).toBeNull(); + }); + + it("counts the expanded height in the virtualizer's total size", () => { + const packets = many(30); + const handlers = makeHandlers(); + const { container, rerender } = render( + , + ); + const collapsed = totalSize(container); + + rerender(); + flushResize(); + + expect(totalSize(container)).toBe(collapsed + (EXPANDED_H - ROW_H)); + }); + + it("forwards the expansion's actions", () => { + const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + usePacketDetail.mockReturnValue({ + data: { + packetHash: "AA11", + header: { payloadType: 1 }, + observations: [{ id: 1, observerId: "o1", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] }], + }, + }); + const handlers = makeHandlers(); + render(); + + // clicking an observation is what opens the analyzer now — there is no button for it + fireEvent.click(screen.getByText("o1")); + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + + expect(handlers.onSelectObservation).toHaveBeenCalledWith(1); + expect(handlers.onOpenAnalyzer).toHaveBeenCalledTimes(1); + expect(handlers.onViewPath).toHaveBeenCalledTimes(1); + }); +}); + +describe("PacketVirtualList header", () => { + it("renders the sticky header once, outside measured item space", () => { + const { container } = render( + , + ); + + const headings = screen.getAllByText("Hash"); + expect(headings).toHaveLength(1); + const header = headings[0]!.parentElement as HTMLElement; + expect(header.closest("[data-index]")).toBeNull(); + expect(header.parentElement).toBe(scroller(container)); + expect(header.previousElementSibling).toBeNull(); + }); +}); + +describe("PacketVirtualList scrolling", () => { + it("pages when scrolled near the bottom", () => { + const handlers = makeHandlers(); + const { container } = render( + , + ); + + const el = scroller(container); + setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 }); + fireEvent.scroll(el); + + expect(handlers.fetchNextPage).toHaveBeenCalled(); + }); + + it("does not page while a page is already in flight", () => { + const handlers = makeHandlers(); + const { container } = render( + , + ); + + const el = scroller(container); + setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 }); + fireEvent.scroll(el); + + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + }); + + it("reports at-top and scrolled-away as the scroll position moves", () => { + const handlers = makeHandlers(); + const { container } = render( + , + ); + + const el = scroller(container); + setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 150 }); + fireEvent.scroll(el); + expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(false); + expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(true); + + setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 0 }); + fireEvent.scroll(el); + expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(true); + expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(false); + }); + + it("does not page when a row collapses near the bottom", () => { + const packets = many(30); + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + handlers.fetchNextPage.mockClear(); + + rerender(); + flushResize(); + + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + }); +}); + +// mirrors DataTable.test.tsx's mobile stub, keeping the max-width query the only configurable one +// so hover-driven components (e.g. Tooltip, used by PacketRow) keep their default hover behaviour +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /max-width/.test(query) ? matches : /hover/.test(query), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +describe("PacketVirtualList responsive row", () => { + afterEach(() => { + setMobile(false); // back to the desktop default so later tests in this file aren't affected + }); + + it("renders the card row below md", () => { + setMobile(true); + const handlers = makeHandlers(); + const { container } = render( + , + ); + + // PacketRow has no button role; PacketTableRow's toggle is a real )} @@ -108,6 +148,7 @@ function RegionSelector() { // Split out from RegionSelector so the filter query lives and dies with the open panel. function RegionSelectorPanel() { + const { t } = useTranslation(); const { selection, setSelection } = useRegionSelection(); const { regions } = useRegions(); const [query, setQuery] = useState(""); @@ -166,13 +207,14 @@ function RegionSelectorPanel() { ); }, [iatas, q]); - const showAll = !q || "all regions".includes(q); + const showAll = !q || t("region.allRegions").toLowerCase().includes(q); const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed const hasRowsAbove = showAll || shownRegions.length > 0; return ( <>
+ {/* 16px on phones so iOS Safari does not zoom the page on focus. */}
@@ -202,14 +245,14 @@ function RegionSelectorPanel() { > {/* spacer matching the checkbox column so ALL/code/name align with the rows below */}
{iatas ? ( shownIatas.map((i) => { const checked = selection.iatas.includes(i.iata); @@ -254,30 +297,31 @@ function RegionSelectorPanel() { ); }) ) : iatasError ? ( -
Failed to load
+
{t("region.failed")}
) : ( -
Loading…
+
{t("common.loading")}
)} )} {!hasRowsAbove && !showIataGroup && ( -
No matches
+
{t("region.noMatches")}
)} ); } function ThemePicker() { - const { themeId, themes, setThemeId } = useTheme(); + const { t } = useTranslation(); + const { themeId, themes } = useTheme(); const current = themes.find((t) => t.id === themeId); - const list = selectableThemes(themes, ENABLED_THEME_IDS); return ( ( )} + > + {(close) => } + + ); +} + +function ThemeOptions({ onPick }: { onPick?: () => void }) { + const { themeId, themes, setThemeId } = useTheme(); + return ( + <> + {selectableThemes(themes, ENABLED_THEME_IDS).map((theme) => ( + + ))} + + ); +} + +function GitHubLink() { + const { t } = useTranslation(); + return ( + + + + ); +} + +const MENU_GROUP = "px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim"; + +// Phones fold theme, language and the repo link into one menu so the header stays a single row. +function SettingsMenu() { + const { t } = useTranslation(); + return ( + ( + + )} > {(close) => ( <> - {list.map((t) => ( - - ))} +
{t("language.label")}
+ +
{t("theme.label")}
+ +
+ + {APP_NAME} v{__APP_VERSION__} +
)}
@@ -328,27 +428,38 @@ interface AppShellProps { } export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); return (
-
- -
- - - - - - -
-
+ + {isMobile ? ( +
+ +
+ +
+
+ + + +
+
+ ) : ( +
+ +
+ + + +
+
+ + + +
+
+ )}
- {children} + {children}
diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index d530b59..ab2896a 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -1,4 +1,5 @@ import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { BottomSheet } from "./BottomSheet"; import { ENABLED_TABS } from "../lib/constants"; @@ -81,8 +82,9 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo // Bottom sheet listing the overflow tabs. function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) { + const { t } = useTranslation(); return ( - + {tabs.map((tab) => ( ))} @@ -101,6 +103,7 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ } export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) { + const { t } = useTranslation(); const [sheetOpen, setSheetOpen] = useState(false); const enabled = new Set(ENABLED_TABS); const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t)); @@ -114,11 +117,11 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab return ( <> -
@@ -70,12 +74,12 @@ export function DetailPanel({ title, onClose, collapsible, isLoading, notFound,
{isLoading ? (
- Loading... + {t("common.loadingDots")}
) : notFound ? (
{notFoundIcon} - {notFoundLabel} + {notFoundLabel ?? t("common.notFound")}
) : ( children diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index e37a350..cdb4ae3 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -1,9 +1,12 @@ import { useState, useRef, useCallback, useEffect, type ReactNode } from "react"; import { useClickOutside } from "../hooks/useClickOutside"; +const ALIGN_CLASS = { left: "left-0", right: "right-0", "left-below-md": "left-0 md:left-auto md:right-0" } as const; + export function Dropdown({ renderTrigger, align = "right", width = "w-48", fullWidth = false, children }: { renderTrigger: (props: { open: boolean; toggle: () => void }) => ReactNode; - align?: "left" | "right"; + // left-below-md: the trigger sits at the left edge of a phone row but mid-row on desktop + align?: "left" | "right" | "left-below-md"; width?: string; // stretch + render the panel inline (accordion) for the mobile filter sheet fullWidth?: boolean; @@ -33,7 +36,7 @@ export function Dropdown({ renderTrigger, align = "right", width = "w-48", fullW
{children(close)}
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index bebdabc..2deec0f 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,8 +1,11 @@ import { Component, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; interface Props { children: ReactNode; fallback?: ReactNode; + // a new value clears a caught error, so one broken tab doesn't block the others + resetKey?: string; } interface State { @@ -16,25 +19,34 @@ export class ErrorBoundary extends Component { return { error }; } + componentDidUpdate(prev: Props) { + if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null }); + } + render() { if (this.state.error) { if (this.props.fallback) return this.props.fallback; // React.lazy caches a rejected import, so remounting can't recover a failed chunk load // (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can - const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message); - return ( -
-

Something went wrong rendering this view.

- -
- ); + const chunkFailure = /dynamically imported module|Loading chunk|error loading|Importing a module script failed/i.test(this.state.error.message); + return this.setState({ error: null })} />; } return this.props.children; } } + +function ErrorFallback({ chunkFailure, onRetry }: { chunkFailure: boolean; onRetry: () => void }) { + const { t } = useTranslation(); + return ( +
+

{t("errorBoundary.message")}

+ +
+ ); +} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index a3920fd..ee6efc8 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MultiSelectDropdown } from "./MultiSelectDropdown"; import { SearchBar, type SearchFieldOption } from "./SearchBar"; import { FilterSheet, FiltersButton } from "./FilterSheet"; @@ -10,11 +11,11 @@ interface FilterOption { label: string; } -const PACKET_SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "hash", label: "Hash" }, - { value: "path", label: "Path", disabled: true }, - { value: "payload", label: "Payload", disabled: true }, -]; +const PACKET_SEARCH_FIELDS = [ + { value: "hash", labelKey: "filters.field.hash" }, + { value: "path", labelKey: "filters.field.path" }, + { value: "payload", labelKey: "filters.field.payload", disabled: true }, +] as const; interface FilterBarProps { typeOptions: FilterOption[]; @@ -57,7 +58,9 @@ export function FilterBar({ onSearchFieldChange, onClear, }: FilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const searchFields: SearchFieldOption[] = PACKET_SEARCH_FIELDS.map(({ labelKey, ...f }) => ({ ...f, label: t(labelKey) })); const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect if (sheetOpen && !isMobile) setSheetOpen(false); @@ -75,19 +78,19 @@ export function FilterBar({ // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - - - + + + {scopeOptions.length > 0 && ( - + )} ); if (isMobile) { return ( -
- onSearchFieldChange(f as SearchField)} /> +
+ onSearchFieldChange(f as SearchField)} /> setSheetOpen(true)} /> {sheetOpen && ( setSheetOpen(false)} onClear={hasFilters ? onClear : undefined}> @@ -102,12 +105,12 @@ export function FilterBar({
onSearchFieldChange(f as SearchField)} /> @@ -122,7 +125,7 @@ export function FilterBar({ className="text-[11px] font-mono text-text-dim hover:text-danger px-1.5 py-0.5 cursor-pointer transition-colors" onClick={onClear} > - Clear + {t("common.clear")} )}
diff --git a/src/components/FilterSheet.tsx b/src/components/FilterSheet.tsx index 17ff5a9..30dfa9f 100644 --- a/src/components/FilterSheet.tsx +++ b/src/components/FilterSheet.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { BottomSheet } from "./BottomSheet"; import { CloseButton } from "./CloseButton"; // Mobile filter trigger: funnel + active count, styled like a dropdown trigger. export function FiltersButton({ activeCount, onClick }: { activeCount: number; onClick: () => void }) { + const { t } = useTranslation(); const active = activeCount > 0; return ( ) : ( @@ -59,7 +62,7 @@ export function FilterSheet({ onClose, onClear, children }: { onClick={onClose} className="text-xs font-mono font-medium text-primary border border-primary-dim bg-primary/6 rounded px-4 py-1.5 cursor-pointer transition-colors hover:bg-primary/10" > - Done + {t("common.done")}
diff --git a/src/components/InfoTip.tsx b/src/components/InfoTip.tsx new file mode 100644 index 0000000..4775853 --- /dev/null +++ b/src/components/InfoTip.tsx @@ -0,0 +1,23 @@ +import type { MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "./Tooltip"; + +// Small (i) marker that holds explanatory notes, keeping analytics pages to headings and numbers. +export function InfoTip({ text }: { text: string | (string | false | null | undefined)[] }) { + const { t } = useTranslation(); + const lines = (Array.isArray(text) ? text : [text]).filter((line): line is string => !!line); + // preventDefault stops a tip inside from toggling its
+ const stop = (e: MouseEvent) => e.preventDefault(); + return ( + {line})}> + + + ); +} diff --git a/src/components/InstanceBanner.tsx b/src/components/InstanceBanner.tsx new file mode 100644 index 0000000..b094246 --- /dev/null +++ b/src/components/InstanceBanner.tsx @@ -0,0 +1,22 @@ +// [label](url) or a bare URL; trailing punctuation and closing parens stay outside a bare link +const LINK_PATTERN = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s()]*[^\s().,;:!?])/g; + +// Deployment-wide notice set by VITE_BANNER, e.g. flagging the dev instance. +export function InstanceBanner({ text }: { text: string }) { + const trimmed = text.trim(); + if (!trimmed) return null; + const parts = []; + let last = 0; + for (const m of trimmed.matchAll(LINK_PATTERN)) { + parts.push(trimmed.slice(last, m.index)); + const href = m[2] ?? m[3]; + parts.push({m[1] ?? href}); + last = m.index + m[0].length; + } + parts.push(trimmed.slice(last)); + return ( +
+ {parts} +
+ ); +} diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..f606431 --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,76 @@ +import { useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { languages } from "../i18n"; +import { Dropdown } from "./Dropdown"; + +export function LanguagePicker() { + const { t, i18n } = useTranslation(); + const triggerRef = useRef(null); + const panelId = useId(); + const language = i18n.resolvedLanguage ?? "en"; + const selectedName = languages.find(({ code }) => code === language)?.name ?? language; + return ( + ( + + )} + > + {(close) => ( +
{ + if (event.key === "Escape") { + event.stopPropagation(); + close(); + triggerRef.current?.focus(); + } + }} + > + { close(); triggerRef.current?.focus(); }} /> +
+ )} +
+ ); +} + +export function LanguageOptions({ onPick }: { onPick?: () => void }) { + const { i18n } = useTranslation(); + const language = i18n.resolvedLanguage ?? "en"; + return ( + <> + {languages.map(({ code, name }) => ( + + ))} + + ); +} diff --git a/src/components/LoadingPill.tsx b/src/components/LoadingPill.tsx index d8ca1f0..acc672b 100644 --- a/src/components/LoadingPill.tsx +++ b/src/components/LoadingPill.tsx @@ -1,14 +1,20 @@ +import { useTranslation } from "react-i18next"; + +type LoadingNoun = "nodes" | "routes" | "packets" | "messages" | "observers"; + interface LoadingPillProps { loading: boolean; error?: boolean; count: number; // rows loaded so far - noun: string; // plural entity name, e.g. "nodes" / "observers" + noun: LoadingNoun; + label?: string; // optional complete localized phrase position?: string; // corner placement (the parent must be `relative`) } // Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while // pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle. -export function LoadingPill({ loading, error, count, noun, position = "bottom-3 left-3" }: LoadingPillProps) { +export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) { + const { t } = useTranslation(); if (!loading && !error) return null; const tone = loading ? "text-text-muted" : "text-danger"; const dot = loading ? "bg-primary animate-pulse" : "bg-danger"; @@ -18,11 +24,11 @@ export function LoadingPill({ loading, error, count, noun, position = "bottom-3 className={`absolute ${position} z-10 flex items-center gap-2 px-2.5 py-1 bg-bg-surface border border-border-subtle rounded-md font-mono text-[11px] ${tone} shadow-lg`} > - {loading - ? `Loading ${noun}… (${count})` + {label ?? (loading + ? t(`loadingPill.${noun}.loading`, { count }) : count > 0 - ? `Some ${noun} failed to load (${count} shown)` - : `Failed to load ${noun}`} + ? t(`loadingPill.${noun}.partial`, { count }) + : t(`loadingPill.${noun}.failed`))}
); } diff --git a/src/components/ModalOverlay.tsx b/src/components/ModalOverlay.tsx index 3babbf5..3363eae 100644 --- a/src/components/ModalOverlay.tsx +++ b/src/components/ModalOverlay.tsx @@ -1,12 +1,10 @@ import { useRef, type ReactNode } from "react"; import { useFocusTrap } from "../hooks/useFocusTrap"; -// Right-anchored modal: dims the surface, focuses and traps keyboard focus within the panel, and -// closes on a backdrop click — only when the press started there, so releasing a text selection over -// the backdrop doesn't close. Escape is left to the caller (some callers gate it on nested state). -// Pass `inactive` when another overlay is stacked on top so this one steps out of the modal/a11y -// path — it stops being an active modal and is hidden from assistive tech, while staying mounted so -// focus can return into it when the overlay above closes. +// Backdrop close fires only when the press started on the backdrop, so releasing a text +// selection there doesn't close it. Escape stops at this layer. +// `inactive` steps a stacked-under overlay out of the modal/a11y path while keeping it +// mounted, so focus can return to it when the overlay above closes. export function ModalOverlay({ label, onClose, inactive = false, children }: { label: string; onClose: () => void; @@ -26,8 +24,15 @@ export function ModalOverlay({ label, onClose, inactive = false, children }: { aria-label={label} aria-hidden={inactive || undefined} tabIndex={-1} + onKeyDown={(e) => { + if (e.key === "Escape" && !inactive) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} onMouseDown={(e) => { pressedBackdrop.current = e.target === e.currentTarget; }} - onClick={() => { if (pressedBackdrop.current) onClose(); }} + onClick={() => { if (!inactive && pressedBackdrop.current) onClose(); }} >
e.stopPropagation()}> {children} diff --git a/src/components/MultiSelectDropdown.tsx b/src/components/MultiSelectDropdown.tsx index 80c427b..88e23d5 100644 --- a/src/components/MultiSelectDropdown.tsx +++ b/src/components/MultiSelectDropdown.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { useClickOutside } from "../hooks/useClickOutside"; interface Option { @@ -19,6 +20,7 @@ interface MultiSelectDropdownProps { // checkbox dropdown with optional search filter export function MultiSelectDropdown({ label, options, selected, onChange, searchable, align = "left", fullWidth = false }: MultiSelectDropdownProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const ref = useRef(null); @@ -32,7 +34,9 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search useEffect(() => { if (!open) return; function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); + if (e.key !== "Escape") return; + e.stopPropagation(); // the filter sheet listens on window; only the dropdown should close + setOpen(false); } document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); @@ -92,7 +96,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search type="text" value={filter} onChange={(e) => setFilter(e.target.value)} - placeholder="Filter..." + placeholder={t("common.filterPlaceholder")} className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim" />
@@ -106,7 +110,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search }`} onClick={() => onChange(options.map((o) => o.value))} > - All + {t("common.all")} ·
@@ -158,7 +162,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search ); })} {filtered.length === 0 && ( -
No matches
+
{t("common.noMatches")}
)}
diff --git a/src/components/ScopeTag.tsx b/src/components/ScopeTag.tsx index ee02310..a0d78a1 100644 --- a/src/components/ScopeTag.tsx +++ b/src/components/ScopeTag.tsx @@ -1,10 +1,15 @@ import type { ReactNode } from "react"; +import { VARIANT_CLASSES } from "./badge-utils"; // Small secondary chip marking a transport scope, shared across packet rows, the analyzer, and the // trace/observer panels so scope labels read identically everywhere. -export function ScopeTag({ children, className }: { children: ReactNode; className?: string }) { +// `boxed` matches Badge's box for when it sits beside one; scope names stay case-sensitive, so no uppercase. +export function ScopeTag({ children, className, boxed }: { children: ReactNode; className?: string; boxed?: boolean }) { + const base = boxed + ? `inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider ${VARIANT_CLASSES.group}` + : "font-mono text-[11px] text-secondary tracking-wide bg-secondary/8 px-1.5 py-px rounded-sm"; return ( - + {children} ); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index ceaeef7..139a9cf 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "./Dropdown"; export interface SearchFieldOption { @@ -13,11 +14,14 @@ interface SearchBarProps { fields: SearchFieldOption[]; field: string; onFieldChange: (field: string) => void; + hideField?: boolean; + inputLabel?: string; } // debounced search input with field dropdown -export function SearchBar({ value, onChange, fields, field, onFieldChange }: SearchBarProps) { +export function SearchBar({ value, onChange, fields, field, onFieldChange, hideField = false, inputLabel }: SearchBarProps) { + const { t } = useTranslation(); const [localValue, setLocalValue] = useState(value); const [prevValue, setPrevValue] = useState(value); const debounceRef = useRef | null>(null); @@ -56,7 +60,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea return (
- ( @@ -92,7 +96,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea {f.label} ))} - + }
handleChange(e.target.value)} - placeholder={`Search by ${(currentField?.label ?? "").toLowerCase()}...`} - className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded-r-sm pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors" + placeholder={inputLabel ?? t("search.placeholder", { field: (currentField?.label ?? "").toLowerCase() })} + className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "rounded-sm text-[11px]" : "rounded-r-sm text-[11px]"}`} /> {localValue && (
No options
+
{t("common.noOptions")}
)}
)} diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 20a1be1..026555a 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -6,6 +6,7 @@ // line from src/App.tsx. Nothing else references it. import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { BeaconLogo } from "./BeaconLogo"; import { SKIP_SPLASH } from "../lib/constants"; @@ -14,6 +15,7 @@ const VISIBLE_MS = 2000; const FADE_MS = 400; export function SplashScreen() { + const { t } = useTranslation(); // Synchronous gate: decided before first paint so StrictMode's double-mount // (and any same-session reload) never re-shows it. const [render, setRender] = useState(() => { @@ -64,7 +66,7 @@ export function SplashScreen() { className="text-text-muted text-xs tracking-[0.12em]" style={{ fontFamily: "'Chakra Petch', sans-serif" }} > - MeshCore Network Analyzer + {t("splash.tagline")} diff --git a/src/components/Timestamp.tsx b/src/components/Timestamp.tsx index 4a1cab2..9face10 100644 --- a/src/components/Timestamp.tsx +++ b/src/components/Timestamp.tsx @@ -1,6 +1,7 @@ +import { useTranslation } from "react-i18next"; import { useTick } from "../hooks/useTick"; import { Tooltip } from "./Tooltip"; -import { timeAgoMs, formatAbsolute } from "../lib/formatters"; +import { timeAgoParts, formatAbsolute } from "../lib/formatters"; interface TimestampProps { value: number; // epoch ms @@ -14,9 +15,11 @@ interface TimestampProps { // attribute lagged ~1s); "absolute" mode flips the two. Self-refreshes via the shared ticker, so // callers don't sprinkle useTick() or build their own tooltips. export function Timestamp({ value, mode = "relative", ms, className }: TimestampProps) { + const { t } = useTranslation(); useTick(); // keep the relative label fresh - const relative = `${timeAgoMs(value)} ago`; + const { count, unit } = timeAgoParts(value); + const relative = t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); const absolute = formatAbsolute(value, { ms }); return ( diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 7d6c838..267f38b 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -4,7 +4,7 @@ import { useHasHover } from "../hooks/useMediaQuery"; // Portals to with fixed positioning so overflow parents (the data tables) can't clip it. // Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap. -export function Tooltip({ label, children, className = "" }: { label: ReactNode; children: ReactNode; className?: string }) { +export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) { const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -60,6 +60,8 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; onMouseEnter={hasHover ? show : undefined} onMouseLeave={hasHover ? hide : undefined} onClick={hasHover ? undefined : toggle} + onFocus={hasHover ? show : undefined} + onBlur={hasHover ? hide : undefined} className={`inline-flex ${className}`} > {children} @@ -69,7 +71,7 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; ref={tipRef} role="tooltip" style={{ left: pos.left, top: pos.top }} - className="fixed z-50 pointer-events-none whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg" + className={`fixed z-50 pointer-events-none ${wrap ? "max-w-xs whitespace-normal leading-relaxed" : "whitespace-nowrap"} rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg`} > {label} , diff --git a/src/components/action-button.ts b/src/components/action-button.ts new file mode 100644 index 0000000..b04ea43 --- /dev/null +++ b/src/components/action-button.ts @@ -0,0 +1,2 @@ +// Compact bordered action used inside panels and page toolbars. +export const ACTION_BUTTON_CLASS = "rounded border border-border px-2 py-1 font-mono text-[11px] text-primary hover:bg-bg-raised disabled:opacity-40 disabled:cursor-not-allowed"; diff --git a/src/components/badge-utils.ts b/src/components/badge-utils.ts index 78b2946..1ab83db 100644 --- a/src/components/badge-utils.ts +++ b/src/components/badge-utils.ts @@ -1,3 +1,5 @@ +import type { CopyState } from "../hooks/useCopyToClipboard"; + // variant styles for badge colors export type BadgeVariant = "advert" | "text" | "trace" | "ack" | "group" | "request" | "default" | "live" | "stale" | "offline"; @@ -15,6 +17,9 @@ export const VARIANT_CLASSES: Record = { offline: "bg-danger/8 text-danger border-danger/15", }; +// copy-button pill color per copy outcome +export const COPY_STATE_VARIANT: Record = { idle: "text", copied: "live", failed: "offline" }; + // maps MeshCore payload type codes to badge variants const PAYLOAD_TYPE_TO_VARIANT: Record = { diff --git a/src/features/InvestigationPanels.tsx b/src/features/InvestigationPanels.tsx new file mode 100644 index 0000000..5db6640 --- /dev/null +++ b/src/features/InvestigationPanels.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ModalOverlay } from "../components/ModalOverlay"; +import { NodeDetailPanel } from "./nodes/NodeDetailPanel"; +import { PacketAnalyzerDrawer } from "./packets/PacketAnalyzerDrawer"; +import { usePacketDetail } from "./packets/usePacketDetail"; +import { PacketPathMapModal } from "./map/PacketPathMapModal"; +import type { PacketDetail } from "../types/api"; + +export type InvestigationTarget = + | { kind: "node"; id: string } + | { kind: "packet"; hash: string; observationId?: number } + | { kind: "path"; detail: PacketDetail; selectedKey?: string }; + +export interface Investigation { + key: string; + target: InvestigationTarget; +} + +// Each panel stays mounted underneath the next one so its selection, scroll and trigger survive. +export function InvestigationPanel({ target, inactive, onClose, onOpen, onViewObserver, onViewOnMap }: { + target: InvestigationTarget; inactive: boolean; onClose: () => void; + onOpen: (target: InvestigationTarget) => void; onViewObserver: (id: string, packet?: { hash: string; observationId?: number }) => void; + onViewOnMap?: (nodeId: string, lat: number, lng: number) => void; +}) { + const { t } = useTranslation(); + const { data: detail, isLoading } = usePacketDetail(target.kind === "packet" ? target.hash : null); + const [selected, setSelected] = useState(target.kind === "packet" ? target.observationId ?? null : null); + const node = (id: string) => onOpen({ kind: "node", id }); + const packet = (hash: string, observationId?: number) => onOpen({ kind: "packet", hash, observationId }); + if (target.kind === "path") return ; + return + {target.kind === "node" ? ( + onViewObserver(id)} onViewNode={node} onAnalyzePacket={packet} onViewOnMap={onViewOnMap && ((lat, lng) => onViewOnMap(target.id, lat, lng))} /> + ) : ( + onViewObserver(id, { hash: target.hash, observationId: observationId ?? selected ?? undefined })} onViewNode={node} + onViewPath={key => { if (detail) onOpen({ kind: "path", detail, selectedKey: key }); }} + /> + )} + ; +} diff --git a/src/features/channels/ChannelFilterBar.tsx b/src/features/channels/ChannelFilterBar.tsx index d091267..dbcf9cb 100644 --- a/src/features/channels/ChannelFilterBar.tsx +++ b/src/features/channels/ChannelFilterBar.tsx @@ -1,25 +1,11 @@ import { useState } from "react"; -import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; +import { useTranslation } from "react-i18next"; +import { SearchBar } from "../../components/SearchBar"; import { SelectDropdown } from "../../components/SelectDropdown"; import { FilterSheet, FiltersButton } from "../../components/FilterSheet"; import { useIsMobile } from "../../hooks/useMediaQuery"; import type { ChannelKeyFilter, ChannelHashtagFilter } from "./channel-filters"; -const SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "name", label: "Name" }, - { value: "hash", label: "Hash" }, -]; - -const KEY_OPTIONS = [ - { value: "known", label: "Known" }, - { value: "unknown", label: "Unknown" }, -]; - -const HASHTAG_OPTIONS = [ - { value: "true", label: "Yes" }, - { value: "false", label: "No" }, -]; - interface ChannelFilterBarProps { search: string; onSearchChange: (v: string) => void; @@ -41,7 +27,20 @@ export function ChannelFilterBar({ hashtagFilter, onHashtagChange, }: ChannelFilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const searchFields = [ + { value: "name", label: t("channels.fieldName") }, + { value: "hash", label: t("channels.fieldHash") }, + ]; + const keyOptions = [ + { value: "known", label: t("channels.keyKnownOption") }, + { value: "unknown", label: t("channels.keyUnknownOption") }, + ]; + const hashtagOptions = [ + { value: "true", label: t("channels.yes") }, + { value: "false", label: t("channels.no") }, + ]; const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect if (sheetOpen && !isMobile) setSheetOpen(false); @@ -55,15 +54,15 @@ export function ChannelFilterBar({ // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} /> - onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} /> + onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} /> + onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} /> ); if (isMobile) { return ( -
- +
+ setSheetOpen(true)} /> {sheetOpen && ( setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}> @@ -78,12 +77,12 @@ export function ChannelFilterBar({
diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index d215e5c..9b293f9 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,6 +1,9 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getChannels } from "../../api/client"; +import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit"; +import { LIVE_BUFFER_CAP } from "../../lib/constants"; import { useRegion } from "../../hooks/useRegion"; import { useIsMobile } from "../../hooks/useMediaQuery"; import { useWsChannelMessageHandler } from "../../hooks/useWsHandlers"; @@ -9,7 +12,7 @@ import { ChannelSidebar } from "./ChannelSidebar"; import { ChannelFilterBar } from "./ChannelFilterBar"; import { MessagePanel } from "./MessagePanel"; import { filterChannels, type ChannelKeyFilter, type ChannelHashtagFilter } from "./channel-filters"; -import type { ChannelMessage, ChannelSummary } from "./types"; +import type { ChannelMessage, ChannelPage, ChannelSummary } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; @@ -18,44 +21,146 @@ interface ChannelListProps { onAnalyze: (hash: string | null) => void; } +// keyed channels are few, so they load in big pages; the rest page on demand +const KEYED_PAGE_SIZE = 200; +const nextChannelPage = (last: ChannelPage) => last.hasMore ? last.nextPageCursor ?? last.nextCursor ?? undefined : undefined; + +function appendMessages(old: InfiniteData> | undefined, messages: ChannelMessage[]) { + if (!old) return old; + const known = new Set(old.pages.flatMap((page) => page.items.map((message) => message.packetHash))); + const added = messages.filter((message) => !known.has(message.packetHash)); + if (!added.length) return old; + return { ...old, pages: old.pages.map((page, index) => index === 0 ? { ...page, items: [...page.items, ...added] } : page) }; +} + export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { - const { iatas, regionKey } = useRegion(); + const { t } = useTranslation(); + const { iatas, regionKey, isResolved } = useRegion(); + const regionPending = isResolved === false; const isMobile = useIsMobile(); - const [selectedId, setSelectedId] = useState(null); + // Keep the open channel available when a directory page is evicted or refreshed. + const [selection, setSelection] = useState(null); + const selectedId = selection?.id ?? null; const [heardCounts, setHeardCounts] = useState>({}); + const [messageScope, setMessageScope] = useState(""); const [search, setSearch] = useState(""); const [searchField, setSearchField] = useState("name"); const [keyFilter, setKeyFilter] = useState(""); const [hashtagFilter, setHashtagFilter] = useState(""); + // tied to the region it was opened in, so a region switch never fetches the new region's other channels + const [othersRegion, setOthersRegion] = useState(null); + const showOthers = othersRegion === regionKey; const queryClient = useQueryClient(); + const refreshPending = useRef(false); + const pendingMessages = useRef(new Map()); + const messagesOverflowed = useRef(false); + const messageKey = useMemo(() => ["channel-messages", selectedId, regionKey, messageScope], [selectedId, regionKey, messageScope]); + const messageFetching = useIsFetching({ queryKey: messageKey, exact: true }); + + const flushPending = useCallback(() => { + if (queryClient.isFetching({ queryKey: messageKey, exact: true })) return; + if (messagesOverflowed.current) { + if (isRateLimited()) return; + messagesOverflowed.current = false; + pendingMessages.current.clear(); + void queryClient.invalidateQueries({ queryKey: messageKey, exact: true }); + } else if (pendingMessages.current.size && queryClient.getQueryData(messageKey)) { + // Merge live arrivals after history settles so its older snapshot cannot erase them. + const queued = [...pendingMessages.current.values()]; + pendingMessages.current.clear(); + queryClient.setQueryData>>(messageKey, (old) => appendMessages(old, queued)); + } + }, [messageKey, queryClient]); + + useEffect(() => { + flushPending(); + }, [messageFetching, flushPending]); + + // An overflow parked behind a 429 would otherwise sit until the user switches channel. + useEffect(() => subscribeRateLimit(flushPending), [flushPending]); const prevRegion = useRef(regionKey); useEffect(() => { if (prevRegion.current !== regionKey) { prevRegion.current = regionKey; - setSelectedId(null); + refreshPending.current = false; + pendingMessages.current.clear(); + messagesOverflowed.current = false; + setSelection(null); setHeardCounts({}); + setMessageScope(""); setSearch(""); setKeyFilter(""); setHashtagFilter(""); } }, [regionKey]); - const handleSelect = useCallback((id: number) => { - setSelectedId(id); - setHeardCounts({}); - }, []); - - const { data: channels, isLoading } = useQuery({ - queryKey: ["channels", regionKey], - queryFn: () => getChannels({ iatas }), + const keyedKey = useMemo(() => ["channels", regionKey, "keyed"], [regionKey]); + const keyed = useInfiniteQuery({ + queryKey: keyedKey, + queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: true, limit: KEYED_PAGE_SIZE }), + initialPageParam: undefined as number | string | undefined, + getNextPageParam: nextChannelPage, staleTime: 60_000, + enabled: !regionPending, + }); + const keyedLoading = keyed.isLoading || regionPending; + const others = useInfiniteQuery({ + queryKey: ["channels", regionKey, "other"], + queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: false }), + initialPageParam: undefined as number | string | undefined, + getNextPageParam: nextChannelPage, + staleTime: 60_000, + enabled: !regionPending && (showOthers || keyFilter === "unknown"), }); - // "Public" pinned first, then named channels, then unnamed by most recent + // A server without the keyKnown filter mixes unkeyed channels in; don't page through all of them. + const keyedUnfiltered = keyed.data?.pages.some((p) => p.items.some((ch) => !ch.keyKnown)) ?? false; + const { hasNextPage: keyedHasMore, isFetching: keyedFetching, isError: keyedError, fetchNextPage: fetchKeyed } = keyed; + useEffect(() => { + if (keyedHasMore && !keyedFetching && !keyedError && !keyedUnfiltered) void fetchKeyed(); + }, [keyedHasMore, keyedFetching, keyedError, keyedUnfiltered, fetchKeyed]); + + useEffect(() => { + if (!keyedFetching && refreshPending.current) { + refreshPending.current = false; + void queryClient.resetQueries({ queryKey: keyedKey, exact: true }); + } + }, [keyedFetching, queryClient, keyedKey]); + + const channels = useMemo(() => { + const byId = new Map(); + for (const page of keyed.data?.pages ?? []) { + for (const channel of page.items) { + if (channel.keyKnown && !byId.has(channel.id)) byId.set(channel.id, channel); + } + } + for (const page of others.data?.pages ?? []) { + for (const channel of page.items) { + if (!byId.has(channel.id)) byId.set(channel.id, channel); + } + } + return [...byId.values()]; + }, [keyed.data, others.data]); + + const handleSelect = useCallback((id: number) => { + pendingMessages.current.clear(); + messagesOverflowed.current = false; + setSelection(channels.find((ch) => ch.id === id) ?? null); + setHeardCounts({}); + }, [channels]); + + const handleScopeChange = useCallback((scope: string) => { + pendingMessages.current.clear(); + messagesOverflowed.current = false; + setMessageScope(scope); + }, []); + + // keyed first; within each, "Public" pinned first, then named channels, then unnamed by most recent const sortedChannels = useMemo( () => - [...(channels ?? [])].sort((a, b) => { + [...channels].sort((a, b) => { + if (a.keyKnown !== b.keyKnown) return a.keyKnown ? -1 : 1; const aPub = a.name === "Public" ? 1 : 0; const bPub = b.name === "Public" ? 1 : 0; if (aPub !== bPub) return bPub - aPub; @@ -71,46 +176,50 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { [sortedChannels, search, searchField, keyFilter, hashtagFilter], ); - // resolve against the full list so a selected channel keeps showing even when filtered out - const selectedChannel = sortedChannels.find((ch) => ch.id === selectedId) ?? null; + const selectedChannel = channels.find((ch) => ch.id === selectedId) ?? selection; const handleChannelMessage = useCallback( (data: ChannelMessage) => { - // bump lastSeen, or refetch the list if this is a channel we haven't seen yet - queryClient.setQueryData(["channels", regionKey], (old) => { - if (!old) return old; - const idx = old.findIndex((ch) => ch.channelHash === data.channelHash); - if (idx === -1) { - queryClient.invalidateQueries({ queryKey: ["channels", regionKey] }); - return old; - } - const updated = [...old]; - updated[idx] = { ...updated[idx]!, lastSeen: data.sentAt }; - return updated; - }); + const listKey = ["channels", regionKey]; + const cachedChannels = queryClient.getQueriesData>({ queryKey: listKey }) + .flatMap(([, cached]) => cached?.pages.flatMap((p) => p.items) ?? []); + const matchesChannel = (ch: ChannelSummary) => data.channelId !== undefined ? ch.id === data.channelId : ch.channelHash === data.channelHash; + const known = cachedChannels.find(matchesChannel); + if (known) { + // Display timestamps may change; the server's page cursors must not. + queryClient.setQueriesData>({ queryKey: listKey }, (old) => old && ({ + ...old, + pages: old.pages.map((p) => ({ ...p, items: p.items.map((ch) => ch.id === known.id + ? { ...ch, lastSeen: Math.max(ch.lastSeen, data.sentAt) } : ch) })), + })); + } else if (!isRateLimited()) { + // A decrypted message means a keyed channel; refetch that list from its first page. + if (queryClient.isFetching({ queryKey: keyedKey, exact: true })) refreshPending.current = true; + else void queryClient.resetQueries({ queryKey: keyedKey, exact: true }); + } - // use cache directly to avoid stale closure over selectedChannel - const cached = queryClient.getQueryData(["channels", regionKey]); - const selected = cached?.find((ch) => ch.id === selectedId); - if (selected && data.channelHash === selected.channelHash) { - // same message, multiple observer paths — count the reach + const selected = cachedChannels.find((ch) => ch.id === selectedId) ?? selection; + if (selected && matchesChannel(selected) && (!messageScope || data.scope === messageScope)) { + if (queryClient.isFetching({ queryKey: messageKey, exact: true }) || !queryClient.getQueryData(messageKey)) { + if (pendingMessages.current.size >= LIVE_BUFFER_CAP && !pendingMessages.current.has(data.packetHash)) { + messagesOverflowed.current = true; + } else pendingMessages.current.set(data.packetHash, data); + } + // A fresh arrival is the only signal left once idle-and-overflowed, so try recovery here too. + flushPending(); + // The WS event has no retained observation total; repeats do not add observers. setHeardCounts((prev) => ({ ...prev, - [data.packetHash]: (prev[data.packetHash] ?? 0) + 1, + [data.packetHash]: Math.max(prev[data.packetHash] ?? 0, data.observationCount ?? 1), })); - // append to the newest InfiniteData page; MessagePanel re-sorts by sentAt, so the page is arbitrary + // Append to the retained newest page; browsing older history must not evict it. queryClient.setQueryData>>( - ["channel-messages", selectedId, regionKey], - (old) => { - if (!old) return old; - if (old.pages.some((p) => p.items.some((msg) => msg.packetHash === data.packetHash))) return old; - const pages = old.pages.map((p, i) => (i === 0 ? { ...p, items: [...p.items, data] } : p)); - return { ...old, pages }; - }, + messageKey, + (old) => appendMessages(old, [data]), ); } }, - [queryClient, selectedId, regionKey], + [queryClient, selectedId, selection, regionKey, keyedKey, messageScope, messageKey, flushPending], ); useWsChannelMessageHandler(wsManager, handleChannelMessage); @@ -135,7 +244,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
{showList && (
- {isLoading ? ( + {keyedLoading ? ( ) : ( )} + {!keyedLoading && filteredChannels.length === 0 && ( +

{t("channels.noMatches")}

+ )} + {(keyed.isError || others.isError) &&

{t("channels.loadError")}

} + {keyed.isError ? ( + void keyed.refetch()}> + {keyed.isFetching ? t("channels.loadingChannels") : t("channels.retryChannels")} + + ) : !others.isEnabled ? ( + !keyedLoading && setOthersRegion(regionKey)}>{t("channels.loadOthers")} + ) : (others.hasNextPage || others.isError) && ( + void (others.hasNextPage ? others.fetchNextPage() : others.refetch())}> + {others.isFetching ? t("channels.loadingChannels") : others.isError ? t("channels.retryChannels") : t("channels.loadMore")} + + )}
)} {(!isMobile || selectedChannel !== null) && ( @@ -152,11 +276,26 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { heardCounts={heardCounts} iatas={iatas} regionKey={regionKey} + scope={messageScope} + onScopeChange={handleScopeChange} onAnalyze={onAnalyze} - onBack={isMobile ? () => setSelectedId(null) : undefined} + onBack={isMobile ? () => setSelection(null) : undefined} /> )}
); } + +function PagerButton({ disabled, onClick, children }: { disabled?: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ); +} diff --git a/src/features/channels/ChannelSidebar.tsx b/src/features/channels/ChannelSidebar.tsx index 9511ecd..2ec184c 100644 --- a/src/features/channels/ChannelSidebar.tsx +++ b/src/features/channels/ChannelSidebar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { channelDisplayName } from "./types"; @@ -10,6 +11,7 @@ interface ChannelSidebarProps { } export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSidebarProps) { + const { t } = useTranslation(); return (
@@ -33,11 +35,11 @@ export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSideba
{ch.keyKnown ? ( - key + {t("channels.key")} ) : ( - no key + {t("channelMessages.noKey")} )} - {ch.isHashtag && hashtag} + {ch.isHashtag && {t("channelMessages.hashtag")}}
); diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index 1bc0a53..c3cd34c 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -1,9 +1,13 @@ import { useMemo, useRef, useLayoutEffect, useState, useCallback } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { getChannelMessagesPage } from "../../api/client"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { LoadingPill } from "../../components/LoadingPill"; +import { ScopeTag } from "../../components/ScopeTag"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { useScopes } from "../../hooks/useScopes"; import { channelDisplayName } from "./types"; import type { ChannelSummary, ChannelMessage } from "./types"; @@ -22,21 +26,30 @@ function senderColor(name: string): string { return SENDER_COLORS[Math.abs(h) % SENDER_COLORS.length] ?? "text-primary"; } -// keyed on packetHash by the caller — live WS messages carry no id (REST ones do) +// Packet hash also identifies messages delivered by older servers without a live ID. function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heardCount?: number; onAnalyze?: (hash: string) => void }) { + const { t } = useTranslation(); // REST carries the server-side total; the live WS counter augments it during the session const reach = Math.max(msg.observationCount ?? 0, heardCount ?? 0); return (
onAnalyze(msg.packetHash) : undefined} > -
- +
+ {msg.senderName} - {reach > 0 && ×{reach}} + {reach > 0 && + ×{reach}{t("channelMessages.heardTimes", { count: reach })} + } + {/* Unscoped is the default, so only a named or unresolved scope earns a chip. */} + {msg.scope ? {msg.scope} + : msg.scopeStatus === "unknown" && {t("channelMessages.unknownScope")}} + {onAnalyze && }
{msg.content}
@@ -51,20 +64,30 @@ interface MessagePanelProps { onAnalyze?: (packetHash: string) => void; // mobile-only: renders a back button, since here the panel replaces the channel list onBack?: () => void; + scope?: string; + onScopeChange?: (scope: string) => void; } -export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack }: MessagePanelProps) { - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ["channel-messages", channel?.id, regionKey], - queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50 }), - getNextPageParam: (last) => last.nextCursor ?? undefined, +export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack, scope = "", onScopeChange }: MessagePanelProps) { + const { t } = useTranslation(); + const scopeNames = useScopes(scope); + const { data, isLoading, isError, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["channel-messages", channel?.id, regionKey, scope], + queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50, scope }), + getNextPageParam: (last) => last.hasMore ? last.nextCursor ?? undefined : undefined, initialPageParam: undefined as number | undefined, enabled: channel !== null, staleTime: 30_000, }); // flatten order is irrelevant — the ascending sort below restores chat order from newest-first pages - const messages = useMemo(() => data?.pages.flatMap((p) => p.items), [data]); + const messages = useMemo(() => { + const unique = new Map(); + for (const page of data?.pages ?? []) { + for (const message of page.items) if (!unique.has(message.packetHash)) unique.set(message.packetHash, message); + } + return [...unique.values()]; + }, [data]); const sorted = useMemo( () => [...(messages ?? [])].sort((a, b) => a.sentAt - b.sentAt), @@ -74,7 +97,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const bottomRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(null); - const scrollAnchor = useRef<{ channelId?: number; count: number }>({ count: 0 }); + const viewKey = `${channel?.id ?? ""}:${scope}`; + const scrollAnchor = useRef<{ view?: string; count: number }>({ count: 0 }); // pre-prepend scroll metrics, captured while a load-older fetch is in flight const prepend = useRef<{ pending: boolean; prevHeight: number; prevTop: number }>({ pending: false, @@ -83,9 +107,9 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze }); // reset scroll tracking on channel switch — adjust state during render, not in an effect - const [prevChannelId, setPrevChannelId] = useState(channel?.id); - if (prevChannelId !== channel?.id) { - setPrevChannelId(channel?.id); + const [prevView, setPrevView] = useState(viewKey); + if (prevView !== viewKey) { + setPrevView(viewKey); setUserScrolled(false); } @@ -93,10 +117,10 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const el = scrollContainerRef.current; const anchor = scrollAnchor.current; - if (anchor.channelId !== channel?.id) { + if (anchor.view !== viewKey) { // first batch for this channel — jump to the bottom once it lands if (isLoading) return; - anchor.channelId = channel?.id; + anchor.view = viewKey; anchor.count = sorted.length; prepend.current = { pending: false, prevHeight: 0, prevTop: 0 }; if (el) el.scrollTop = el.scrollHeight; @@ -115,7 +139,14 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down } anchor.count = sorted.length; - }, [sorted.length, channel?.id, isLoading, userScrolled]); + }, [sorted.length, viewKey, isLoading, userScrolled]); + + const loadOlder = useCallback(() => { + if (!hasNextPage || isFetching) return; + const el = scrollContainerRef.current; + prepend.current = { pending: true, prevHeight: el?.scrollHeight ?? 0, prevTop: el?.scrollTop ?? 0 }; + void fetchNextPage(); + }, [hasNextPage, isFetching, fetchNextPage]); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; @@ -123,16 +154,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; setUserScrolled(!atBottom); // near the top: pull the next older page, capturing metrics so the prepend can hold position - if (el.scrollTop < 40 && hasNextPage && !isFetchingNextPage) { - prepend.current = { pending: true, prevHeight: el.scrollHeight, prevTop: el.scrollTop }; - fetchNextPage(); - } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + if (el.scrollTop < 40) loadOlder(); + }, [loadOlder]); if (!channel) { return (
- Select a channel + {t("channelMessages.select")}
); } @@ -145,7 +173,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
-
+
+ {onScopeChange && ({ value: name, label: name }))} align="right" />} {channel.keyKnown ? ( - key known + {t("channelMessages.keyKnown")} ) : ( - no key + {t("channelMessages.noKey")} )} - {channel.isHashtag && hashtag} + {channel.isHashtag && {t("channelMessages.hashtag")}}
{!channel.keyKnown && (
- Key not known, messages may not be decrypted + {t("channelMessages.keyMissing")}
)} + {isError &&
{t("channelMessages.error")}
} +
{isLoading ? (
- Loading... + {t("channelMessages.loading")}
) : messages && messages.length > 0 ? (
+ {hasNextPage && } {sorted.map((msg) => ( ))} @@ -192,13 +225,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
) : (
- No messages + {t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
)}
{/* floats over the panel (not the scroll area) so older-page fetches don't shift it */} - +
); } diff --git a/src/features/channels/channel-filters.ts b/src/features/channels/channel-filters.ts index 9bb13fe..ad37ab3 100644 --- a/src/features/channels/channel-filters.ts +++ b/src/features/channels/channel-filters.ts @@ -11,7 +11,7 @@ export interface ChannelFilters { hashtagFilter: ChannelHashtagFilter; } -// client-side channel-list filtering (the list is fully loaded; the backend has no text search) +// Filter loaded directory pages; the backend has no text-search parameter. export function filterChannels(channels: ChannelSummary[], filters: ChannelFilters): ChannelSummary[] { const q = filters.search.trim().toLowerCase(); return channels.filter((ch) => { diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index 6d7e9af..cfc1a4b 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -1,3 +1,9 @@ +import type { CursorPage } from "../../types/api"; + +export interface ChannelPage extends CursorPage { + nextPageCursor?: string; // opaque precise cursor; older servers only return nextCursor +} + export interface ChannelSummary { id: number; name: string | null; @@ -27,4 +33,7 @@ export interface ChannelMessage { content: string; sentAt: number; // epoch ms, from the sender's embedded timestamp observationCount?: number; + channelId?: number; // supplied by live events; channel history already has an ID-specific endpoint + scope?: string | null; // absent on older servers, null when no name was recorded + scopeStatus?: "matched" | "unscoped" | "unknown" | "unavailable"; } diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 8f86744..432267b 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MapStyleSwitcher } from "./MapStyleSwitcher"; import { SegmentedControl } from "./SegmentedControl"; import { NODE_TYPE_FILTER_OPTIONS, type NeighborLinesMode } from "./types"; @@ -9,30 +10,16 @@ import { useIsMobile } from "../../hooks/useMediaQuery"; // Open/closed state persists across sessions; no click-outside dismiss, so it stays open while you pan. const OPEN_STORAGE_KEY = "beacon-map-settings-open"; -const TYPE_OPTIONS = [{ value: "", label: "All" }, ...NODE_TYPE_FILTER_OPTIONS]; -const CLUSTER_OPTIONS = [ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, -]; -const NEIGHBOR_OPTIONS = [ - { value: "on", label: "On" }, - { value: "selected", label: "Selected" }, - { value: "off", label: "Off" }, -]; -const BORDER_OPTIONS = [ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, -]; - // Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme. function BorderLegend() { + const { t } = useTranslation(); return (
- IATA region outline + {t("map.areaOutline")}
); } @@ -40,9 +27,10 @@ function BorderLegend() { // Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors // (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. function NeighborLegend() { + const { t } = useTranslation(); return (
-
Observations
+
{t("map.observations")}
20 150+
-
fainter = heard longer ago
+
{t("map.fainter")}
); } @@ -85,7 +73,15 @@ export function MapSettingsPanel({ onBordersChange, buildShareParams, }: MapSettingsPanelProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const on = { value: "on", label: t("map.on") }; + const off = { value: "off", label: t("map.off") }; + const typeOptions = [ + { value: "", label: t("map.all") }, + ...NODE_TYPE_FILTER_OPTIONS.map((o) => ({ value: o.value, label: t(`nodeTypes.${o.value}`, { defaultValue: o.label }) })), + ]; + const neighborOptions = [on, { value: "selected", label: t("map.selected") }, off]; // collapsed by default on mobile (the card would cover the map); a saved preference still wins const [open, setOpen] = useState(() => { const stored = localStorage.getItem(OPEN_STORAGE_KEY); @@ -117,48 +113,48 @@ export function MapSettingsPanel({ - Map Settings + {t("map.settings")} {open ? "▾" : "▸"} {open && (
-
+
-
+
-
+
onClusteredChange(v === "on")} className="w-full" />
-
+
onNeighborLinesChange(v as NeighborLinesMode)} className="w-full" /> {neighborLines === "selected" && }
-
+
onBordersChange(v === "on")} className="w-full" @@ -168,8 +164,8 @@ export function MapSettingsPanel({
diff --git a/src/features/map/MapStyleSwitcher.tsx b/src/features/map/MapStyleSwitcher.tsx index 0b8fee3..e80973c 100644 --- a/src/features/map/MapStyleSwitcher.tsx +++ b/src/features/map/MapStyleSwitcher.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MAP_STYLES } from "./types"; import { SegmentedControl } from "./SegmentedControl"; @@ -8,10 +9,11 @@ interface MapStyleSwitcherProps { } export function MapStyleSwitcher({ styleId, onChange, className }: MapStyleSwitcherProps) { + const { t } = useTranslation(); return ( ({ value: s.id, label: s.name }))} + ariaLabel={t("map.style")} + options={MAP_STYLES.map((s) => ({ value: s.id, label: t(`map.styles.${s.id}`, { defaultValue: s.name }) }))} value={styleId} onChange={onChange} className={className} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 7871077..282aa13 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import "maplibre-gl/dist/maplibre-gl.css"; import { useMapLibre } from "./useMapLibre"; @@ -12,12 +13,13 @@ import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; -import { parseMapView, buildMapParams, type MapViewSnapshot } from "./map-url"; +import { parseMapView, parseNodeType, buildMapParams, type MapViewSnapshot } from "./map-url"; import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, MAP_BORDERS_STORAGE_KEY, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types"; import type { FeatureCollection, LineString } from "geojson"; import { EmptyState } from "../../components/EmptyState"; import { LoadingPill } from "../../components/LoadingPill"; -import { useRegion } from "../../hooks/useRegion"; +import { useRegion, useRegionSelection } from "../../hooks/useRegion"; +import { serializeSelection } from "../../hooks/region-selection"; import { useTheme } from "../../hooks/useTheme"; import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers"; import { getIatas, getNodeNeighbors } from "../../api/client"; @@ -37,6 +39,7 @@ interface MapViewProps { } export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProps) { + const { t } = useTranslation(); // Deep-link params, read once at mount (like the region's ?iata seed). Each setting below is seeded // URL -> localStorage -> default; the URL wins for this session but is never written back to // localStorage, so a shared link can't clobber the visitor's saved prefs. @@ -61,7 +64,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp }, []); const [typeFilter, setTypeFilter] = useState( - () => urlView.nodeType ?? localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY) ?? "", + () => urlView.nodeType ?? parseNodeType(localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY)) ?? "", ); // "" = All const handleTypeChange = useCallback((t: string) => { setTypeFilter(t); @@ -102,7 +105,9 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp [urlView], ); - const { iatas: selectedIatas, regionKey } = useRegion(); + const { iatas: selectedIatas, regionKey, isResolved } = useRegion(); + const { selection } = useRegionSelection(); + const regionPending = isResolved === false; const queryClient = useQueryClient(); // marker/cluster icons are canvas-drawn from the active --palette-* vars, so useMapNodes has to // re-register them whenever the palette changes: on a theme switch, and once on load when the async @@ -114,7 +119,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp // nodes for the selected region (its own key, independent of the Nodes-table filters/page cap). // Pages in 50 at a time so the map fills batch by batch; nodesKey matches the hook's query key. const nodesKey = useMemo(() => ["map-nodes", regionKey], [regionKey]); - const { nodes, loadedCount, isPaging, isError: nodesError } = useMapNodesData(selectedIatas, regionKey); + const { nodes, loadedCount, isPaging, isError: nodesError } = useMapNodesData(selectedIatas, regionKey, { enabled: !regionPending }); // patch-or-insert the live update into the paged node cache (the shared helper preserves refs // when nothing changed, so a same-values re-advert doesn't trigger a full map repaint); brand-new @@ -167,11 +172,11 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp ); // IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no - // bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing). + // bounds from the API, so their member IATAs stand in for the extent. const fitPoints = useMemo<[number, number][] | null>(() => { const withCoords = (iatas ?? []).filter((i) => i.lat != null && i.lon != null); if (withCoords.length === 0) return null; - const scope = selectedIatas && selectedIatas.length > 0 ? new Set(selectedIatas) : null; + const scope = selectedIatas ? new Set(selectedIatas) : null; const chosen = scope ? withCoords.filter((i) => scope.has(i.iata)) : withCoords; return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null; }, [iatas, selectedIatas]); @@ -180,11 +185,11 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp // which resolves to a 204 and is dropped). Only fetched while the layer is toggled on. const borderIatas = useMemo(() => { const all = (iatas ?? []).map((i) => i.iata); - return selectedIatas && selectedIatas.length > 0 ? all.filter((c) => selectedIatas.includes(c)) : all; + return selectedIatas ? all.filter((c) => selectedIatas.includes(c)) : all; }, [iatas, selectedIatas]); const borderData = useMapBordersData(borderIatas, borders); - const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera); + const { containerRef, mapRef, isReady, error, nodeIconResolverRef } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera, serializeSelection(selection)); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome // Snapshot the current view (live camera + settings) into deep-link params for the copy button. @@ -205,7 +210,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp return { tab: "Map", ...buildMapParams(snapshot) }; }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); - useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); + useMapNodes(mapRef, nodeIconResolverRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapBorders(mapRef, isReady, borderData, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); @@ -231,11 +236,11 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp /> setPacketFlow((v) => !v)} /> {/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */} - + {error && ( // z-20 so the failure overlay covers the settings card (z-10) instead of it floating on top
- +
)}
diff --git a/src/features/map/PacketFlowButton.tsx b/src/features/map/PacketFlowButton.tsx index 61862cd..ea6f4ce 100644 --- a/src/features/map/PacketFlowButton.tsx +++ b/src/features/map/PacketFlowButton.tsx @@ -1,22 +1,27 @@ +import { useTranslation } from "react-i18next"; + interface PacketFlowButtonProps { active: boolean; onToggle: () => void; } -// Floating play/stop control for the live packet-flow animation. Off by default; when live it shows -// a pulsing dot and accent styling. Bottom-center, clear of the corner map controls. +// Floating play/stop control for the live packet-flow animation. Off by default; keeps an accent +// border either way so it reads as a control, not decoration. Bottom-center, clear of the corner +// map controls. Labelled "Live Map" to distinguish it from the header's LIVE websocket badge. export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) { + const { t } = useTranslation(); + const label = active ? t("map.stopFlow") : t("map.playFlow"); return ( ); } diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 87d6cea..ebf3507 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -1,8 +1,9 @@ // src/features/map/PacketPathMap.tsx import { useEffect, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; +import { Map as MapLibreMap, NavigationControl, AttributionControl, Popup, LngLatBounds } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import "./maplibre-worker"; import type { - Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, CircleLayerSpecification, @@ -12,6 +13,7 @@ import type { Point } from "geojson"; import type { PacketPath } from "./packet-path"; import { packetPathsToFeatures } from "./packet-path"; import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types"; +import { mapLocale } from "./useMapLibre"; // Private ids — this map instance is dedicated to the popup, so they can't collide with the main map. const LINE_SOURCE = "pp-lines"; @@ -38,16 +40,17 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { // build the map once (styleId is read at creation; the popup doesn't hot-swap basemaps) useEffect(() => { if (mapRef.current || !containerRef.current) return; - const map = new maplibregl.Map({ + const map = new MapLibreMap({ container: containerRef.current, style: resolveMapStyle(styleId).url, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, + locale: mapLocale(), }); mapRef.current = map; - map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); - map.addControl(new maplibregl.AttributionControl({ compact: true })); + map.addControl(new NavigationControl({ showCompass: false }), "top-right"); + map.addControl(new AttributionControl({ compact: true })); // start the attribution as a bare (i) instead of the wide expanded bar it pops open on load — // on mobile that bar overlaps the observer list beneath the map (same trick as useMapLibre) const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib"); @@ -69,7 +72,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { coords.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; el.append(name, coords); // a fresh popup per click; closeOnClick removes the previous one on the same click - new maplibregl.Popup({ closeButton: false, closeOnClick: true, offset: 10 }) + new Popup({ closeButton: false, closeOnClick: true, offset: 10 }) .setLngLat([lng, lat]).setDOMContent(el).addTo(map); }); const onLoad = () => setReady(true); @@ -111,7 +114,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { if (!map.getLayer(NODE_LABEL_LAYER)) { map.addLayer({ id: NODE_LABEL_LAYER, type: "symbol", source: NODE_SOURCE, - layout: { "text-field": ["get", "label"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, + layout: { "text-field": ["get", "label"], "text-font": ["Noto Sans Regular"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, paint: { "text-color": paletteVar("--palette-text-bright", "#e5e7eb"), "text-halo-color": paletteVar("--palette-bg-base", "#0a0a0a"), @@ -124,7 +127,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { (map.getSource(NODE_SOURCE) as GeoJSONSource).setData(points); if (bounds.length) { - const b = bounds.reduce((acc, p) => acc.extend(p), new maplibregl.LngLatBounds(bounds[0], bounds[0])); + const b = bounds.reduce((acc, p) => acc.extend(p), new LngLatBounds(bounds[0], bounds[0])); map.fitBounds(b, { padding: 60, maxZoom: IATA_ZOOM }); } }, [ready, paths, selectedKey]); diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index e9c5d4b..c17a675 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -1,13 +1,16 @@ -import { useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; import type { PacketDetail } from "../../types/api"; import { ModalOverlay } from "../../components/ModalOverlay"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import { formatPropagation } from "../../lib/formatters"; import { buildPacketPaths } from "./packet-path"; -import { PacketPathMap } from "./PacketPathMap"; import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; +const PacketPathMap = lazy(() => import("./PacketPathMap").then(module => ({ default: module.PacketPathMap }))); + // Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route; // clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it // returns the user exactly where they were. @@ -29,54 +32,53 @@ function Row({ active, color, label, meta, onClick }: { ); } -export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { +export function PacketPathMapModal({ detail, onClose, initialSelectedKey, inactive = false }: { detail: PacketDetail; onClose: () => void; initialSelectedKey?: string | null; + inactive?: boolean; }) { + const { t } = useTranslation(); const paths = useMemo(() => buildPacketPaths(detail), [detail]); const [selectedKey, setSelectedKey] = useState( - // deep-link value that matches a known path isolates it; anything else (incl. "all") shows All - () => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null), + () => initialSelectedKey && initialSelectedKey !== "all" ? initialSelectedKey : null, ); + const unavailable = selectedKey != null && !paths.some(p => p.key === selectedKey); const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []); - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - return ( - +
- Packet Path + {t("investigation.mapTitle")}
({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })} - ariaLabel="Copy path link" + preserveParams={["regions", "iata", "region"]} + params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })} + label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPath")} /> - +
+ {unavailable &&

{t("investigation.unavailableMap")}

} + {paths.length === 0 && !unavailable &&

{t("investigation.unmappable")}

}
- + {t("common.loading")}

}> + +
- setSelectedKey(null)} /> + setSelectedKey(null)} />
{paths.map((p) => ( setSelectedKey(p.key)} /> diff --git a/src/features/map/location.ts b/src/features/map/location.ts new file mode 100644 index 0000000..45cb318 --- /dev/null +++ b/src/features/map/location.ts @@ -0,0 +1,9 @@ +// 0/0 is an advert reset, not Null Island; a single zero axis is still a valid location. +export function hasMapLocation( + node: { lat?: number | null; lng?: number | null } | null | undefined, +): node is { lat: number; lng: number } { + return node?.lat != null && node.lng != null + && Number.isFinite(node.lat) && Number.isFinite(node.lng) + && Math.abs(node.lat) <= 90 && Math.abs(node.lng) <= 180 + && (node.lat !== 0 || node.lng !== 0); +} diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index 6f9a522..aa3890e 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -53,6 +53,12 @@ function parseBool(raw: string | null): boolean | undefined { return undefined; } +// A canonical node-type name, or undefined for anything else (All, a label, a stale saved value). +export function parseNodeType(raw: string | null | undefined): string | undefined { + const type = raw?.toLowerCase(); + return type && NODE_TYPE_NAMES.includes(type as (typeof NODE_TYPE_NAMES)[number]) ? type : undefined; +} + export function parseMapView(params: URLSearchParams): ParsedMapView { const view: ParsedMapView = {}; @@ -65,8 +71,8 @@ export function parseMapView(params: URLSearchParams): ParsedMapView { 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 type = parseNodeType(params.get("node_type")); + if (type) view.nodeType = type; const neighbor = params.get("neighbor_lines")?.toLowerCase(); if (neighbor && NEIGHBOR_MODES.includes(neighbor as NeighborLinesMode)) { diff --git a/src/features/map/maplibre-worker.ts b/src/features/map/maplibre-worker.ts new file mode 100644 index 0000000..6d090f9 --- /dev/null +++ b/src/features/map/maplibre-worker.ts @@ -0,0 +1,6 @@ +import { setWorkerUrl } from "maplibre-gl"; +import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; + +// maplibre 6 can't find its worker through Vite's module graph on its own; without this the map +// silently never fetches vector tiles. Import once from anything that constructs a Map. +setWorkerUrl(workerUrl); diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index e6452d6..65609b6 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -1,5 +1,6 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { NodeSummary, NodeNeighbor } from "../nodes/types"; +import { hasMapLocation } from "./location"; // 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. @@ -16,8 +17,7 @@ export function nodesToFeatureCollection( ): FeatureCollection { const features: Feature[] = []; for (const n of nodes) { - // != null keeps 0 (a valid coordinate) while dropping null/undefined - if (n.lat == null || n.lng == null) continue; + if (!hasMapLocation(n)) continue; features.push({ type: "Feature", // GeoJSON/maplibre order is [lng, lat]; the API sends decimal degrees as-is @@ -52,13 +52,13 @@ export function buildNeighborEdges( ): FeatureCollection { const located = new Map(); for (const n of nodes) { - if (n.lat != null && n.lng != null) located.set(n.id, n); + if (hasMapLocation(n)) 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; + if (!hasMapLocation(n) || !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); @@ -89,12 +89,12 @@ export function buildFocusedNeighborEdges( now: number = Date.now(), ): FeatureCollection { const empty: FeatureCollection = { type: "FeatureCollection", features: [] }; - if (!selected || selected.lat == null || selected.lng == null) return empty; + if (!hasMapLocation(selected)) 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; + if (nb.id === selected.id || !hasMapLocation(nb)) continue; const prev = byId.get(nb.id); if (prev) { prev.obs += nb.observationCount; @@ -123,7 +123,7 @@ export function neighborFocusIds(nodes: NodeSummary[], selectedId: 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); + if (hasMapLocation(n)) located.set(n.id, n); } const selected = located.get(selectedId); if (!selected) return null; diff --git a/src/features/map/node-icons.ts b/src/features/map/node-icons.ts index af49be6..628c9ba 100644 --- a/src/features/map/node-icons.ts +++ b/src/features/map/node-icons.ts @@ -8,7 +8,7 @@ import { } from "./types"; // Marker icons: per-type SVG + cluster hexagon, recolored and rasterized to maplibre images (unknown -// type = canvas ring). Async, so provided lazily via styleimagemissing in useMapNodes; re-colors on theme. +// type = canvas ring). Async, so provided lazily via the missing-image resolver in useMapNodes; re-colors on theme. // Glyph SVGs as raw text per type (+ observer variant). Marker style follows the basemap: filled // Glitch on dark, hollow Wireframe on light — which is exactly what isDark encodes. import.meta.glob diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index cce4657..f4bfe1d 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -1,14 +1,13 @@ import type { ResolvedHop } from "../../types/api"; +import { hasMapLocation } from "./location"; // Pure helpers for drawing a packet's path — the live flow animation (modelled on MeshMapper's // LiveViz) and the path map share them. No maplibre import, so they stay unit-testable; the hook // owns the layers, the rAF loop, and the node flashes. -// The full chain for one observation: source → relay hops → destination. Both maps plot one marker -// per hop, so an ambiguous endpoint (a 1-byte prefix matching several candidate nodes) would force -// us to guess which node actually sent or received the packet — only plot endpoints the backend -// resolved unambiguously. Relay hops carry no such gate; they fall back to their first located -// candidate. WS types the endpoints nullable where REST leaves them optional, hence both here. +// The full chain for one observation: source → relay hops → destination. An ambiguous endpoint +// (a 1-byte prefix matching several candidate nodes) would force us to guess which node sent or +// received the packet, so only plot endpoints the backend resolved unambiguously. export function packetChain( source: ResolvedHop | null | undefined, path: ResolvedHop[], @@ -18,14 +17,20 @@ export function packetChain( return [confident(source), ...path, confident(destination)].filter((hop): hop is ResolvedHop => hop != null); } -// 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 locatedHopNode(hop: ResolvedHop) { + if (hop.confidence !== "high" || hop.nodes.length !== 1) return undefined; + const node = hop.nodes[0]!; + return hasMapLocation({ lat: node.latitude, lng: node.longitude }) ? node : undefined; +} + +// Animate only a completely located, unambiguous chain; skipping a hop would invent a link. 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)) { + const node = locatedHopNode(hop); + if (!node) return []; + if (!seen.has(node.id)) { seen.add(node.id); out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); } diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index b0d5553..8e2e0e8 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -1,13 +1,14 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; import { PayloadType } from "../../types/enums"; -import { packetChain } from "./packet-flow"; +import { packetChain, locatedHopNode } from "./packet-flow"; export interface PathPoint { id: string; name?: string; lng: number; lat: number; + breakBefore?: boolean; } export interface PacketPath { @@ -31,17 +32,19 @@ export const PATH_COLORS: string[] = [ "#a78bfa", // violet ]; -// The located nodes on a resolved path — first candidate per hop that has coords, deduped by id, in -// order. Modelled on resolvedPathNodes() in packet-flow.ts, but keeps each node's name for labels. +// Keep known locations, but break lines at ambiguous/unlocated hops instead of inventing a link. function pathPoints(hops: ResolvedHop[]): PathPoint[] { const seen = new Set(); const out: PathPoint[] = []; + let gap = false; for (const hop of hops) { - const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); - if (node && !seen.has(node.id)) { + const node = locatedHopNode(hop); + if (!node) { gap = true; continue; } + if (!seen.has(node.id)) { seen.add(node.id); - out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude! }); - } + out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude!, ...(gap && out.length ? { breakBefore: true } : {}) }); + gap = false; + } else gap = out.at(-1)?.id !== node.id; } return out; } @@ -65,7 +68,7 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { // lines would just duplicate the single "Trace route" below — draw only that one for traces. if (!isTrace) { for (const obs of detail.observations) { - // full chain: source → relay hops → destination; missing/unlocated hops drop out in pathPoints. + // Full chain: source → relay hops → destination, with gaps retained by pathPoints. const chain = packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination); add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); } @@ -104,11 +107,16 @@ export function packetPathsToFeatures( const bounds: [number, number][] = []; for (const path of shown) { - lines.push({ - type: "Feature", - properties: { key: path.key, color: path.color }, - geometry: { type: "LineString", coordinates: path.points.map((p) => [p.lng, p.lat]) }, - }); + let segment: [number, number][] = []; + const finishSegment = () => { + if (segment.length > 1) lines.push({ type: "Feature", properties: { key: path.key, color: path.color }, geometry: { type: "LineString", coordinates: segment } }); + segment = []; + }; + for (const point of path.points) { + if (point.breakBefore) finishSegment(); + segment.push([point.lng, point.lat]); + } + finishSegment(); path.points.forEach((pt, i) => { const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid"; points.push({ diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 9298b06..7175d25 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -1,6 +1,8 @@ // Map feature config: pure data and lookups, no maplibre import, so it stays unit-testable // without pulling in the WebGL engine. +import { runtimeEnv } from "../../lib/runtime-env"; + export interface MapStyleOption { id: string; name: string; @@ -40,7 +42,7 @@ export const TERRAIN_EXAGGERATION = 1.5; // The fallback/initial map view, configured per deployment via .env (VITE_MAP_CENTER as decimal // "lat,lon", VITE_MAP_ZOOM). Used before airports load and when a selection has no airport coords; -// otherwise MapView fits bounds over the airports (see CLAUDE.md, map framing). With neither set, +// otherwise MapView fits bounds over the airports. With neither set, // fall back to a wide world overview. const FALLBACK_CENTER: [number, number] = [0, 20]; // [lng, lat] — neutral world view const FALLBACK_ZOOM = 1.5; @@ -72,9 +74,9 @@ export function parseMapZoom(raw: string | undefined): number { } export const DEFAULT_CENTER: [number, number] = parseMapCenter( - import.meta.env.VITE_MAP_CENTER as string | undefined, + runtimeEnv("VITE_MAP_CENTER", import.meta.env.VITE_MAP_CENTER), ); -export const DEFAULT_ZOOM = parseMapZoom(import.meta.env.VITE_MAP_ZOOM as string | undefined); +export const DEFAULT_ZOOM = parseMapZoom(runtimeEnv("VITE_MAP_ZOOM", import.meta.env.VITE_MAP_ZOOM)); export const DEFAULT_PITCH = 0; // flat overview, no tilt export const DEFAULT_BEARING = 0; export const MAX_PITCH = 85; diff --git a/src/features/map/useMapBorders.ts b/src/features/map/useMapBorders.ts index bd476d7..5564931 100644 --- a/src/features/map/useMapBorders.ts +++ b/src/features/map/useMapBorders.ts @@ -75,7 +75,7 @@ export function useMapBorders( if (src) src.setData(data); }, [mapRef, isReady, data]); - // remove layers (before the source) on unmount; runs before useMapLibre tears the map down + // remove layers (before the source) on unmount; useMapLibre's map.remove() runs first, hence the try useEffect(() => { const map = mapRef.current; return () => { diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts index f89f326..5c3718e 100644 --- a/src/features/map/useMapBordersData.ts +++ b/src/features/map/useMapBordersData.ts @@ -3,6 +3,17 @@ import { useQueries } from "@tanstack/react-query"; import type { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson"; import { getIataBorder, type IataBorder } from "../../api/client"; +// Identity ids for border data objects, so the sig below reflects a real data change rather than a +// refetch timestamp (TanStack's structural sharing keeps unchanged geometry referentially equal). +const idsByData = new WeakMap(); +let nextId = 1; +function idOf(data: unknown): number { + if (data == null || typeof data !== "object") return 0; + let id = idsByData.get(data); + if (id == null) { id = nextId++; idsByData.set(data, id); } + return id; +} + export type BorderProps = { iata: string; [key: string]: unknown }; export type BorderFeatureCollection = FeatureCollection; @@ -18,23 +29,23 @@ export function mergeBorders(entries: { iata: string; border: IataBorder | null } // Fetch the border for each active IATA (only while `enabled`), then merge into one collection. -// Borders are static, so each is cached indefinitely and most IATAs simply have none (204 -> null). +// Cache geometry for an hour; missing borders can appear after an operator imports a snapshot. export function useMapBordersData(iataCodes: string[], enabled: boolean): BorderFeatureCollection { const results = useQueries({ queries: iataCodes.map((iata) => ({ queryKey: ["iata-border", iata], queryFn: () => getIataBorder(iata), enabled, - staleTime: Infinity, + staleTime: (query: { state: { data: unknown } }) => query.state.data ? 3_600_000 : 60_000, })), }); - // useQueries returns a fresh array each render; a border is immutable once fetched, so a signature - // of which IATAs have resolved one is enough to keep the collection reference stable between renders. - const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}`).join("|"); + // Keep the collection stable between renders, but replace geometry after a successful refresh. + // disabled queries keep their cached data, so an off toggle has to empty the collection itself + const sig = enabled ? iataCodes.map((iata, i) => `${iata}:${idOf(results[i]?.data)}`).join("|") : "off"; return useMemo( - () => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))), - // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded + () => mergeBorders(enabled ? iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null })) : []), + // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures enabled + iataCodes + which borders loaded [sig], ); } diff --git a/src/features/map/useMapLibre.ts b/src/features/map/useMapLibre.ts index 08a8fea..8d4f380 100644 --- a/src/features/map/useMapLibre.ts +++ b/src/features/map/useMapLibre.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; -import type { Map as MapLibreMap, RasterDEMSourceSpecification } from "maplibre-gl"; +import { Map as MapLibreMap, NavigationControl, ScaleControl, AttributionControl, LngLatBounds } from "maplibre-gl"; +import type { RasterDEMSourceSpecification } from "maplibre-gl"; +import "./maplibre-worker"; +import i18n from "../../i18n"; import { DEM_TILES, DEM_ATTRIBUTION, @@ -15,10 +17,30 @@ import { resolveMapStyle, } from "./types"; +// MapLibre reads its control tooltips once at construction, so a language switch applies on next mount. +export function mapLocale(): Record { + return { + "AttributionControl.ToggleAttribution": i18n.t("map.controls.toggleAttribution"), + "AttributionControl.MapFeedback": i18n.t("map.controls.mapFeedback"), + "Map.Title": i18n.t("map.controls.map"), + "NavigationControl.ResetBearing": i18n.t("map.controls.resetBearing"), + "NavigationControl.ZoomIn": i18n.t("map.controls.zoomIn"), + "NavigationControl.ZoomOut": i18n.t("map.controls.zoomOut"), + "Popup.Close": i18n.t("map.controls.closePopup"), + }; +} + // serialized fit target, so the fit effect can skip redundant re-fits const fitKey = (points: [number, number][] | null) => points && points.length ? points.map((p) => `${p[0]},${p[1]}`).join(";") : null; +// Sprite/glyph fetches fail with their own url; only a urlless error (parse/validation) or one for the +// style document itself means the style won't load. +function isStyleLoadFailure(error: unknown, styleUrl: string): boolean { + const url = (error as { url?: unknown } | undefined)?.url; + return typeof url !== "string" || url === styleUrl; +} + // Keeps the imperative MapLibre lifecycle out of MapView; exposes mapRef + isReady for overlays. const TERRAIN_SOURCE_ID = "terrain-dem"; @@ -70,6 +92,8 @@ export function useMapLibre( // 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 }, + // identity of the selection being framed; the deep-link camera only wins while it's unchanged + fitScope?: string, ) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -78,9 +102,11 @@ export function useMapLibre( const lastGoodStyleIdRef = useRef(styleId); // last style that loaded; the revert target on a failed swap const hasLoadedRef = useRef(false); // a style has loaded at least once (distinguishes initial-load failure) const swapPendingRef = useRef(false); // a setStyle() basemap swap is in flight (awaiting style.load) + const nodeIconResolverRef = useRef<((id: string) => Promise) | null>(null); // filled by useMapNodes 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 initialFitScopeRef = useRef(fitScope); 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); @@ -103,7 +129,7 @@ export function useMapLibre( // 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({ + const map = new MapLibreMap({ container, style: resolveMapStyle(styleIdRef.current).url, center: initialCameraRef.current?.center ?? DEFAULT_CENTER, @@ -112,13 +138,14 @@ export function useMapLibre( bearing: DEFAULT_BEARING, maxPitch: MAX_PITCH, attributionControl: false, // replaced below with a compact (always-collapsed) control + locale: mapLocale(), }); mapRef.current = map; lastStyleIdRef.current = styleIdRef.current; - map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); - map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); - map.addControl(new maplibregl.AttributionControl({ compact: true })); // bottom-right + map.addControl(new NavigationControl({ visualizePitch: true }), "top-right"); + map.addControl(new ScaleControl({ unit: "metric" }), "bottom-left"); + map.addControl(new AttributionControl({ compact: true })); // bottom-right // maplibre pops the compact attribution open the first time the basemap credit loads (it tacks // on .maplibregl-compact-show). Mark it .maplibregl-compact up front so it skips that and stays // a bare (i) on load — clicking it still opens the credit. @@ -141,18 +168,22 @@ export function useMapLibre( // "circle-11"), so maplibre warns on every load. Hand it a transparent 1x1 for anything that // isn't ours and the noise goes away — a missing icon already draws nothing, so the map looks // identical. Our own markers all start with "node-" and are rasterized by useMapNodes, so we - // leave those alone. This lives here (not in useMapNodes) so it's listening before the base + // leave those alone. This lives here (not in useMapNodes) so it's installed before the base // style's first paint, when those icons are first requested. - map.on("styleimagemissing", (e) => { - if (!e.id.startsWith("node-") && !map.hasImage(e.id)) map.addImage(e.id, new ImageData(1, 1)); + // maplibre allows one resolver per map, so node icons route through a slot useMapNodes fills. + map.setMissingStyleImageResolver((id) => { + if (id.startsWith("node-")) return nodeIconResolverRef.current?.(id); + if (!map.hasImage(id)) map.addImage(id, new ImageData(1, 1)); }); map.on("error", (e) => { - const err = e as { error?: Error; sourceId?: string; tile?: unknown }; + const err = e as unknown as { error?: Error; sourceId?: string; tile?: unknown }; // A single tile/source failure (one basemap or DEM tile timing out / 403 / a momentary network // blip) is transient and non-fatal — the rest of the map stays usable — so never blank the map // for it. maplibre tags tile/source errors with a tile/sourceId; style-level errors have neither. if (err.sourceId != null || err.tile != null) return; + // a sprite 404 mid-swap is non-fatal too; the new style still loads without its icons + if (swapPendingRef.current && !isStyleLoadFailure(err.error, resolveMapStyle(lastStyleIdRef.current).url)) return; // The new basemap failed mid-swap. setStyle keeps the old style (and our node layers) // rendered, so roll back to the last good style and tell MapView to revert the picker rather // than blanking the map under a fatal overlay. @@ -194,6 +225,8 @@ export function useMapLibre( useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; + // the linked region never got its fit (e.g. it has no coords); don't spend the skip on another one + if (fitScope !== initialFitScopeRef.current) skipInitialFitRef.current = false; const key = fitKey(fitPoints); if (key === lastFitKeyRef.current) return; lastFitKeyRef.current = key; @@ -203,8 +236,8 @@ 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. + // First real fit of the linked region: keep the URL-supplied view instead of framing it. + // Consumed once, so later region changes fit normally. if (skipInitialFitRef.current) { skipInitialFitRef.current = false; return; @@ -212,7 +245,7 @@ export function useMapLibre( const bounds = fitPoints.reduce( (b, p) => b.extend(p), - new maplibregl.LngLatBounds(fitPoints[0], fitPoints[0]), + new LngLatBounds(fitPoints[0], fitPoints[0]), ); map.fitBounds(bounds, { padding: 48, @@ -220,7 +253,7 @@ export function useMapLibre( pitch: fitPoints.length === 1 ? IATA_PITCH : DEFAULT_PITCH, bearing: DEFAULT_BEARING, }); - }, [fitPoints, isReady]); + }, [fitPoints, isReady, fitScope]); - return { containerRef, mapRef, isReady, error }; + return { containerRef, mapRef, isReady, error, nodeIconResolverRef }; } diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts index d6db109..b39c5f2 100644 --- a/src/features/map/useMapNeighbors.ts +++ b/src/features/map/useMapNeighbors.ts @@ -89,7 +89,7 @@ export function useMapNeighbors( }, [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. + // component's life. useMapLibre's map.remove() runs first, hence the try. useEffect(() => { const map = mapRef.current; return () => { diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index c4a7807..d8c19c0 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -16,6 +16,7 @@ import { NODES_POINT_LAYER_ID, NODES_SELECTED_LAYER_ID, NODES_SELECTED_LEAF_LAYER_ID, + PACKET_FLOW_TRAIL_LAYER_ID, CLUSTER_RADIUS, CLUSTER_MAX_ZOOM, NODES_SOURCE_MAXZOOM, @@ -98,6 +99,7 @@ const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { // labels at high zoom). Like useMapLibre, the imperative work re-adds itself after every style switch. export function useMapNodes( mapRef: React.RefObject, + nodeIconResolverRef: React.RefObject<((id: string) => Promise) | null>, isReady: boolean, geojson: NodeFC, isDark: boolean, @@ -178,6 +180,9 @@ export function useMapNodes( }); } + // a clustering toggle re-adds these after packet flow built its layers; keep the flow on top + const beforeFlow = map.getLayer(PACKET_FLOW_TRAIL_LAYER_ID) ? PACKET_FLOW_TRAIL_LAYER_ID : undefined; + // Cluster as a SYMBOL layer (hexagon icon + count) — spiderfy requires a symbol layer. The icon // is a density level picked by point_count; the count is drawn as centered text (the icon has // none baked in). text-size isn't scaled by icon-size, so both are interpolated together. @@ -197,7 +202,7 @@ export function useMapNodes( "text-allow-overlap": true, }, paint: { "text-color": "#FFFFFF", "text-halo-color": "rgba(0,0,0,0.55)", "text-halo-width": 1.2 }, - } as SymbolLayerSpecification); + } as SymbolLayerSpecification, beforeFlow); } if (!map.getLayer(NODES_POINT_LAYER_ID)) { @@ -223,7 +228,7 @@ export function useMapNodes( "text-halo-width": 1.3, "text-opacity": LABEL_OPACITY, // labels fade in only at high zoom }, - } as SymbolLayerSpecification); + } as SymbolLayerSpecification, beforeFlow); } // Ring under the selected node's icon. Only matches an unclustered point (clusters carry no id); @@ -281,8 +286,9 @@ export function useMapNodes( }, [mapRef, isReady, isDark, clustered, themeKey]); // Supply and re-color the marker images. SVG glyphs rasterize async, so they're provided both - // proactively here and lazily on styleimagemissing. Re-runs on a theme/basemap/DPR change to - // re-rasterize; a basemap switch also drops the images via setStyle, which this then restores. + // proactively here and lazily through the map's missing-image resolver. Re-runs on a theme/ + // basemap/DPR change to re-rasterize; a basemap switch also drops the images via setStyle, which + // this then restores. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; @@ -297,12 +303,11 @@ export function useMapNodes( .catch(() => { /* an icon failed to rasterize; the layer simply draws nothing for that id */ }); - const onMissing = (e: { id: string }) => provide(e.id); - map.on("styleimagemissing", onMissing); + nodeIconResolverRef.current = provide; // A symbol won't draw until its icon is in, and adding one late doesn't redraw tiles that // already laid out — that's the "markers only show after I pan/zoom" bug. So once every icon // is ready, nudge the source to lay the markers out again (setData reloads the whole source). - // styleimagemissing still covers anything asked for before we get here. + // The resolver still covers anything asked for before we get here. Promise.all(MAP_ICON_IDS.map(provide)).then(() => { if (cancelled || mapRef.current !== map) return; const src = map.getSource(NODES_SOURCE_ID) as GeoJSONSource | undefined; @@ -310,9 +315,9 @@ export function useMapNodes( }); return () => { cancelled = true; - map.off("styleimagemissing", onMissing); + nodeIconResolverRef.current = null; }; - }, [mapRef, isReady, isDark, themeKey, dpr]); + }, [mapRef, nodeIconResolverRef, isReady, isDark, themeKey, dpr]); // Reflect the shared selection as a ring (mirrors the table's row highlight). Its own effect so // changing the selection doesn't rebuild the source/layers. @@ -357,9 +362,9 @@ export function useMapNodes( }, [mapRef, isReady, geojson]); // Build spiderfy + node/cluster interactions, and tear them down on cleanup. Re-runs on every - // style switch and clustering toggle, so body and cleanup must stay symmetric: setStyle does NOT - // drop delegated layer listeners (stable ids in maplibre's Evented registry), so every map.on - // must be matched by a map.off here or handlers pile up across switches. + // style switch, clustering toggle and dataset reset, so body and cleanup must stay symmetric: + // setStyle does NOT drop delegated layer listeners (stable ids in maplibre's Evented registry), so + // every map.on must be matched by a map.off here or handlers pile up across switches. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; @@ -433,15 +438,7 @@ export function useMapNodes( /* map may already be removed */ } }; - // themeKey is a dep so spiderfy rebuilds and its legs + leaf icons pick up the new palette - }, [mapRef, isReady, clustered, themeKey]); - - // close any open fan when the dataset identity changes — its leaves no longer exist - useEffect(() => { - try { - spiderRef.current?.unspiderfyAll(); - } catch { - /* map may already be removed */ - } - }, [resetKey]); + // themeKey rebuilds the legs + leaf icons in the new palette; resetKey closes a fan whose leaves + // are gone (unspiderfyAll also unbinds the cluster click, so it has to be a full rebuild) + }, [mapRef, isReady, clustered, themeKey, resetKey]); } diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index ed3ed9a..7085f10 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -200,7 +200,7 @@ export function useMapPacketFlow( clearFlows(mapRef.current); }, [resetKey, mapRef, clearFlows]); - // remove layers + sources on unmount (runs before useMapLibre's map.remove()) + // remove layers + sources on unmount (after useMapLibre's map.remove(), hence the try) useEffect(() => { const map = mapRef.current; return () => { diff --git a/src/features/nodes/ForeignNodeBadge.tsx b/src/features/nodes/ForeignNodeBadge.tsx new file mode 100644 index 0000000..e4ae87c --- /dev/null +++ b/src/features/nodes/ForeignNodeBadge.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "../../components/Badge"; +import { Tooltip } from "../../components/Tooltip"; + +export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) { + const { t } = useTranslation(); + if (possiblyForeign !== true) return null; + return ( + + {t("nodes.possiblyForeign")} + + ); +} diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx deleted file mode 100644 index 514c949..0000000 --- a/src/features/nodes/NodeDetailOverlay.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from "react"; -import { NodeDetailPanel } from "./NodeDetailPanel"; -import { ModalOverlay } from "../../components/ModalOverlay"; - -// Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is -// (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet. -export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: { - nodeId: string; - onClose: () => void; - onViewObserver: (observerId: string) => void; - onViewNode?: (nodeId: string) => void; -}) { - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - return ( - - - - ); -} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 4debae6..dedc183 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; @@ -7,9 +8,12 @@ import { CopyLinkButton } from "../../components/CopyLinkButton"; import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; +import { hasMapLocation } from "../map/location"; import type { NodeObservation, NodeNeighbor } from "./types"; +import { ForeignNodeBadge } from "./ForeignNodeBadge"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { + const { t } = useTranslation(); return (
{neighbor.publicKey} · - {neighbor.observationCount.toLocaleString()} obs + {t("nodeDetail.observations", { count: neighbor.observationCount, formatted: neighbor.observationCount.toLocaleString() })}
); } function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?: () => void }) { + const { t } = useTranslation(); const level = snrLevel(obs.snr); return (
- Hops + {t("nodeDetail.hops")} {obs.hopCount ?? "—"}
@@ -72,9 +77,11 @@ interface NodeDetailPanelProps { onViewObserver: (observerId: string) => void; onViewNode?: (nodeId: string) => void; onAnalyzePacket?: (hash: string) => void; + onViewOnMap?: (lat: number, lng: number) => void; } -export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket }: NodeDetailPanelProps) { +export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) { + const { t } = useTranslation(); const { data: node, isLoading } = useQuery({ queryKey: ["node", nodeId], queryFn: () => getNode(nodeId), @@ -93,17 +100,17 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o staleTime: 30_000, }); - const hasLocation = node != null && node.lat != null && node.lng != null; + const hasLocation = hasMapLocation(node); return ( } + headerAction={} isLoading={isLoading} notFound={!node} - notFoundLabel="Node not found" + notFoundLabel={t("nodeDetail.notFound")} notFoundIcon={ @@ -112,18 +119,19 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o > {node && ( <> -
-
+
+
{node.name ?? formatHex(node.id)} {node.nodeTypeName} +
{node.publicKey}
- +
{node.observerId && ( )}
{(hasLocation || node.locationSource) && ( -
+
- {node.lat != null && } - {node.lng != null && } - {node.locationSource && } + {hasLocation && } + {hasLocation && } + {node.locationSource && }
+ {hasMapLocation(node) && onViewOnMap && ( + + )}
)} -
+
- {node.minFirmwareVersion && } - - - {node.radio && } - {node.defaultScope && } + {node.minFirmwareVersion && } + + + {node.radio && } + {node.defaultScope && }
-
+
- } /> - } /> - {node.lastAdvertAt != null && } />} + } /> + } /> + {node.lastAdvertAt != null && } />} {node.clockDriftSeconds != null && ( {formatClockDrift(node.clockDriftSeconds)}} + label={t("nodeDetail.clockDrift")} + value={{formatClockDrift(node.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}} /> )}
-
0 ? `Neighbors (${node.knownNeighborCount})` : "Neighbors"}> +
0 ? t("nodeDetail.neighborsCount", { count: node.knownNeighborCount }) : t("nodeDetail.neighbors")}> {neighbors && neighbors.length > 0 ? (
{neighbors.map((n) => ( @@ -183,11 +200,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o ))}
) : ( -
No known neighbors
+
{t("nodeDetail.noNeighbors")}
)}
-
+
{observations && observations.items.length > 0 ? (
{observations.items.map((obs) => ( @@ -199,7 +216,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o ))}
) : ( -
No recent observations
+
{t("nodeDetail.noRecentPackets")}
)}
diff --git a/src/features/nodes/NodeFilterBar.tsx b/src/features/nodes/NodeFilterBar.tsx index 24d5dfd..d951e50 100644 --- a/src/features/nodes/NodeFilterBar.tsx +++ b/src/features/nodes/NodeFilterBar.tsx @@ -1,20 +1,11 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; import { SelectDropdown } from "../../components/SelectDropdown"; import { FilterSheet, FiltersButton } from "../../components/FilterSheet"; import { useIsMobile } from "../../hooks/useMediaQuery"; import { NODE_TYPE_OPTIONS } from "../../lib/node-types"; -const MULTIBYTE_OPTIONS = [ - { value: "true", label: "Yes" }, - { value: "false", label: "No" }, -]; - -const SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "name", label: "Name" }, - { value: "pubkey", label: "Public Key" }, -]; - // "" means no filter (Any) export type MultibyteFilter = "" | "true" | "false"; @@ -49,6 +40,7 @@ export function NodeFilterBar({ onScopeChange, scopeOptions, }: NodeFilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect @@ -62,22 +54,32 @@ export function NodeFilterBar({ onScopeChange(""); }; + const multibyteOptions = [ + { value: "true", label: t("nodeFilters.yes") }, + { value: "false", label: t("nodeFilters.no") }, + ]; + const typeOptions = NODE_TYPE_OPTIONS.map((o) => ({ ...o, label: t(`nodeTypes.${o.value}`, { defaultValue: o.label }) })); + const searchFields: SearchFieldOption[] = [ + { value: "name", label: t("nodeFilters.fieldName") }, + { value: "pubkey", label: t("nodeFilters.fieldPubkey") }, + ]; + // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - - onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} /> - onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} /> + + onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} /> + onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} /> {scopeOptions.length > 0 && ( - ({ value: s, label: s }))} allLabel="Any" value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} /> + ({ value: s, label: s }))} allLabel={t("nodeFilters.any")} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} /> )} ); if (isMobile) { return ( -
- +
+ setSheetOpen(true)} /> {sheetOpen && ( setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}> @@ -92,12 +94,12 @@ export function NodeFilterBar({
diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 60179f7..4b29d60 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getNodesPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -7,7 +8,8 @@ import { useTick } from "../../hooks/useTick"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { patchInfinitePages } from "../../lib/infinite-pages"; import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers"; -import { formatHex, timeAgoMs, formatRadio } from "../../lib/formatters"; +import { formatHex, timeAgoParts, formatRadio } from "../../lib/formatters"; +import { hasMapLocation } from "../map/location"; import { Badge } from "../../components/Badge"; import { Tooltip } from "../../components/Tooltip"; import { ObserverIcon } from "../../components/ObserverIcon"; @@ -16,13 +18,27 @@ import { LoadingPill } from "../../components/LoadingPill"; import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; -import type { NodeSummary } from "./types"; +import { ForeignNodeBadge } from "./ForeignNodeBadge"; +import type { TFunction } from "i18next"; +import type { NodeSummary, NodeIATA } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; import type { WsNodeUpdate } from "../../types/ws"; const nodeId = (n: NodeSummary) => n.id; // stable id accessor for the paged hook's dedup +// Cell and card renderers can't call hooks, so the computed "last heard" label gets its own component. +function IataBadge({ entry }: { entry: NodeIATA }) { + const { t } = useTranslation(); + const { count, unit } = timeAgoParts(entry.lastHeard); + const ago = t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); + return ( + + {entry.iata} + + ); +} + interface NodeTableProps { wsManager: WsManager; // shared with the Map tab (lifted to AppInner) so the detail panel persists across tab switches @@ -30,70 +46,10 @@ interface NodeTableProps { onSelectNode: (id: string | null) => void; } -const COLUMNS: Column[] = [ - { - header: "Name", - sortValue: (node) => node.name ?? formatHex(node.id), - cell: (node) => ( - - {node.name ?? formatHex(node.id)} - - ), - }, - { - header: "Type", - sortValue: (node) => node.nodeTypeName, - cell: (node) => ( - - {node.isObserver && ( - - )} - {node.nodeTypeName} - - ), - }, - { - header: "Radio", - className: "text-text-muted", - sortValue: (node) => formatRadio(node.radio) ?? null, - cell: (node) => formatRadio(node.radio) ?? "—", - }, - { - header: "IATAs", - cell: (node) => - node.iatas && node.iatas.length > 0 ? ( -
- {node.iatas.map((entry) => ( - - {entry.iata} - - ))} -
- ) : ( - — - ), - }, - { - header: "Neighbors", - className: "text-text-muted", - sortValue: (node) => node.knownNeighborCount, - cell: (node) => node.knownNeighborCount.toLocaleString(), - }, - { - header: "Location", - className: "text-text-muted", - cell: (node) => - node.lat != null && node.lng != null - ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` - : "—", - }, -]; - -function renderNodeCard(node: NodeSummary) { - const location = - node.lat != null && node.lng != null - ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` - : null; +function renderNodeCard(node: NodeSummary, t: TFunction) { + const location = hasMapLocation(node) + ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` + : null; return (
@@ -103,7 +59,7 @@ function renderNodeCard(node: NodeSummary) { {node.isObserver && ( - + )} {node.nodeTypeName} @@ -112,14 +68,13 @@ function renderNodeCard(node: NodeSummary) {
{formatRadio(node.radio) ?? "—"} {location && · {location}} - {node.knownNeighborCount > 0 && · {node.knownNeighborCount.toLocaleString()} neighbors} + {node.knownNeighborCount > 0 && · {t("nodes.neighbors", { count: node.knownNeighborCount, formatted: node.knownNeighborCount.toLocaleString() })}}
+ {node.iatas && node.iatas.length > 0 && (
{node.iatas.map((entry) => ( - - {entry.iata} - + ))}
)} @@ -128,7 +83,8 @@ function renderNodeCard(node: NodeSummary) { } export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTableProps) { - const { iatas, regionKey } = useRegion(); + const { t } = useTranslation(); + const { iatas, regionKey, isResolved } = useRegion(); const queryClient = useQueryClient(); const [typeFilter, setTypeFilter] = useState(""); const [pathsFilter, setPathsFilter] = useState(""); @@ -137,6 +93,72 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable const [search, setSearch] = useState(""); const [searchField, setSearchField] = useState("name"); + const columns = useMemo[]>(() => [ + { + id: "name", + header: t("nodes.colName"), + sortValue: (node) => node.name ?? formatHex(node.id), + cell: (node) => ( + + {node.name ?? formatHex(node.id)} + + ), + }, + { + id: "type", + header: t("nodes.colType"), + sortValue: (node) => node.nodeTypeName, + cell: (node) => ( +
+ + {node.isObserver && ( + + )} + {node.nodeTypeName} + + +
+ ), + }, + { + id: "radio", + header: t("nodes.colRadio"), + className: "text-text-muted", + sortValue: (node) => formatRadio(node.radio) ?? null, + cell: (node) => formatRadio(node.radio) ?? "—", + }, + { + id: "areas", + header: t("nodes.colAreas"), + cell: (node) => + node.iatas && node.iatas.length > 0 ? ( +
+ {node.iatas.map((entry) => ( + + ))} +
+ ) : ( + — + ), + }, + { + id: "neighbors", + header: t("nodes.colNeighbors"), + className: "text-text-muted", + sortValue: (node) => node.knownNeighborCount, + cell: (node) => node.knownNeighborCount.toLocaleString(), + }, + { + id: "location", + header: t("nodes.colLocation"), + className: "text-text-muted", + cell: (node) => + hasMapLocation(node) + ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` + : "—", + }, + ], [t]); + useTick(); // switching the field flips what the box means (a name vs a hex prefix), so stale text mustn't carry over @@ -169,10 +191,10 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable }), getId: nodeId, keepPrevious: true, + enabled: isResolved !== false, }); - // scope options are the configured scopes; the filter itself is applied client-side on defaultScope - const scopeOptions = useScopes(); + const scopeOptions = useScopes(scopeFilter); const displayNodes = useMemo( () => (scopeFilter ? nodes.filter((n) => n.defaultScope === scopeFilter) : nodes), @@ -213,15 +235,15 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable /> n.id} selectedKey={selectedNodeId} onSelect={onSelectNode} - isLoading={isLoading} - emptyLabel="No nodes" - defaultSort={{ header: "Name" }} - renderCard={renderNodeCard} + isLoading={isLoading || isResolved === false} + emptyLabel={t("nodes.empty")} + defaultSort={{ id: "name" }} + renderCard={(node) => renderNodeCard(node, t)} />
diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index caee590..578c00f 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -18,13 +18,14 @@ export function patchNodeSummary( // only name/coords move in practice; nodeType/iatas are near-static, so we drop data.nodeType here // and let a reload carry a rare type change rather than keep a numeric-type lookup in sync const name = data.name || prev.name; - const lat = data.lat ?? prev.lat; - const lng = data.lng ?? prev.lng; + const lat = data.lat === undefined ? prev.lat : data.lat; + const lng = data.lng === undefined ? prev.lng : data.lng; + const possiblyForeign = data.possiblyForeign === undefined ? prev.possiblyForeign : data.possiblyForeign ?? undefined; // a re-advert that re-sends the same values must keep the SAME ref so patchInfinitePages no-ops // (otherwise an unchanged node would trigger a full map FeatureCollection rebuild + setData) - if (name === prev.name && lat === prev.lat && lng === prev.lng) return list; + if (name === prev.name && lat === prev.lat && lng === prev.lng && possiblyForeign === prev.possiblyForeign) return list; const updated = [...list]; - updated[idx] = { ...prev, name, lat, lng }; + updated[idx] = { ...prev, name, lat, lng, possiblyForeign }; return updated; } @@ -54,6 +55,7 @@ export function upsertNodePages( // meeting for the first time has none resolved yet, so start at 0 until a reload fills it in knownNeighborCount: 0, isObserver: data.isObserver, + possiblyForeign: data.possiblyForeign ?? undefined, }; const pages = [...old.pages]; const last = pages[pages.length - 1]!; diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 02ca6e1..40886a6 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -20,6 +20,8 @@ export interface NodeSummary { // map's observer-pip marker variant; observerId, when present, links to that observer's detail. isObserver?: boolean; observerId?: string; + // Server verdict against its configured local border union; absence is unknown/disabled. + possiblyForeign?: boolean; } export interface Node extends NodeSummary { diff --git a/src/features/observers/ObserverAdverts.tsx b/src/features/observers/ObserverAdverts.tsx new file mode 100644 index 0000000..30fb046 --- /dev/null +++ b/src/features/observers/ObserverAdverts.tsx @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverAdverts } from "../../api/client"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import { formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import type { AdvertObservation } from "./types"; + +function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick: () => void }) { + const { t } = useTranslation(); + const level = snrLevel(advert.snr); + const signal = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + return ( + + ); +} + +export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: string; onAnalyzePacket: (hash: string, observationId: number) => void }) { + const { t } = useTranslation(); + const { data: adverts, isPending } = useQuery({ + queryKey: ["observer-adverts", observerId], + queryFn: () => getObserverAdverts(observerId, { limit: 50 }), + staleTime: 30_000, + }); + return ( +
+ {t("observerPage.advertsHeard")} +
+ {isPending ?

{t("common.loading")}

+ : adverts?.items.length ? ( +
+ {adverts.items.map(a => onAnalyzePacket(a.packetHash, a.id)} />)} +
+ ) :

{t("observerPage.noAdverts")}

} +
+
+ ); +} diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx new file mode 100644 index 0000000..7ccdf9a --- /dev/null +++ b/src/features/observers/ObserverComparison.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; +import { getObserverComparison } from "../../api/client"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { formatBattery, formatUptime } from "../../lib/formatters"; +import { ObserverPicker } from "./ObserverPicker"; +import { observerNoiseFloor } from "./observer-stats"; +import { OBSERVER_UUID } from "./observer-id"; +import { Card, ChartCard } from "../stats/cards"; +import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; +import { useChartColors } from "../stats/chartTheme"; +import { heardOption } from "../stats/chartOptions"; +import { fillActivity, intervalToMs } from "../stats/transforms"; +import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; +import type { Observer } from "./types"; + +function statusNoise(observer: Observer) { + const n = observerNoiseFloor(observer); + return n == null ? "—" : `${n} dBm`; +} + +export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { + observerA: Observer; activityA?: ObserverActivity; range: StatsRange; observerBId: string; until: number | null; + onSelect: (id: string) => void; onRefresh: () => number; +}) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); + const valid = until != null && OBSERVER_UUID.test(observerA.id) && OBSERVER_UUID.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const b = useObserver(valid ? observerBId : null); + const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); + const dataB = activityB.isError ? undefined : activityB.data; + const intervalMs = intervalToMs(activityParamsFor(range).interval)!; + const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs; + const start = end - RANGE_MS[range]; + // raw packets only outlive 24h/7d windows; at 30d the overlap would silently cover a few days + const overlapRange = range !== "30d"; + const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs); + const overlap = useQuery({ + queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end], + queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal), + enabled: overlapRange && valid && aligned && !!b.data && !b.isError, + staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, + }); + const option = useMemo(() => { + const window = { start, end }; + const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window, activityA) : []; + const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window, dataB) : []; + return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, + legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, + series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({ + name: name as string, type: "line" as const, symbol: "none", connectNulls: false, + data: (points as typeof aPoints).map(p => [p.t, p.observations]), + lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, + })), + }; + }, [activityA, dataB, aligned, intervalMs, start, end, colors]); + const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + const groups = overlap.data && !overlap.isError ? [ + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary, textClass: "text-primary" }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green, textClass: "text-green" }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary, textClass: "text-secondary" }, + ] : []; + const refresh = () => { + const nextUntil = onRefresh(); + if (valid) { + void b.refetch(); + if (nextUntil === until) { + void activityB.refetch(); + if (aligned && overlapRange) void overlap.refetch(); + } + } + }; + const rows = aligned && b.data ? [ + [t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)], + [t("observerPage.packetsLastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], + [t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)], + [t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)], + [t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)], + [t("observerPage.noise"), statusNoise(observerA), statusNoise(b.data)], + ] : []; + return
+

{t("observerCompare.title")}

+
+

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

+ + {until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> +

{t("observerPage.window")}: {date(start)} – {date(end)}

+
{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
+ + {!overlapRange ?

{t("observerCompare.overlapShortRanges")}

: }> + {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <> +

{t("observerCompare.total", { count: overlap.data.totalPackets })}

+ {overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} +
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+ } + } + } +
; +} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx deleted file mode 100644 index 1dd9c43..0000000 --- a/src/features/observers/ObserverDetailPanel.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import type { Observer, AdvertObservation } from "./types"; -import { useQuery } from "@tanstack/react-query"; -import { getObserver, getObserverAdverts } 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 { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; -import { Timestamp } from "../../components/Timestamp"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "./observer-status"; -import type { BadgeVariant } from "../../components/badge-utils"; -import { IataChip } from "../../components/IataChip"; -import { ScopeTag } from "../../components/ScopeTag"; - -function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: () => void }) { - const level = snrLevel(advert.snr); - return ( -
-
- - {advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : "unknown")} - - {advert.iata} - -
-
-
- SNR - {formatSnr(advert.snr)} -
-
- RSSI - {advert.rssi ?? "—"} -
-
- Hops - {advert.hopCount ?? "—"} -
-
-
- ); -} - -interface Stats { - noise_floor?: number; - rx_air_secs?: number; - tx_air_secs?: number; - queue_len?: number; - recv_errors?: number; - errors?: number; - internal_heap?: number; -} - -// broker freshness badge: <5m = live, <30m = stale -function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { - if (!lastPacketAt) return "offline"; - const ageMs = Date.now() - lastPacketAt; - return ageMs < 5 * 60_000 ? "live" : ageMs < 30 * 60_000 ? "stale" : "offline"; -} - -// stats shape depends on the observer's firmware, so we just grab what we recognize -function getStats(metadata: Record | undefined): Stats | null { - if (!metadata?.stats || typeof metadata.stats !== "object") return null; - return metadata.stats as Stats; -} - -function formatAirtime(secs: number): string { - if (secs < 60) return `${secs}s`; - if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return `${h}h ${m}m`; -} - -function RadioSection({ observer, noiseFloor }: { observer: Observer; noiseFloor?: number | null }) { - const parts = [ - observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, - observer.radioSf && `SF${observer.radioSf}`, - observer.radioBwKhz && `${observer.radioBwKhz} kHz`, - observer.radioCr && `CR 4/${observer.radioCr}`, - ].filter(Boolean) as string[]; - - return ( -
-
- {parts.join(" · ")} -
- {noiseFloor != null && ( -
- -
- )} -
- ); -} - -interface ObserverDetailPanelProps { - observerId: string; - onClose: () => void; - onAnalyzePacket?: (hash: string) => void; - onViewStats?: (observerId: string) => void; -} - -export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) { - const { data: observer, isLoading } = useQuery({ - queryKey: ["observer", observerId], - queryFn: () => getObserver(observerId), - staleTime: 30_000, - }); - - const { data: adverts } = useQuery({ - queryKey: ["observer-adverts", observerId], - queryFn: () => getObserverAdverts(observerId, { limit: 50 }), - staleTime: 30_000, - }); - - useTick(); // re-derive the status badge as lastStatusAt ages - const stats = observer ? getStats(observer.statusMetadata) : null; - const status = observer ? deriveObserverStatus(observer) : null; - - return ( - } - isLoading={isLoading} - notFound={!observer} - notFoundLabel="Observer not found" - notFoundIcon={ - - - - - } - > - {observer && ( - <> -
-
- - {observer.displayName ?? observer.id.slice(0, 8)} - - - {status} - -
-
-
- {observer.publicKey} -
- -
-
- -
-
- {observer.observerType && {observer.observerType}} - {observer.iata} - {observer.scopes?.map((s) => ( - {s} - ))} -
- {onViewStats && ( - - )} -
- - {(observer.radioFreqMhz || observer.radioSf || observer.radioBwKhz || observer.radioCr) && ( - - )} - - {(observer.firmwareVersion || observer.softwareVersion || observer.hardwareModel) && ( -
-
- {observer.firmwareVersion && } - {observer.softwareVersion && } - {observer.hardwareModel && } -
-
- )} - -
-
- {observer.batteryLevel != null && } - {observer.uptimeSeconds != null && } - {stats?.queue_len != null && } -
- {observer.lastStatusAt && ( -
- } /> -
- )} -
- - {stats && (stats.rx_air_secs != null || stats.tx_air_secs != null || stats.recv_errors != null) && ( -
-
- {stats.rx_air_secs != null && } - {stats.tx_air_secs != null && } -
- {(stats.recv_errors != null || stats.errors != null) && ( -
- {stats.recv_errors != null && } - {stats.errors != null && } -
- )} -
- )} - - {observer.brokers.length > 0 && ( -
-
- {[...observer.brokers].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).map((b) => { - const variant = brokerStatusVariant(b.lastPacketAt); - return ( -
- {b.name} -
- } /> - : "—"} /> -
-
- ); - })} -
-
- )} - -
- {adverts && adverts.items.length > 0 ? ( -
- {adverts.items.map((a) => ( - onAnalyzePacket(a.packetHash) : undefined} - /> - ))} -
- ) : ( -
No adverts heard
- )} -
- -
-
- } /> - · - } /> -
-
- - )} -
- ); -} diff --git a/src/features/observers/ObserverFilterBar.tsx b/src/features/observers/ObserverFilterBar.tsx index e169aef..7c6bdb9 100644 --- a/src/features/observers/ObserverFilterBar.tsx +++ b/src/features/observers/ObserverFilterBar.tsx @@ -1,18 +1,10 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; import { SelectDropdown } from "../../components/SelectDropdown"; import { FilterSheet, FiltersButton } from "../../components/FilterSheet"; import { useIsMobile } from "../../hooks/useMediaQuery"; -const STATUS_OPTIONS = [ - { value: "online", label: "Online" }, - { value: "offline", label: "Offline" }, -]; - -const SEARCH_FIELDS: SearchFieldOption[] = [ - { value: "name", label: "Name" }, -]; - interface ObserverFilterBarProps { search: string; onSearchChange: (v: string) => void; @@ -29,6 +21,7 @@ interface ObserverFilterBarProps { scopeFilter: string; onScopeChange: (s: string) => void; scopeOptions: string[]; + trailing?: ReactNode; } export function ObserverFilterBar({ @@ -47,8 +40,15 @@ export function ObserverFilterBar({ scopeFilter, onScopeChange, scopeOptions, + trailing, }: ObserverFilterBarProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const statusOptions = [ + { value: "online", label: t("observerFilters.online") }, + { value: "offline", label: t("observerFilters.offline") }, + ]; + const searchFields: SearchFieldOption[] = [{ value: "name", label: t("observerFilters.name") }]; const [sheetOpen, setSheetOpen] = useState(false); // close the sheet when leaving mobile — derive during render, not in an effect if (sheetOpen && !isMobile) setSheetOpen(false); @@ -64,24 +64,25 @@ export function ObserverFilterBar({ // shared by the desktop inline bar and the mobile filter sheet const controls = (fullWidth: boolean) => ( <> - + {typeOptions.length > 0 && ( - ({ value: t, label: t }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} /> + ({ value: o, label: o }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} /> )} {brokerOptions.length > 0 && ( - ({ value: b, label: b }))} value={brokerFilter} onChange={onBrokerChange} fullWidth={fullWidth} /> + ({ value: b, label: b }))} value={brokerFilter} onChange={onBrokerChange} fullWidth={fullWidth} /> )} {scopeOptions.length > 0 && ( - ({ value: s, label: s }))} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} /> + ({ value: s, label: s }))} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} /> )} ); if (isMobile) { return ( -
- +
+ setSheetOpen(true)} /> + {trailing} {sheetOpen && ( setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}> {controls(true)} @@ -95,12 +96,12 @@ export function ObserverFilterBar({
@@ -108,6 +109,7 @@ export function ObserverFilterBar({ │ {controls(false)} + {trailing}
); } diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx new file mode 100644 index 0000000..5bc0833 --- /dev/null +++ b/src/features/observers/ObserverPage.tsx @@ -0,0 +1,111 @@ +import { lazy, Suspense, useState, useMemo } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getBrokers } from "../../api/client"; +import { CloseButton } from "../../components/CloseButton"; +import { CopyButton } from "../../components/CopyButton"; +import { MinimizeButton } from "../../components/DetailPanel"; +import { EmptyState } from "../../components/EmptyState"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { useScopes } from "../../hooks/useScopes"; +import { useTick } from "../../hooks/useTick"; +import { ObserverFilterBar } from "./ObserverFilterBar"; +import { ObserverSidebar } from "./ObserverSidebar"; +import { ObserverAdverts } from "./ObserverAdverts"; +import { filterObservers } from "./observer-filter"; +import { useObserverDirectory } from "./useObserverDirectory"; +import { observerDestination, observerRange } from "./observer-navigation"; +import { Segmented } from "../stats/Segmented"; +import type { WsManager } from "../../api/ws-manager"; +const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); + +export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsManager; onAnalyzePacket: (hash: string, observationId: number) => void }) { + const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); + const now = useTick(60_000); + const id = params.get("observer"); + const navigate = useNavigate(); + const inspected = (useLocation().state as { inspected?: boolean } | null)?.inspected; + const [actionTime, setActionTime] = useState(() => Date.now()); + const hourAt = (time: number) => Math.floor(time / 3_600_000) * 3_600_000; + const comparing = params.has("compareWith"); + const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : hourAt(actionTime); + const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= Math.max(now, actionTime); + const until = validAnchor ? anchor : null; + const range = observerRange(params.get("range")); + const [search, setSearch] = useState(""); + const [searchField, setSearchField] = useState("name"); + const [status, setStatus] = useState(""); + const [type, setType] = useState(""); + const [broker, setBroker] = useState(""); + const [scope, setScope] = useState(""); + const [minimized, setMinimized] = useState(false); + const directory = useObserverDirectory(wsManager, broker); + const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, staleTime: 60_000 }); + const scopeOptions = useScopes(scope); + const typeOptions = useMemo(() => [...new Set((directory.data ?? []).flatMap((o) => o.observerType ? [o.observerType] : []))].sort(), [directory.data]); + const observers = useMemo(() => filterObservers(directory.data ?? [], { search, status, type, scope }, now), [directory.data, search, status, type, scope, now]); + const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); + if (id) share.searchParams.set("range", range); + if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); + const select = (observer: string | null) => { setMinimized(false); setParams(observerDestination(params, observer, range)); }; + const compare = (observer: string) => { + // eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback. + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareWith", observer); + next.set("compareUntil", String(comparing ? until ?? hourAt(clickedAt) : hourAt(clickedAt))); + return next; + }); + }; + const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }); + const refreshCompare = () => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareUntil", String(hourAt(clickedAt))); + return next; + }, { replace: true }); + return hourAt(clickedAt); + }; + return
+ b.name) ?? []} + scopeFilter={scope} onScopeChange={setScope} scopeOptions={scopeOptions} + trailing={{t("observerPage.range")} + setParams(observerDestination(params, id, observerRange(v)))} /> + } + /> +
+
+ void directory.refetch()} range={range} selectedId={id} onSelect={select} /> +
+ {/* below md the dashboard overlays the list, like the node detail panel */} + {id ?
+
+ {t("observerPage.detail")} +
+ setMinimized(v => !v)} /> + inspected ? navigate(-1) : select(null)} label={t("observerPage.back")} /> +
+
+
+ {t("common.loading")}

}> + + + + } comparison={comparing ? { id: params.getAll("compareWith").length === 1 ? params.get("compareWith") ?? "" : "invalid", until, onSelect: compare, onRefresh: refreshCompare } : undefined} /> +
+
+
+
:
} +
+
; +} diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx new file mode 100644 index 0000000..4acad77 --- /dev/null +++ b/src/features/observers/ObserverPicker.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { Dropdown } from "../../components/Dropdown"; + +function PickerPanel({ id, onPick, excludeId, label }: { id: string; onPick: (id: string) => void; excludeId?: string; label: string }) { + const { t } = useTranslation(); + const { iatas, regionKey, isResolved } = useRegion(); + const [query, setQuery] = useState(""); + // debounce so the server-side lookup fires once per pause, not once per keystroke + const [q, setQ] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setQ(query.trim()), 250); + return () => clearTimeout(timer); + }, [query]); + const options = useQuery({ + queryKey: ["observer-picker", regionKey, q], + queryFn: () => getObserversPage(iatas, { name: q || undefined, limit: 50 }), + enabled: isResolved !== false, + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + const rows = (options.data?.items ?? []).filter((o) => o.id !== excludeId); + const status = options.isPending ? t("common.loading") : options.isError ? null : rows.length === 0 ? t("observerPage.noMatches") : null; + + return ( + <> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && rows[0]) { e.preventDefault(); onPick(rows[0].id); } + }} + aria-label={`${t("observerPage.sidebarSearch")} · ${label}`} + placeholder={`${t("observerPage.sidebarSearch")}…`} + className="w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-base text-text-normal placeholder:text-text-dim sm:text-[12px]" + /> +
+
+ {rows.map((o) => { + const selected = o.id === id; + return ( + + ); + })} +
+ {status &&
{status}
} + {options.isError && ( + + )} + + ); +} + +// One field that opens a searchable list; the search runs server-side across the whole region. +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const title = label ?? t("observerPage.choose"); + return ( + ( + + )} + > + {(close) => { onSelect(next); close(); }} />} + + ); +} diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx new file mode 100644 index 0000000..13d1a01 --- /dev/null +++ b/src/features/observers/ObserverSidebar.tsx @@ -0,0 +1,77 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getTopObservers } from "../../api/client"; +import { formatCount } from "../../lib/formatters"; +import { Segmented } from "../stats/Segmented"; +import { gatedOn, sinceFor, useStatsRegion } from "../stats/useStats"; +import type { StatsRange } from "../stats/types"; +import { observerName as nameOf } from "./observer-filter"; +import { deriveObserverStatus } from "./observer-status"; +import type { ObserverSummary } from "./types"; + +type Sort = "activity" | "name"; +const TOP = 200; // the API's list cap + +export function ObserverSidebar({ observers, filtered, isPending, isError, onRetry, range, selectedId, onSelect }: { + observers: ObserverSummary[]; filtered: boolean; isPending: boolean; isError: boolean; onRetry: () => void; + range: StatsRange; selectedId: string | null; onSelect: (id: string) => void; +}) { + const { t } = useTranslation(); + const { where, regionKey, isResolved } = useStatsRegion(); + const [sort, setSort] = useState("activity"); + + const activity = useQuery({ + queryKey: ["observer-sidebar-activity", regionKey, range], + ...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), TOP)), + staleTime: 30_000, + }); + + const counts = useMemo(() => new Map((activity.data ?? []).map((o) => [o.observerId, o.observationCount])), [activity.data]); + const max = Math.max(1, ...counts.values()); + const rows = useMemo(() => { + const byName = (a: ObserverSummary, b: ObserverSummary) => nameOf(a).localeCompare(nameOf(b), undefined, { numeric: true }); + return [...observers].sort(sort === "name" ? byName : (a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) || byName(a, b)); + }, [observers, sort, counts]); + + return ( + // a card at md+, an edge-to-edge list like the Nodes tab below it +
+
+
{t("tabs.Observers")}
+ setSort(v as Sort)} + options={[{ value: "activity", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> +
+ {isPending ?
{t("common.loading")}
+ : isError ? + : rows.length === 0 ?
{t(filtered ? "observerPage.noMatches" : "observerPage.none")}
+ : ( +
+ {rows.map((o) => { + const active = o.id === selectedId; + const count = counts.get(o.id); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx new file mode 100644 index 0000000..fad3d81 --- /dev/null +++ b/src/features/observers/ObserverSummary.tsx @@ -0,0 +1,117 @@ +import { useId, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import { CopyButton } from "../../components/CopyButton"; +import { formatAbsolute, formatBattery, formatRadioParts, formatUptime, timeAgoParts } from "../../lib/formatters"; +import { useTick } from "../../hooks/useTick"; +import { observerNoiseFloor } from "./observer-stats"; +import { Tooltip } from "../../components/Tooltip"; +import { IataChip } from "../../components/IataChip"; +import type { Observer } from "./types"; +import type { ObserverActivity, TelemetryPoint } from "../stats/types"; + +// Numbers at full size, units and words smaller, so "30d 16h 52m" and "-119 dBm" scan as values. +function Measure({ text }: { text: string }) { + if (!/\d/.test(text)) return <>{text}; + return <>{text.split(/(-?[\d.,]+)/).filter(Boolean).map((part, i) => + /\d/.test(part) ? {part} : {part})}; +} + +const DT = "text-[11px] text-text-dim"; + +export function ObserverSummary({ observer, activity, points, pending = false, actions }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean; actions?: ReactNode }) { + const { t, i18n } = useTranslation(); const now = useTick(); + const summary = activity?.summary; + const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; + const reportedNoise = observerNoiseFloor(observer); + const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; + const ago = (at: number) => { + const { count, unit } = timeAgoParts(at); + return t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); + }; + const headingId = useId(); + const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); + const client = observer.softwareVersion && !(observer.firmwareVersion && observer.softwareVersion.includes(observer.firmwareVersion)) ? observer.softwareVersion : null; + const device = ([ + [t("observerPage.model"), observer.hardwareModel], + [t("observerPage.firmware"), observer.firmwareVersion], + [t("observerPage.client"), client], + [t("observerPage.radio"), radio], + ] as const).filter((item): item is readonly [string, string] => !!item[1]); + const cards: { key: string; label: string; value: string; title?: string }[] = [ + { key: "records", label: t("observerPage.records"), value: summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—" }, + { + key: "lastHour", + label: t("observerPage.packetsLastHour"), + value: summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—", + }, + { + key: "lastPacket", + label: t("observerPage.lastPacket"), + value: summary?.latestRecordedAt != null ? ago(summary.latestRecordedAt) : "—", + title: summary?.latestRecordedAt != null ? formatAbsolute(summary.latestRecordedAt) : undefined, + }, + { key: "battery", label: t("observerPage.battery"), value: observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—" }, + { key: "uptime", label: t("observerPage.uptime"), value: observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—" }, + { + key: "noise", + label: t("observerPage.noise"), + value: noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—", + }, + ]; + return <> +
+
+
+

{observer.displayName ?? observer.id.slice(0, 8)}

+ + + + {observer.iata} +
+
+ {device.length > 0 && <> +
{t("observerPage.deviceInfo")}
+
+ {device.map(([label, value]) => {value})} +
+ } + {observer.publicKey && <> +
{t("observerPage.publicKey")}
+
+ {observer.publicKey.slice(0, 8)}…{observer.publicKey.slice(-7)} + +
+ } + {(observer.brokers ?? []).length > 0 && <> +
{t("observerPage.brokers")}
+
+ {[...observer.brokers].sort((x, y) => x.name.localeCompare(y.name, undefined, { numeric: true })).map((b) => ( + 0 ? ago(b.lastPacketAt) : "—"}`}> + + + {b.name} + + + + ))} +
+ } +
{t("observerPage.firstSeen")}
+
{observer.firstSeen ? : "—"}
+
+
+ {actions &&
{actions}
} +
+
    + {cards.map(({ key, label, value, title }) => { + const shown = ; + return
  • + {label} + {title ? {shown} : shown} +
  • ; + })} +
+ {!summary && !pending &&

{t("observerPage.summaryMissing")}

} + ; +} diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx deleted file mode 100644 index 21eab8e..0000000 --- a/src/features/observers/ObserverTable.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useState, useCallback, useMemo } from "react"; -import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; -import { getObserversPage, getBrokers } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; -import { useScopes } from "../../hooks/useScopes"; -import { useInfinitePages } from "../../hooks/useInfinitePages"; -import { patchInfinitePages } from "../../lib/infinite-pages"; -import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; -import { formatHex, formatRadio } from "../../lib/formatters"; -import { Badge } from "../../components/Badge"; -import { DataTable, type Column } from "../../components/DataTable"; -import { LoadingPill } from "../../components/LoadingPill"; -import { ObserverFilterBar } from "./ObserverFilterBar"; -import { ObserverDetailPanel } from "./ObserverDetailPanel"; -import { patchObserverSummary } from "./observer-updates"; -import { deriveObserverStatus } from "./observer-status"; -import { useTick } from "../../hooks/useTick"; -import type { ObserverSummary } from "./types"; -import type { CursorPage } from "../../types/api"; -import type { WsManager } from "../../api/ws-manager"; -import type { WsObserverStatus } from "../../types/ws"; - -const observerId = (o: ObserverSummary) => o.id; // stable id accessor for the paged hook's dedup - -interface ObserverTableProps { - wsManager: WsManager; - selectedObserverId: string | null; - onSelectObserver: (id: string | null) => void; - onAnalyzePacket?: (hash: string) => void; - onViewStats?: (observerId: string) => void; -} - -const COLUMNS: Column[] = [ - { - header: "Name", - sortValue: (obs) => obs.displayName ?? formatHex(obs.id), - cell: (obs) => ( -
- - - {obs.displayName ?? formatHex(obs.id)} - -
- ), - }, - { - header: "Type", - className: "text-text-muted", - sortValue: (obs) => obs.observerType ?? null, - cell: (obs) => obs.observerType ?? "—", - }, - { - header: "Radio", - className: "text-text-muted", - sortValue: (obs) => formatRadio(obs.radio) ?? null, - cell: (obs) => formatRadio(obs.radio) ?? "—", - }, - { - header: "IATA", - className: "text-text-normal", - sortValue: (obs) => obs.iata, - cell: (obs) => obs.iata, - }, - { - header: "Status", - sortValue: (obs) => deriveObserverStatus(obs), - cell: (obs) => { - const status = deriveObserverStatus(obs); - return {status}; - }, - }, -]; - -function renderObserverCard(obs: ObserverSummary) { - const status = deriveObserverStatus(obs); - return ( -
-
-
- - - {obs.displayName ?? formatHex(obs.id)} - -
- - {status} - -
-
- {obs.iata} - · {obs.observerType ?? "—"} - · {formatRadio(obs.radio) ?? "—"} -
-
- ); -} - -export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) { - const { iatas, regionKey } = useRegion(); - const queryClient = useQueryClient(); - const [search, setSearch] = useState(""); - const [searchField, setSearchField] = useState("name"); - const [statusFilter, setStatusFilter] = useState(""); - const [typeFilter, setTypeFilter] = useState(""); - const [brokerFilter, setBrokerFilter] = useState(""); - const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set - - useTick(); // keep recency-derived status badges fresh - - const { data: brokers } = useQuery({ - queryKey: ["brokers"], - queryFn: getBrokers, - staleTime: 60_000, - }); - - const brokerNames = useMemo( - () => brokers?.map((b) => b.name) ?? [], - [brokers], - ); - - const queryKey = useMemo( - () => ["observers", regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], - [regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], - ); - - // page the region's observers 50 at a time (filters stay server-side, in the query key); rows - // stream in as each batch lands. Loads once per filter set — WS status events keep them live. - const { items: observers, loadedCount, isPaging, isError, isLoading } = useInfinitePages({ - queryKey, - queryFn: (cursor) => - getObserversPage(iatas, { - cursor, - status: statusFilter || undefined, - type: typeFilter || undefined, - broker: brokerFilter || undefined, - name: searchField === "name" ? search || undefined : undefined, - }), - getId: observerId, - keepPrevious: true, - }); - - const typeOptions = useMemo(() => { - const types = new Set(); - for (const obs of observers) { - if (obs.observerType) types.add(obs.observerType); - } - return [...types].sort(); - }, [observers]); - - // scope options are the configured scopes; the filter itself is applied client-side on obs.scopes - const scopeOptions = useScopes(); - - const displayObservers = useMemo( - () => (scopeFilter ? observers.filter((o) => o.scopes?.includes(scopeFilter)) : observers), - [observers, scopeFilter], - ); - - // patch the live status into the paged cache (mirrors NodeTable). A brand-new observer not on any - // loaded page isn't pulled in here — it surfaces on the next reload/region switch (see the - // beacon-docs ticket about carrying the full summary in WS events for true live insertion). - const handleObserverStatus = useCallback( - (data: WsObserverStatus["data"]) => { - queryClient.setQueryData>>(queryKey, (old) => - patchInfinitePages(old, (items) => patchObserverSummary(items, data) ?? items), - ); - // refresh detail panel if it's showing this observer - if (selectedObserverId === data.observerId) { - queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] }); - } - }, - [queryClient, queryKey, selectedObserverId], - ); - - useWsObserverStatusHandler(wsManager, handleObserverStatus); - - return ( -
-
- - - o.id} - selectedKey={selectedObserverId} - onSelect={onSelectObserver} - isLoading={isLoading} - emptyLabel="No observers" - defaultSort={{ header: "Name" }} - renderCard={renderObserverCard} - /> - -
- - {selectedObserverId && ( - onSelectObserver(null)} - onAnalyzePacket={onAnalyzePacket} - onViewStats={onViewStats} - /> - )} -
- ); -} diff --git a/src/features/observers/observer-filter.ts b/src/features/observers/observer-filter.ts new file mode 100644 index 0000000..fe64158 --- /dev/null +++ b/src/features/observers/observer-filter.ts @@ -0,0 +1,22 @@ +import { deriveObserverStatus } from "./observer-status"; +import type { ObserverSummary } from "./types"; + +export interface ObserverFilters { + search: string; + status: string; + type: string; + scope: string; +} + +export const observerName = (o: ObserverSummary) => o.displayName ?? o.id.slice(0, 8); + +// Client-side over the loaded region list; broker isn't on the summary, so it stays a server filter. +export function filterObservers(list: ObserverSummary[], f: ObserverFilters, now: number): ObserverSummary[] { + const q = f.search.trim().toLowerCase(); + return list.filter((o) => + (!q || observerName(o).toLowerCase().includes(q)) && + (!f.status || deriveObserverStatus(o, now) === f.status) && + (!f.type || o.observerType === f.type) && + (!f.scope || (o.scopes?.includes(f.scope) ?? false)), + ); +} diff --git a/src/features/observers/observer-id.ts b/src/features/observers/observer-id.ts new file mode 100644 index 0000000..3695d5f --- /dev/null +++ b/src/features/observers/observer-id.ts @@ -0,0 +1,2 @@ +// excludes the nil UUID, which the API uses as a placeholder rather than a real observer id +export const OBSERVER_UUID = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts new file mode 100644 index 0000000..b086e0a --- /dev/null +++ b/src/features/observers/observer-navigation.ts @@ -0,0 +1,14 @@ +import type { StatsRange } from "../stats/types"; + +export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; + +export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { + const next = new URLSearchParams(params); + next.set("tab", "Observers"); + for (const key of ["statsTab", "observerId", "node", "route", "routeIata", "compareA", "compareB", "compareSince"]) next.delete(key); + // compareUntil is shared with the analytics compare; it only belongs here alongside compareWith + if (!id || !next.has("compareWith")) next.delete("compareUntil"); + if (id) { next.set("observer", id); next.set("range", range); } + else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); } + return next; +} diff --git a/src/features/observers/observer-stats.ts b/src/features/observers/observer-stats.ts new file mode 100644 index 0000000..2cd114b --- /dev/null +++ b/src/features/observers/observer-stats.ts @@ -0,0 +1,22 @@ +import type { Observer } from "./types"; + +export interface Stats { + noise_floor?: number; + rx_air_secs?: number; + tx_air_secs?: number; + queue_len?: number; + recv_errors?: number; + errors?: number; + internal_heap?: number; +} + +// stats shape depends on the observer's firmware, so we just grab what we recognize +export function getStats(metadata: Record | undefined): Stats | null { + if (!metadata?.stats || typeof metadata.stats !== "object") return null; + return metadata.stats as Stats; +} + +export function observerNoiseFloor(observer: Pick): number | null { + const stats = getStats(observer.statusMetadata); + return stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; +} diff --git a/src/features/observers/useObserverDirectory.ts b/src/features/observers/useObserverDirectory.ts new file mode 100644 index 0000000..138be4d --- /dev/null +++ b/src/features/observers/useObserverDirectory.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; +import { patchObserverSummary } from "./observer-updates"; +import type { ObserverSummary } from "./types"; +import type { WsManager } from "../../api/ws-manager"; +import type { WsObserverStatus } from "../../types/ws"; + +const PAGE = 200; // the API's list cap +const MAX_PAGES = 10; + +// The whole region fits in a page or two, so sorting and the other filters stay client-side and instant. +async function allObservers(iatas: string[] | undefined, broker: string): Promise { + const items: ObserverSummary[] = []; + let cursor: number | undefined; + for (let i = 0; i < MAX_PAGES; i++) { + const page = await getObserversPage(iatas, { cursor, limit: PAGE, broker: broker || undefined }); + items.push(...page.items); + if (!page.hasMore || page.nextCursor == null) break; + cursor = page.nextCursor; + } + return items; +} + +// The region's observers, kept live by WS status events between the minute refetches. +export function useObserverDirectory(wsManager: WsManager, broker: string) { + const { iatas, regionKey, isResolved } = useRegion(); + const queryClient = useQueryClient(); + const queryKey = useMemo(() => ["observer-directory", regionKey, broker], [regionKey, broker]); + const list = useQuery({ queryKey, queryFn: () => allObservers(iatas, broker), enabled: isResolved !== false, staleTime: 30_000, refetchInterval: 60_000 }); + const onStatus = useCallback( + (data: WsObserverStatus["data"]) => queryClient.setQueryData(queryKey, (old) => patchObserverSummary(old, data)), + [queryClient, queryKey], + ); + useWsObserverStatusHandler(wsManager, onStatus); + return list; +} diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx index a690933..6f155f3 100644 --- a/src/features/packets/ObservationCard.tsx +++ b/src/features/packets/ObservationCard.tsx @@ -1,68 +1,91 @@ +import type { ReactNode } from "react"; import type { Observation } from "../../types/api"; -import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES, SIGNAL_LEVEL_BORDER_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { PathData } from "./PathData"; import { IataChip } from "../../components/IataChip"; +import { useTranslation } from "react-i18next"; // single observation with signal stats and resolved path -export function ObservationCard({ observation: obs, selected, onClick, onViewNode, isTrace }: { observation: Observation; selected?: boolean; onClick?: () => void; onViewNode?: (nodeId: string) => void; isTrace?: boolean }) { +export function ObservationCard({ observation: obs, selected, onClick, onViewNode, isTrace, snrValues, selectLabel, actions }: { + observation: Observation; selected?: boolean; onClick?: () => void; onViewNode?: (nodeId: string) => void; isTrace?: boolean; + // TRACE only: SNR per hop from the packet's parsed payload + snrValues?: number[]; + // when set, the summary becomes a keyboard-selectable button with this name + selectLabel?: string; + actions?: ReactNode; +}) { + const { t } = useTranslation(); const level = snrLevel(obs.snr); return (
-
- {obs.observerName ?? obs.observerId.slice(0, 8)} - {obs.iata} - -
+
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick?.(); } } : undefined} + > +
+ {obs.observerName ?? obs.observerId.slice(0, 8)} + {obs.iata} + +
-
-
- SNR - - {formatSnr(obs.snr)} - -
-
- RSSI - - {obs.rssi ?? "—"} - -
-
- Prop - {formatPropagation(obs.propagationTimeMs)} -
-
- Hops - {obs.pathLength.hopCount} + {/* equal columns keep stats aligned across stacked cards and fitting any card width */} +
+
+ SNR + + {formatSnr(obs.snr)} + +
+
+ RSSI + + {obs.rssi ?? "—"} + +
+
+ {t("observation.prop")} + {formatPropagation(obs.propagationTimeMs)} +
+
+ {t("observerPage.hops")} + {obs.pathLength.hopCount} +
+ + {obs.radio && ( +
+ {t("observerPage.radio")} + {obs.radio.freqMhz != null && {obs.radio.freqMhz} MHz} + {obs.radio.spreadFactor != null && <>·SF{obs.radio.spreadFactor}} + {obs.radio.bandwidthKhz != null && <>·{obs.radio.bandwidthKhz} kHz} + {obs.radio.codingRate != null && <>·CR 4/{obs.radio.codingRate}} +
+ )}
{obs.pathBytes && (
- {isTrace ? ( - // TRACE path bytes are per-hop SNR samples, not hop hashes — show them raw, never as a resolvable path. - <> - Path SNR - {obs.pathBytes.toUpperCase()} - - ) : ( - <> - Path - - - )} + {t("observation.path")} +
)} + {actions &&
e.stopPropagation()}>{actions}
}
); } diff --git a/src/features/packets/ObservationTable.tsx b/src/features/packets/ObservationTable.tsx index f81b389..0fa3bfa 100644 --- a/src/features/packets/ObservationTable.tsx +++ b/src/features/packets/ObservationTable.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Observation } from "../../types/api"; import { formatSnr, formatPropagation, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -11,18 +12,19 @@ interface Props { // Per-observer readings vary by distance; presentational component owned by caller. export function ObservationTable({ observations, selectedId, onSelect }: Props) { + const { t } = useTranslation(); return ( - - - + + + - - - + + + diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 18a7fb3..5eaba78 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import type { PacketDetail } from "../../types/api"; @@ -10,11 +11,12 @@ import { payloadTypeVariant } from "../../components/badge-utils"; import { ScopeTag } from "../../components/ScopeTag"; import { formatHex, formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; -import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, ObservationDetail } from "./packet-structure"; +import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, transportCodeHex } from "./packet-structure"; import { PayloadBreakdown } from "./payload-renderers"; -import { ObservationCard } from "./ObservationCard"; import { PathData } from "./PathData"; import { buildPacketPaths } from "../map/packet-path"; +import { PacketInvestigation } from "./PacketInvestigation"; +import { reportSelection } from "./packet-investigation"; function decodePayloadHex(encoded: string): string | null { try { @@ -33,32 +35,42 @@ interface PacketAnalyzerDrawerProps { onClose: () => void; onSelectObservation?: (id: number) => void; onViewNode?: (nodeId: string) => void; - onViewPath?: () => void; + onViewPath?: (key?: string) => void; + onViewObserver?: (id: string, observationId?: number) => void; loading?: boolean; + requireSelectedObservation?: boolean; + syncUrl?: boolean; } // side panel (full-screen on mobile) showing packet structure and payload breakdown -export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, loading }: PacketAnalyzerDrawerProps) { - const [, setSearchParams] = useSearchParams(); +export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, onViewObserver, loading, requireSelectedObservation = false, syncUrl = true }: PacketAnalyzerDrawerProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const { t } = useTranslation(); - const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); + const pathKeys = useMemo(() => new Set(detail ? buildPacketPaths(detail).map(p => p.key) : []), [detail]); + const hasPath = pathKeys.size > 0; + const observerCount = useMemo(() => new Set(detail?.observations.map(o => o.observerId)).size, [detail]); // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { - setSearchParams((p) => { + if (syncUrl) setSearchParams((p) => { const n = new URLSearchParams(p); n.delete("analyze"); return n; }, { replace: true }); onClose(); - }, [setSearchParams, onClose]); + }, [setSearchParams, onClose, syncUrl]); - const selectedObs = detail?.observations.find((o) => o.id === selectedObservationId) - ?? detail?.observations[0] - ?? null; + const urlMatches = syncUrl && detail != null && searchParams.get("hash")?.toLowerCase() === detail.packetHash.toLowerCase(); + const selectionParams = requireSelectedObservation && selectedObservationId != null ? new URLSearchParams({ observation: String(selectedObservationId) }) : urlMatches ? searchParams : new URLSearchParams(); + const { selected: selectedObs, unavailable } = reportSelection(detail?.observations ?? [], selectionParams, selectedObservationId); + const selectReport = (id: number) => { + if (urlMatches) setSearchParams(prev => { const next = new URLSearchParams(prev); next.set("observation", String(id)); return next; }, { replace: true }); + onSelectObservation?.(id); + }; - const rawHex = detail ? buildObservationFrame(detail, selectedObs) : ""; + const rawHex = detail && !unavailable ? buildObservationFrame(detail, selectedObs) : ""; const totalBytes = rawHex.length / 2; const fieldRanges = detail @@ -70,10 +82,10 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o return (
- Packet Analyzer + {t("packetAnalyzer.title")}
- {detail && } - + {detail && ({ tab: "Packets", route: null, routeIata: null, hash: detail.packetHash, analyze: "1", path: null, node: null, observer: null, observerId: null, statsTab: null, compareWith: null, compareUntil: null, observation: selectedObs ? String(selectedObs.id) : null })} label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPacket")} />} +
@@ -85,96 +97,78 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o - {loading ? "Loading…" : "Select a packet to analyze"} + {loading ? t("common.loading") : t("packetAnalyzer.empty")}
) : ( <> - +
{formatHex(detail.packetHash)} - {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "Unknown"} + {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? t("observerPage.unknown")} - {detail.scope && {detail.scope}} + {detail.scope && {detail.scope}} - ×{detail.observations.length} + ×{observerCount}
- First + {t("investigation.first")} · - Last + {t("investigation.last")} · - Propagation {formatPropagation(detail.firstToLastMs)} + {t("investigation.span")} {formatPropagation(detail.firstToLastMs)}
+ {unavailable &&

{t("investigation.unavailableReport")}

} +
- {selectedObs && ( - - - - )} - - {detail.observations.length >= 1 && ( - -
- {detail.observations.map((obs) => ( - onSelectObservation(obs.id) : undefined} - onViewNode={onViewNode} - isTrace={detail.header.payloadType === PayloadType.TRACE} - /> - ))} -
-
- )} + {rawHex && ( - +
)} - +
{/* Header byte */} -
Header Byte
+
{t("packetAnalyzer.headerByte")}
- Ver {detail.header.payloadVersion} - Type {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType}) - Route {ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType}) + {t("packetAnalyzer.ver")} {detail.header.payloadVersion} + {t("packetAnalyzer.type")} {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType}) + {t("packetAnalyzer.route")} {ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType})
{headerHex && ( @@ -184,18 +178,31 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {/* Transport codes */} {detail.transportCodes && ( - Transport - {detail.transportCodes.regionCode} / {detail.transportCodes.subRegionCode} +
{t("packetAnalyzer.transportCodes")}
+
+ {t("packetAnalyzer.scopeCode")} + {transportCodeHex(detail.transportCodes.regionCode).toUpperCase()} + (2B) = + {detail.transportCodes.regionCode} + → + {detail.scope ? {detail.scope} : {t("packetAnalyzer.noKnownScope")}} +
+
+ {t("packetAnalyzer.code2")} + {transportCodeHex(detail.transportCodes.subRegionCode).toUpperCase()} + (2B) = + {detail.transportCodes.subRegionCode} +
)} {/* Path length */} {selectedObs && ( -
Path Length
+
{t("packetAnalyzer.pathLength")}
- Hash Size {selectedObs.pathLength.hashSize}B - Hops {selectedObs.pathLength.hopCount} + {t("packetAnalyzer.hashSize")} {selectedObs.pathLength.hashSize}B + {t("observerPage.hops")} {selectedObs.pathLength.hopCount}
@@ -205,14 +212,14 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o and resolvedPath), so it resolves through PathData like every other type. */} {selectedObs?.pathBytes && ( -
Path Data
+
{t("packetAnalyzer.pathData")}
)} {detail.originPubkey && detail.header.payloadType !== PayloadType.ADVERT && ( - Origin Pubkey + {t("packetAnalyzer.originPubkey")}
{detail.originPubkey}
)} @@ -220,7 +227,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && ( - +
@@ -231,7 +238,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o const hex = decodePayloadHex(detail.parsedPayload); if (!hex) return null; return ( - +
                       {(hex.match(/.{1,2}/g) ?? []).reduce((acc, b, i) => {
@@ -240,7 +247,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
                       }, "")}
                     
-
{hex.length / 2} bytes
+
{t("packetAnalyzer.bytes", { count: hex.length / 2 })}
); })()} diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx deleted file mode 100644 index ec10bf8..0000000 --- a/src/features/packets/PacketAnalyzerOverlay.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useState } from "react"; -import type { PacketDetail } from "../../types/api"; -import { PacketAnalyzerDrawer } from "./PacketAnalyzerDrawer"; -import { NodeDetailOverlay } from "../nodes/NodeDetailOverlay"; -import { ModalOverlay } from "../../components/ModalOverlay"; - -// Packet analyzer floated over a node detail panel (mirror of NodeDetailOverlay). The node detail it -// can stack on top gets no onAnalyzePacket, so the overlay chain stops there instead of recursing. -export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver, onViewPath, inactive = false }: { - detail: PacketDetail | undefined; - loading?: boolean; - onClose: () => void; - onViewObserver: (observerId: string) => void; - onViewPath?: () => void; - inactive?: boolean; -}) { - const [selectedObservationId, setSelectedObservationId] = useState(null); - const [viewNodeId, setViewNodeId] = useState(null); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - // peel back one layer at a time: the nested node overlay handles its own Escape, so only - // close the analyzer once nothing is stacked above it - if (e.key === "Escape" && !viewNodeId && !inactive) onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose, viewNodeId, inactive]); - - return ( - <> - - - - {viewNodeId && ( - setViewNodeId(null)} - onViewObserver={onViewObserver} - /> - )} - - ); -} diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 33e5822..e921453 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,6 +1,7 @@ -import type { PacketSummary } from "../../types/api"; -import type { PathConfidence } from "../../types/enums"; -import { buildPathSummary, type PathChip } from "./path-summary"; +import { useTranslation } from "react-i18next"; +import type { PacketSummary, ResolvedHop, ResolvedNode } from "../../types/api"; +import { PayloadType, type PathConfidence } from "../../types/enums"; +import { HopPopover } from "./PathData"; // Same three-state vocabulary PathData uses in the analyzer. const CONFIDENCE_CLASSES: Record = { @@ -9,39 +10,53 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; -function Chip({ chip }: { chip: PathChip }) { - if (chip.kind === "hex") { - return {chip.label}; - } - if (chip.kind === "unresolved-run") { - return ( - - {chip.count === 1 ? "?" : `?×${chip.count}`} - - ); - } +const nodeLabel = (node: ResolvedNode) => node.name ?? node.publicKey.slice(0, 8); + +function Chip({ hop }: { hop: ResolvedHop }) { + const { t } = useTranslation(); + const node = hop.nodes[0]; + const label = node ? nodeLabel(node) : "?"; return ( - - {chip.label} - + + + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} + + ); } -const Na = () => n/a; +function Na() { + const { t } = useTranslation(); + return {t("packetEndpoints.na")}; +} -// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them -// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed -// endpoint at all (GRP_TXT/GRP_DATA/TRACE). +// Keep every candidate: a short endpoint hash can match several nodes. export function PacketEndpoints({ packet }: { packet: PacketSummary }) { - const { source, destination } = buildPathSummary(packet); - // One n/a for the pair reads better than "n/a → n/a" on every historical row. - if (!source && !destination) return ; + const source = packet.latestObserver?.resolvedSource; + const destination = packet.latestObserver?.resolvedDestination; + + // An advert is the node announcing itself, so its name is the whole story — no destination. + if (packet.payloadType === PayloadType.ADVERT) { + if (source) return ; + return packet.summary + ? {packet.summary} + : ; + } + + if (!source && !destination) { + return packet.summary + ? {packet.summary} + : ; + } return ( - {source ? : } + {source ? : } → - {destination ? : } + {destination ? : } ); } diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index 043d10d..c3be9ae 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -1,20 +1,26 @@ import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { PacketSummary } from "../../types/api"; import { formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { usePacketDetail } from "./usePacketDetail"; import { ObservationTable } from "./ObservationTable"; +import { ObservationCard } from "./ObservationCard"; +import { useIsMobile } from "../../hooks/useMediaQuery"; +import { PayloadType } from "../../types/enums"; import { buildPacketPaths } from "../map/packet-path"; +import { traceSnrValues } from "./packet-investigation"; // Roughly what fits the scroll cap; observations are unbounded server-side. const SKELETON_ROW_CAP = 12; +// full-width and taller below md for a real tap target; desktop keeps its inline density const ACTION_BUTTON_CLASS = - "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; + "border border-border rounded-sm w-full md:w-auto px-3 py-2 md:px-2 md:py-0.5 text-[11px] md:text-[10px] bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; interface Props { packet: PacketSummary; - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -24,6 +30,8 @@ interface Props { // the per-observer table, which does wait on usePacketDetail. export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + const isMobile = useIsMobile(); + const { t } = useTranslation(); const observer = packet.latestObserver; // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is // already in ms for formatPropagation -- no *1000 here. @@ -33,45 +41,49 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb // The summary already knows the count is zero, so skip the fetch-driven states entirely rather // than showing a blank (0-row) skeleton while it loads. const noObservations = packet.observationCount === 0; - const emptyState =
No observations
; + const emptyState =
{t("packetRow.noObservations")}
; // Picking an observation is the way into the analyzer — it opens on the one you clicked. const handleSelectObservation = useCallback( (id: number) => { onSelectObservation(id); - onOpenAnalyzer(); + onOpenAnalyzer(id); }, [onSelectObservation, onOpenAnalyzer], ); + // below md the expansion is the bottom half of the tapped card (the row's own border-b is the + // divider); at md+ it stays the flat left-accented strip. return ( -
-
- - observer{" "} +
+
+ {/* the mobile card header right above already names the latest observer */} + + {t("packetRow.observer")}{" "} {observer ? {observer.displayName ?? observer.id.slice(0, 8)} - : n/a} + : {t("packetEndpoints.na")}} - first - last - spread {formatPropagation(spread)} + {t("packetRow.first")} + {t("packetRow.last")} + {t("packetRow.spread")} {formatPropagation(spread)}
-
+ {/* capped scroller is desktop-only; on touch it would trap the page scroll */} +
{isError ? ( -
- Failed to load observations -
) : noObservations ? ( @@ -89,7 +101,24 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb ) : data && data.observations.length === 0 ? ( emptyState ) : data ? ( - + // The table's eight columns need ~407px, so below md its Path column lands off-screen + // behind a nested sideways scroll. Cards give the path a full-width row of its own. + isMobile ? ( +
+ {data.observations.map((o) => ( + handleSelectObservation(o.id)} + isTrace={data.header.payloadType === PayloadType.TRACE} + snrValues={traceSnrValues(data)} + /> + ))} +
+ ) : ( + + ) ) : null}
diff --git a/src/features/packets/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx new file mode 100644 index 0000000..892d4e1 --- /dev/null +++ b/src/features/packets/PacketInvestigation.tsx @@ -0,0 +1,68 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { InfoTip } from "../../components/InfoTip"; +import { Tooltip } from "../../components/Tooltip"; +import type { PacketDetail } from "../../types/api"; +import { PayloadType } from "../../types/enums"; +import { ObservationCard } from "./ObservationCard"; +import { groupPacketReports, traceSnrValues } from "./packet-investigation"; + +const PAGE = 3; + +// Every retained reception, grouped by the path bytes it carried; each report is a selectable signal card. +export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserver, onViewPath, onViewNode, mappedKeys, observerCount, defaultOpen = false }: { + detail: PacketDetail; selectedId: number | null; onSelect: (id: number) => void; + onViewObserver?: (id: string, observationId: number) => void; onViewPath?: (key?: string) => void; onViewNode?: (id: string) => void; + mappedKeys: Set; observerCount: number; defaultOpen?: boolean; +}) { + const { t } = useTranslation(); + const groups = useMemo(() => groupPacketReports(detail.observations), [detail.observations]); + const isTrace = detail.header.payloadType === PayloadType.TRACE; + const [shown, setShown] = useState(PAGE); + const [open, setOpen] = useState(defaultOpen); + // a selected report deeper in the list stays visible even before it's paged in + const visible = groups.filter((group, index) => index < shown || group.reports.some(o => o.id === selectedId)); + const remaining = Math.max(0, groups.length - shown); + return
+
+

+ +
+

{detail.observations.length === observerCount ? t("investigation.heardBy", { count: observerCount }) : `${t("investigation.heardTimes", { count: detail.observations.length })} ${t("investigation.byObservers", { count: observerCount })}`}

+ {groups.length === 0 ?

{t("investigation.empty")}

: open && visible.map(group =>
o.id === selectedId)} className="group/path"> + + ▶ + {t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })} + {group.hashes.length > 0 && {group.hashes.join(" → ")}} + ×{group.reports.length} + +
    {group.reports.map(report => { + const key = isTrace ? "trace" : report.observerId; + const canMap = mappedKeys.has(key); + const name = report.observerName ?? report.observerId.slice(0, 8); + return
  • + onSelect(report.id)} + onViewNode={onViewNode} + isTrace={isTrace} + snrValues={traceSnrValues(detail)} + selectLabel={`${t("investigation.inspect")}: ${name}`} + actions={<> + {onViewObserver && } + {canMap + ? + : } + } + /> +
  • ; + })}
+
)} + {open && remaining > 0 && } +
; +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 0340b6b..de11a41 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,9 +1,11 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; import { usePacketDetail } from "./usePacketDetail"; import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; +import { parsePathSearch } from "./path-search"; import { useScopes } from "../../hooks/useScopes"; import { useRegion } from "../../hooks/useRegion"; import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers"; @@ -11,6 +13,7 @@ import { PacketVirtualList } from "./PacketVirtualList"; import { FilterBar } from "../../components/FilterBar"; import { LoadingPill } from "../../components/LoadingPill"; import { SkeletonRows } from "../../components/SkeletonRows"; +import { CloseButton } from "../../components/CloseButton"; import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; import type { WsManager } from "../../api/ws-manager"; import type { PacketDetail } from "../../types/api"; @@ -30,7 +33,7 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; - onAnalyze: (hash: string | null) => void; + onAnalyze: (hash: string | null, observationId?: number) => void; onViewPath: (detail: PacketDetail) => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -40,11 +43,13 @@ interface PacketListProps { export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); + const { t } = useTranslation(); const queryClient = useQueryClient(); 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 pathSearch = useMemo(() => parsePathSearch(filters.search), [filters.search]); + const scopeNames = useScopes(filters.scopes); const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]); const { regionKey } = useRegion(); @@ -61,6 +66,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati acknowledgeNewPackets, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -72,27 +78,32 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati } = usePackets(!isAtTop, serverFilter); const packets = useMemo( - () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)), - [allPackets, filters, observersByHash], + () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash, pathSearch)), + [allPackets, filters, observersByHash, pathSearch], ); // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). - const expandedHash = searchParams.get("hash"); + // Lowercased because packet hashes are lowercase hex and a shared link may not be. + const expandedHash = searchParams.get("hash")?.toLowerCase() ?? null; const handleToggleExpand = useCallback((hash: string) => { const next = expandedHash === hash ? null : hash; setSearchParams((p) => { const n = new URLSearchParams(p); + n.delete("observation"); if (next) n.set("hash", next); else n.delete("hash"); return n; }, { replace: true }); }, [expandedHash, setSearchParams]); // Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request. - const { data: expandedDetail } = usePacketDetail(expandedHash); + const { data: expandedDetail, isError: selectedError, error: detailError, refetch: retryDetail } = usePacketDetail(expandedHash); + const detailStatus = detailError && "status" in detailError ? detailError.status : undefined; - const handleOpenAnalyzer = useCallback(() => { - if (expandedHash) onAnalyze(expandedHash); + const handleOpenAnalyzer = useCallback((observationId?: number) => { + if (!expandedHash) return; + if (observationId == null) onAnalyze(expandedHash); + else onAnalyze(expandedHash, observationId); }, [expandedHash, onAnalyze]); const handleViewPath = useCallback(() => { @@ -167,10 +178,38 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati onClear={clearFilters} /> + {filters.searchField === "path" && ( +

+ {pathSearch === null + ? t("packetList.pathSearchInvalid") + : t("packetList.pathSearchHint")} +

+ )} + + {/* A hash-only link still selects a row; offer the existing analyzer when there is no row. */} + {expandedHash && !isLoading && searchParams.get("analyze") !== "1" && !packets.some(p => p.packetHash === expandedHash) && ( +
+
+ {selectedError ? ( + <> +

{detailStatus === 400 ? t("packetList.invalidHash") : detailStatus === 404 ? t("packetList.notFound") : t("packetList.loadFailed")}

+ + + ) : expandedDetail ? ( + <> +

{t("packetList.outsideResults")}

+ + + ) :

{t("packetList.loadingSelected")}

} +
+ handleToggleExpand(expandedHash)} /> +
+ )} + {laggedCount > 0 && (
- {laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete - + {t("packetList.dropped", { count: laggedCount })} +
)} @@ -181,13 +220,13 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati onClick={handleScrollToTop} > ▲ - {bannerCount} new packet{bannerCount === 1 ? "" : "s"} - · scroll to top + {t("packetList.newPackets", { count: bannerCount })} + {t("packetList.scrollToTop")} ) : (
- Live Packets + {t("packetList.live")}
)} @@ -198,7 +237,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati key={listResetKey} packets={packets} hasNextPage={hasNextPage} - isFetchingNextPage={isFetchingNextPage} + isFetching={isFetching} fetchNextPage={fetchNextPage} onScrollAwayFromTop={setIsScrolledAway} onAtTopChange={setIsAtTop} diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 36812cc..2e3b1a1 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { formatHex } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import type { PacketSummary } from "../../types/api"; @@ -17,11 +18,14 @@ interface PacketRowProps { // selectable packet card; observations live in the analyzer drawer export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProps) { + const { t } = useTranslation(); + const heardBy = t("packetRow.heardBy", { count: packet.observationCount }); return (
{PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName} - + ×{packet.observationCount}
+ {packet.summary &&
{packet.summary}
} +
- {packet.routeTypeName || "Unknown"} + {packet.routeTypeName || t("packetRow.unknown")} {packet.scope && ( <> · - {packet.scope} + + {packet.scope} + )} · diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx index 97ae3da..b8b919c 100644 --- a/src/features/packets/PacketTableHeader.tsx +++ b/src/features/packets/PacketTableHeader.tsx @@ -1,22 +1,24 @@ +import { useTranslation } from "react-i18next"; import { GRID_TEMPLATE } from "./packet-grid"; // Sticky above the virtualizer's spacer, never inside measured item space. export function PacketTableHeader() { + const { t } = useTranslation(); return (
- Hash - Type - Route - Obs - Hops - Hash Size - Src → Dst - IATA - Age + {t("packetTable.hash")} + {t("packetAnalyzer.type")} + {t("packetAnalyzer.route")} + {t("packetTable.obs")} + {t("packetTable.hops")} + {t("packetAnalyzer.hashSize")} + {t("packetTable.summary")} + {t("packetTable.area")} + {t("packetTable.age")}
); } diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 95a430f..90aaafd 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { formatHex } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { Badge } from "../../components/Badge"; @@ -15,12 +16,13 @@ interface PacketTableRowProps { onToggle: () => void; } -// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the -// expansion instead, which frees the wide column for the packet's endpoints. +// The observer lives in the expansion, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { + const { t } = useTranslation(); // ?? not ||, so a legitimate 0-hop direct packet still shows its count const pathLength = packet.latestObserver?.pathLength; - const na = n/a; + const routeName = packet.routeTypeName || t("packetTable.unknown"); + const na = {t("packetEndpoints.na")}; return (
@@ -50,9 +52,9 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa {PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName} - - {packet.routeTypeName || "Unknown"} - {packet.scope && {packet.scope}} + + {routeName} + {packet.scope && {packet.scope}} ×{packet.observationCount} {pathLength?.hopCount ?? na} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 4cc15ad..714e4ec 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -1,5 +1,6 @@ import { useRef, useCallback, useLayoutEffect } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { useTranslation } from "react-i18next"; import type { PacketSummary } from "../../types/api"; import { PacketTableHeader } from "./PacketTableHeader"; import { PacketTableRow } from "./PacketTableRow"; @@ -16,14 +17,14 @@ import { interface PacketVirtualListProps { packets: PacketSummary[]; hasNextPage: boolean; - isFetchingNextPage: boolean; + isFetching: boolean; fetchNextPage: () => void; onScrollAwayFromTop: (isAway: boolean) => void; onAtTopChange: (isAtTop: boolean) => void; expandedHash: string | null; onToggleExpand: (hash: string) => void; // only the expanded row renders an expansion, so these need no hash argument - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -34,7 +35,7 @@ interface PacketVirtualListProps { export function PacketVirtualList({ packets, hasNextPage, - isFetchingNextPage, + isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange, @@ -45,6 +46,7 @@ export function PacketVirtualList({ selectedObservationId, onSelectObservation, }: PacketVirtualListProps) { + const { t } = useTranslation(); const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); const isMobile = useIsMobile(); @@ -71,13 +73,13 @@ export function PacketVirtualList({ onScrollAwayFromTop(el.scrollTop > SCROLL_TOP_THRESHOLD_PX); onAtTopChange(atTopRef.current); - if (hasNextPage && !isFetchingNextPage) { + if (hasNextPage && !isFetching) { const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (distFromBottom < SCROLL_BOTTOM_THRESHOLD_PX) { fetchNextPage(); } } - }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); + }, [hasNextPage, isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); // 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 @@ -152,6 +154,21 @@ export function PacketVirtualList({ ); })}
+ {packets.length === 0 && ( +

{t("packetList.noMatching")}

+ )} + {hasNextPage && ( +
+ +
+ )}
); } diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 90f20e1..b13b247 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { useHasHover } from "../../hooks/useMediaQuery"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import type { ResolvedHop, ResolvedNode } from "../../types/api"; @@ -17,12 +18,13 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -function HopPopover({ hop, onViewNode, showSnr = true, children }: { +export function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -94,7 +96,7 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - className="inline-flex" + className="inline-flex min-w-0 max-w-full" > {children} {anchor && @@ -108,7 +110,7 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { className={`fixed z-50 flex flex-col gap-0.5 whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg ${clickable ? "" : "pointer-events-none"}`} > {nodes.length === 0 ? ( - "No Path Resolutions Available" + t("packetRow.noResolution") ) : clickable ? ( nodes.map((node) => (
@@ -337,6 +341,7 @@ export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: num } export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { + const { t } = useTranslation(); const bits = formatBinary(flagsByte); const roleBits = bits.slice(4, 8); const loc = bits[3]; @@ -358,7 +363,7 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { | {loc} - loc + {t("packetAnalyzer.bits.loc")} | @@ -373,7 +378,7 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { | {nm} - name + {t("packetAnalyzer.bits.name")} ]
@@ -396,36 +401,3 @@ export function ColorAccentField({
); } - -export function ObservationDetail({ observation }: { observation: Observation }) { - const level = snrLevel(observation.snr); - const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; - - return ( -
-
- {observation.observerName ?? observation.observerId.slice(0, 8)} - {observation.iata} - -
- -
- SNR {formatSnr(observation.snr)} - RSSI {observation.rssi ?? "—"} - Prop {formatPropagation(observation.propagationTimeMs)} - Hops {observation.pathLength.hopCount} -
- - {observation.radio && ( -
- Radio - {observation.radio.freqMhz != null && {observation.radio.freqMhz} MHz} - {observation.radio.spreadFactor != null && <>·SF{observation.radio.spreadFactor}} - {observation.radio.bandwidthKhz != null && <>·{observation.radio.bandwidthKhz} kHz} - {observation.radio.codingRate != null && <>·CR 4/{observation.radio.codingRate}} -
- )} - -
- ); -} diff --git a/src/features/packets/path-search.ts b/src/features/packets/path-search.ts new file mode 100644 index 0000000..2407911 --- /dev/null +++ b/src/features/packets/path-search.ts @@ -0,0 +1,36 @@ +import type { PacketSummary } from "../../types/api"; +import { PayloadType } from "../../types/enums"; + +export interface PathSearch { + hashSize: number; + bytes: string; +} + +// Parse once per search change; complete hop hashes make the width unambiguous. +export function parsePathSearch(query: string): PathSearch | null { + const text = query.trim().toLowerCase(); + if (!text) return { hashSize: 0, bytes: "" }; + if (text.length > 1024) return null; + const hops = text.split(/[\s,→]+/); + const width = hops[0]!.length; + if (!hops.every(hop => hop.length === width && /^(?:[0-9a-f]{2}){1,4}$/.test(hop))) return null; + return { hashSize: width / 2, bytes: hops.join("") }; +} + +export function matchesPathSearch(packet: PacketSummary, search: PathSearch | null): boolean { + if (!search) return false; + if (!search.bytes) return true; + const observer = packet.latestObserver; + const length = observer?.pathLength; + const bytes = observer?.pathBytes; + // TRACE summaries carry SNR samples here, not the relay hashes from trace detail. + if (packet.payloadType === PayloadType.TRACE || !length || !bytes || + length.hashSize !== search.hashSize || !Number.isInteger(length.hopCount) || length.hopCount <= 0 || + bytes.length !== length.hopCount * length.hashSize * 2 || search.bytes.length > bytes.length || + !/^[0-9a-f]+$/i.test(bytes)) return false; + const path = bytes.toLowerCase(); + for (let offset = 0; offset <= path.length - search.bytes.length; offset += search.hashSize * 2) { + if (path.startsWith(search.bytes, offset)) return true; + } + return false; +} diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts index 8df354d..3fbcf01 100644 --- a/src/features/packets/path-summary.ts +++ b/src/features/packets/path-summary.ts @@ -15,12 +15,10 @@ export interface PathSummary { hopLabel: string; chips: PathChip[]; overflow: number; // hops not represented by a visible chip - source: PathChip | null; - destination: PathChip | null; isNa: boolean; } -const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, isNa: true }; function hopChip(hop: ResolvedHop): PathChip | null { if (hop.confidence === "none") return null; @@ -29,12 +27,6 @@ function hopChip(hop: ResolvedHop): PathChip | null { return { kind: "node", label, confidence: hop.confidence }; } -// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. -function endpointChip(hop: ResolvedHop | undefined): PathChip | null { - if (!hop) return null; - return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; -} - function chipsFromResolved(path: ResolvedHop[]): PathChip[] { const out: PathChip[] = []; for (const hop of path) { @@ -63,9 +55,7 @@ export function buildPathSummary(packet: PacketSummary): PathSummary { if (!observer || !length) return NA; const { hopCount, hashSize } = length; - const source = endpointChip(observer.resolvedSource); - const destination = endpointChip(observer.resolvedDestination); - const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, isNa: false }; if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index 00dade5..d0a8db1 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -1,10 +1,11 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; 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, DEVICE_ROLE_NAMES } from "./packet-structure"; import type { FieldId } from "./packet-structure"; -import { ResolvedHopBlock } from "./PathData"; +import { HopSnr, ResolvedHopBlock } from "./PathData"; import type { ResolvedHop } from "../../types/api"; // shared layout primitives for payload fields @@ -60,7 +61,8 @@ function HexBadge({ value }: { value: string }) { } function BooleanValue({ value }: { value: boolean }) { - return {value ? "Yes" : "No"}; + const { t } = useTranslation(); + return {value ? t("packetAnalyzer.payload.yes") : t("packetAnalyzer.payload.no")}; } function FlagChip({ label, active, variant = "green" }: { label: string; active: boolean; variant?: "green" | "danger" }) { @@ -77,13 +79,14 @@ function FlagChip({ label, active, variant = "green" }: { label: string; active: } function EncryptedIndicator() { + const { t } = useTranslation(); return (
- Encrypted — key not available + {t("packetAnalyzer.payload.encrypted")}
); } @@ -93,6 +96,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso headerSlot?: ReactNode; children: (decrypted: Record) => ReactNode; } & EndpointProps) { + const { t } = useTranslation(); const destinationHash = payload.destinationHash as string | undefined; const sourceHash = payload.sourceHash as string | undefined; const cipherMac = payload.cipherMac as string | undefined; @@ -104,7 +108,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
{destinationHash && ( - To + {t("packetAnalyzer.payload.to")} {resolvedDestination ? : } @@ -113,7 +117,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso )} {sourceHash && ( - From + {t("packetAnalyzer.payload.from")} {resolvedSource ? : } @@ -122,12 +126,12 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso )} {headerSlot} {cipherMac && } - {ciphertext && } + {ciphertext && }
{decrypted ? (
- Decrypted + {t("packetAnalyzer.payload.decrypted")} {children(decrypted)}
) : ( @@ -140,6 +144,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso // per-payload-type renderers function AdvertPayload({ payload }: PayloadProps) { + const { t } = useTranslation(); const p = payload; const publicKey = p.publicKey as string | undefined; const signature = p.signature as string | undefined; @@ -156,7 +161,7 @@ function AdvertPayload({ payload }: PayloadProps) {
{publicKey && ( - Public Key + {t("packetAnalyzer.payload.publicKey")} {publicKey.slice(0, 16).toUpperCase()}… ({publicKey.length / 2}B) @@ -164,7 +169,7 @@ function AdvertPayload({ payload }: PayloadProps) { {timestamp != null && ( - Timestamp + {t("packetAnalyzer.payload.timestamp")} (4B LE) @@ -172,20 +177,20 @@ function AdvertPayload({ payload }: PayloadProps) { {signature && ( - Signature + {t("packetAnalyzer.payload.signature")} {signature.slice(0, 16).toUpperCase()}… ({signature.length / 2}B) )} -
App Flags
+
{t("packetAnalyzer.payload.appFlags")}
- Role {String(flags.deviceRoleName ?? "?")} - Loc + {t("packetAnalyzer.payload.role")} {String(flags.deviceRoleName ?? "?")} + {t("packetAnalyzer.payload.loc")} F1 F2 - Name + {t("packetAnalyzer.payload.name")}
{flagsByte != null && !Number.isNaN(flagsByte) && }
@@ -193,7 +198,7 @@ function AdvertPayload({ payload }: PayloadProps) { {/* Coordinates render as raw decimal degrees by design — intentionally not D°M' / N·S·E·W formatted. */} {hasLocation && (latitude != null || longitude != null) && ( - Location + {t("packetAnalyzer.payload.location")} {latitude != null ? latitude.toFixed(5) : "—"} {", "} @@ -205,7 +210,7 @@ function AdvertPayload({ payload }: PayloadProps) { {name && ( - Name + {t("packetAnalyzer.payload.name")} {name} ({new TextEncoder().encode(name).length}B UTF-8) @@ -218,6 +223,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & { resolvedRoute?: ResolvedHop[]; onViewNode?: (nodeId: string) => void; }) { + const { t } = useTranslation(); const traceTag = payload.traceTag as string | undefined; const authCode = payload.authCode as number | undefined; const flags = payload.flags as number | undefined; @@ -228,7 +234,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
{traceTag && ( - Trace Tag + {t("packetAnalyzer.payload.traceTag")} {traceTag.toUpperCase()} ({traceTag.length / 2}B) @@ -236,7 +242,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & { {authCode != null && ( - Auth Code + {t("packetAnalyzer.payload.authCode")} 0x{authCode.toString(16).toUpperCase()} (4B) @@ -244,19 +250,16 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & { {flags != null && ( - Flags + {t("packetAnalyzer.payload.flags")} 0x{flags.toString(16).toUpperCase().padStart(2, "0")} )} {pathHashes && pathHashes.length > 0 && ( -
Trace Path
+
{t("packetAnalyzer.payload.tracePath")}
{pathHashes.map((hash, i) => { - const snr = snrValues?.[i]; - const level = snr != null ? snrLevel(snr) : null; - const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; // When the packet detail resolved this trace's route, overlay it onto each hash block: // tint by match confidence and reveal the resolved node(s) on hover. Falls back to the // plain hash badge when there's no resolution (e.g. live/WS view). @@ -270,13 +273,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & { ) : ( )} - {/* keep a sub-line on every hop (SNR, or a "-" placeholder when there's no reading) - so the hash badges across the row stay aligned */} - {snr != null ? ( - {formatSnr(snr)} dB - ) : ( - - - )} + ); @@ -289,6 +286,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & { } function GroupTextPayload({ payload }: PayloadProps) { + const { t } = useTranslation(); const channelHash = payload.channelHash as string | undefined; const cipherMac = payload.cipherMac as string | undefined; const ciphertext = payload.ciphertext as string | undefined; @@ -297,25 +295,25 @@ function GroupTextPayload({ payload }: PayloadProps) { return (
- {channelHash && } + {channelHash && } {cipherMac && } - {ciphertext && } + {ciphertext && }
{decrypted ? (
- Decrypted + {t("packetAnalyzer.payload.decrypted")}
{decrypted.sender != null && (
- Sender + {t("packetAnalyzer.payload.sender")} {String(decrypted.sender)}
)} {decrypted.content != null && (
{/* label sits above the body so every line of a multi-line message shares a left edge */} -
Message
+
{t("packetAnalyzer.payload.message")}
{String(decrypted.content)}
)} @@ -332,6 +330,7 @@ function GroupTextPayload({ payload }: PayloadProps) { } function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { + const { t } = useTranslation(); return ( {(d) => ( @@ -341,8 +340,8 @@ function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { )}
{d.timestamp != null && } - {d.attempt != null && Attempt {String(d.attempt)}} - {d.flags != null && Flags {String(d.flags)}} + {d.attempt != null && {t("packetAnalyzer.payload.attempt", { value: String(d.attempt) })}} + {d.flags != null && {t("packetAnalyzer.payload.flagsValue", { value: String(d.flags) })}}
)} @@ -351,6 +350,7 @@ function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { } function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { + const { t } = useTranslation(); return ( {(d) => ( @@ -359,10 +359,10 @@ function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps)
{String(d.requestTypeName)}
)} {d.requestData != null && ( - + )} {d.timestamp != null && ( - + )} @@ -373,12 +373,13 @@ function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) } function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { + const { t } = useTranslation(); return ( {(d) => (
{d.tag != null && ( - {String(d.tag)} + {String(d.tag)} )} {d.content != null && (
{String(d.content)}
@@ -390,12 +391,13 @@ function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps } function AckPayload({ payload }: PayloadProps) { + const { t } = useTranslation(); const checksum = payload.checksum as string | undefined; if (!checksum) return null; return ( - Checksum + {t("packetAnalyzer.payload.checksum")} ({checksum.length / 2}B) @@ -411,6 +413,7 @@ function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { } function PathDecryptedContent({ decrypted }: { decrypted: Record }) { + const { t } = useTranslation(); const pathLength = decrypted.pathLength as number | undefined; const pathHashSize = decrypted.pathHashSize as number | undefined; const pathHashes = decrypted.pathHashes as string[] | undefined; @@ -426,10 +429,10 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record
{pathLength != null && ( - {pathLength} + {pathLength} )} {pathHashSize != null && ( - {pathHashSize}B + {pathHashSize}B )}
{pathLenByte != null && } @@ -437,7 +440,7 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record 0 && (
- Path + {t("observation.path")}
{pathHashes.map((hash, i) => ( @@ -453,11 +456,11 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record {extraTypeName && (
- Bundled Type + {t("packetAnalyzer.payload.bundledType")} {extraTypeName}
)} - {extraData && } + {extraData && }
)}
@@ -465,6 +468,7 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record {tag && ( - + 0x{tag.toUpperCase()} )} {typeFilter != null && (
- Type Filter + {t("packetAnalyzer.payload.typeFilter")} {roles.length > 0 ? ( {roles.map((i) => ( - + ))} ) : ( - any + {t("packetAnalyzer.payload.any")} )}
)} {since != null && ( - + )} {prefixOnly != null && ( - + )}
); } function DiscoverRespPayload({ payload }: PayloadProps) { + const { t } = useTranslation(); const tag = payload.tag as string | undefined; const nodeTypeName = payload.nodeTypeName as string | undefined; const requestSnr = payload.requestSnr as number | undefined; @@ -521,25 +526,25 @@ function DiscoverRespPayload({ payload }: PayloadProps) { return (
{tag && ( - + 0x{tag.toUpperCase()} )} {nodeTypeName && (
- Node Type + {t("packetAnalyzer.payload.nodeType")} {nodeTypeName}
)} {requestSnr != null && ( - + {formatSnr(requestSnr)} dB - (node-to-node) + {t("packetAnalyzer.payload.nodeToNode")} )} {pubKey && ( ; + return ; } // generic key-value renderer for unknown payload types @@ -668,6 +674,7 @@ function GenericPayload({ payload }: PayloadProps) { } function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadProps & EndpointProps) { + const { t } = useTranslation(); const destination = payload.destination as number | undefined; const ephemeralPubKey = payload.ephemeralPubKey as string | undefined; const destLabel = destination != null ? `0x${destination.toString(16).toUpperCase().padStart(2, "0")}` : ""; @@ -675,14 +682,14 @@ function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadPro return (
{destination != null && ( - + {resolvedDestination ? : {destLabel}} )} {ephemeralPubKey && ( - + )}
); @@ -697,7 +704,8 @@ export function PayloadBreakdown({ payload, resolvedRoute, resolvedSource, resol const endpoints: EndpointProps = { resolvedSource, resolvedDestination, onViewNode }; switch (payload.type) { case "ADVERT": return ; - case "TRACE": return ; + case "TRACE": + case "PING": return ; case "GROUP_TEXT": return ; case "TEXT_MESSAGE": return ; case "REQUEST": return ; diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index e1bb284..9cc46c7 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -3,6 +3,7 @@ import { useMemo, useCallback } from "react"; import type { PacketFilterState, PacketServerFilter, SearchField } from "./types"; import type { PacketSummary } from "../../types/api"; import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums"; +import { matchesPathSearch, parsePathSearch, type PathSearch } from "./path-search"; // filter state synced to URL search params @@ -16,9 +17,8 @@ function parseStringArray(value: string | null): string[] { return value.split(",").filter(Boolean); } -// "path"/"payload" show as disabled placeholders in the filter bar — matchesFilters only -// implements hash, so don't accept them from the URL either or the search silently does nothing -const IMPLEMENTED_SEARCH_FIELDS = new Set(["hash"]); +// Keep unimplemented search modes out of URLs as well as the dropdown. +const IMPLEMENTED_SEARCH_FIELDS = new Set(["hash", "path"]); function parseSearchField(value: string | null): SearchField { if (value && IMPLEMENTED_SEARCH_FIELDS.has(value as SearchField)) return value as SearchField; @@ -132,6 +132,7 @@ export function matchesFilters( packet: PacketSummary, filters: PacketFilterState, observersByHash?: ReadonlyMap>, + pathSearch?: PathSearch | null, ): boolean { if (filters.payloadTypes.length > 0 && !filters.payloadTypes.includes(packet.payloadType as PayloadTypeValue)) { return false; @@ -155,5 +156,7 @@ export function matchesFilters( return false; } } + if (filters.searchField === "path" && !matchesPathSearch(packet, + pathSearch === undefined ? parsePathSearch(filters.search) : pathSearch)) return false; return true; } diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 00a54d9..0f0214e 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -1,11 +1,12 @@ import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from "react"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { getPackets } from "../../api/client"; +import { isRateLimited } from "../../api/rate-limit"; 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"; +import { LIVE_BUFFER_CAP } from "../../lib/constants"; // merge and deduplicate live + paginated packets @@ -123,7 +124,8 @@ class LivePacketStore { // combines live WS stream with paginated history export function usePackets(frozen: boolean = false, serverFilter: PacketServerFilter | null = null) { - const { iatas, regionKey } = useRegion(); + const { iatas, regionKey, isResolved } = useRegion(); + const pending = isResolved === false; const queryClient = useQueryClient(); const [store] = useState(() => new LivePacketStore()); const [laggedCount, setLaggedCount] = useState(0); @@ -152,6 +154,8 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi const handlePacketObservation = useCallback( (data: WsPacketObservation["data"]) => { + // the previous region's subscription can still deliver after the buffer reset + if (iatas && !iatas.includes(data.observation.iata)) return; const summary: PacketSummary = { packetHash: data.packetHash, payloadType: data.packet.payloadType, @@ -162,6 +166,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi lastHeardAt: data.observation.heardAt, observationCount: data.packet.observationCount, scope: data.packet.scope, + summary: data.packet.summary, latestObserver: { id: data.observation.observerId, displayName: data.observation.observerName, @@ -176,7 +181,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi store.pushOrUpdate(summary); }, - [store], + [store, iatas], ); // Reset (drop to one fresh first page) instead of invalidate: an invalidate replays every cached @@ -185,7 +190,8 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi const handleLagged = useCallback( (data: WsLagged) => { setLaggedCount((prev) => prev + data.droppedCount); - queryClient.resetQueries({ queryKey: ["packets", regionKey] }); + // while the API is throttling us the refetch would only 429; the next lag notice or remount heals it + if (!isRateLimited()) queryClient.resetQueries({ queryKey: ["packets", regionKey] }); }, [queryClient, regionKey], ); @@ -204,6 +210,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi data: history, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -216,7 +223,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi getNextPageParam: (last) => last.nextCursor ?? undefined, initialPageParam: undefined as number | undefined, staleTime: Infinity, - maxPages: MAX_INFINITE_PAGES, + enabled: !pending, }); const allPackets = useMemo( @@ -249,8 +256,9 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi acknowledgeNewPackets, fetchNextPage, hasNextPage: hasNextPage ?? false, + isFetching, isFetchingNextPage, - isLoading, + isLoading: isLoading || pending, isError, observersByHash, handlePacketObservation, diff --git a/src/features/routes/RouteDetailPanel.tsx b/src/features/routes/RouteDetailPanel.tsx index 6025d97..e7f84c2 100644 --- a/src/features/routes/RouteDetailPanel.tsx +++ b/src/features/routes/RouteDetailPanel.tsx @@ -1,49 +1,126 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; +import { InfoTip } from "../../components/InfoTip"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { getRouteEvidence, isNotFound } from "../../api/client"; +import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { ResolvedHopBlock } from "../packets/PathData"; import type { KnownRoute, ResolvedHop } from "../../types/api"; -// Detail for a selected known route. The /routes list already carries the full hops, so this takes the -// route object directly — no extra fetch. Hops reuse the packet path renderer's block (high-confidence -// by definition); the node label lights up if/when the server populates hop.node. -interface RouteDetailPanelProps { - route: KnownRoute; +export interface RouteActions { + onAnalyzePacket?: (hash: string, observationId: number) => void; + onViewObserver?: (id: string) => void; + onViewNode?: (id: string) => void; +} + +// The server's widest window; packet retention is what actually bounds the list. +const RECENT_RANGE = "720h"; +const PAGE_LIMIT = 50; +const MAX_PAGES = 10; + +interface RouteDetailPanelProps extends RouteActions { + route?: KnownRoute; + iata?: string; + pathKey?: string; onClose: () => void; } -export function RouteDetailPanel({ route, onClose }: RouteDetailPanelProps) { +// A shared link only carries iata + pathKey, so the route itself can arrive with the first packet page. +export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnalyzePacket, onViewObserver, onViewNode }: RouteDetailPanelProps) { + const { t } = useTranslation(); + const keyed = !!iata && !!pathKey; + const query = useInfiniteQuery({ + queryKey: ["route-evidence", iata, pathKey], + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata!, pathKey!, pageParam ? { pageCursor: pageParam, limit: PAGE_LIMIT } : { range: RECENT_RANGE, limit: PAGE_LIMIT }, signal), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last, pages) => pages.length < MAX_PAGES && last.hasMore ? last.nextPageCursor : undefined, + enabled: keyed, + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }); + const first = query.data?.pages[0]; + const route = listed ?? first?.route; + const reports = query.data?.pages.flatMap(page => page.items) ?? []; + const capped = (query.data?.pages.length ?? 0) >= MAX_PAGES && query.data?.pages.at(-1)?.hasMore; + const width = first?.matchAvailable && first.hashSize ? first.hashSize : "—"; + return ( - -
-
- {route.iata} - - -
-
+ ({ tab: "Routes", route: pathKey!, routeIata: iata!, hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />} + > + {route && <> +
+
+ {route.iata} + + +
+
-
-
- {route.hops.map((hop, i) => { - const resolved: ResolvedHop = { confidence: "high", nodes: hop.node ? [hop.node] : [] }; - return ( -
- #{i + 1} - - {hop.node?.name && {hop.node.name}} -
- ); - })} -
-
+
+
+ {route.hops.map((hop, i) => { + const resolved: ResolvedHop = { confidence: "high", nodes: hop.node ? [hop.node] : [] }; + return ( +
+ #{i + 1} + + {hop.node?.name && {hop.node.name}} +
+ ); + })} +
+
-
-
- } /> - } /> -
-
+
+
+ } /> + } /> +
+
+ } + + {keyed && (route || query.isError) && ( +
{t("routeEvidence.recent")}}> + {query.isError && ( +
+

{t(isNotFound(query.error) ? "routeEvidence.missing" : "routeEvidence.error")}

+ +
+ )} + {query.isPending ?

{t("routeEvidence.loading")}

+ : first && !first.matchAvailable ?

{t("routeEvidence.unavailable")}

+ : first && reports.length === 0 ?

{t("routeEvidence.empty")}

+ : ( +
    + {reports.map(report => { + const level = snrLevel(report.snr); + return ( +
  • +
    {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}
    +

    {report.observerName ?? report.observerId.slice(0, 8)}

    +

    · SNR {formatSnr(report.snr)}{report.snr != null && " dB"} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

    +
    + {onAnalyzePacket && } + {onViewObserver && } +
    +
  • + ); + })} +
+ )} + {query.hasNextPage && } + {capped &&

{t("routeEvidence.cap")}

} +
+ )}
); } diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index a92b051..2b3c748 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -1,14 +1,18 @@ import { useState, useMemo, useCallback, useEffect, useRef, memo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { useSearchParams } from "react-router-dom"; +import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas, type RouteCursor } from "../../api/client"; +import { useRegion, useRegionSelection } from "../../hooks/useRegion"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { DataTable, type Column } from "../../components/DataTable"; import { LoadingPill } from "../../components/LoadingPill"; import { MultiSelectDropdown } from "../../components/MultiSelectDropdown"; -import { RouteDetailPanel } from "./RouteDetailPanel"; +import { useIsMobile } from "../../hooks/useMediaQuery"; +import { RouteDetailPanel, type RouteActions } from "./RouteDetailPanel"; import { ResolvedHopBlock } from "../packets/PathData"; import { formatHex } from "../../lib/formatters"; import type { KnownRoute, CrossIATARoute, ResolvedHop, ResolvedNode, RouteHop } from "../../types/api"; @@ -50,6 +54,7 @@ const RouteHopChain = memo(function RouteHopChain({ route }: { route: KnownRoute // A cross-IATA route: source segment → boundary hop (the two nodes that bridge the IATAs) → target segment. function CrossRouteCard({ route }: { route: CrossIATARoute }) { + const { t } = useTranslation(); const { crossHop } = route; return (
@@ -59,7 +64,7 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) { → {crossHop.toIata}
- {route.totalHops} hops + {t("routes.hops", { count: route.totalHops })}
@@ -74,53 +79,61 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) { ); } -const COLUMNS: Column[] = [ +// ids keep sorting stable when the headers are translated +const buildColumns = (t: TFunction): Column[] => [ { - header: "IATA", + id: "area", + header: t("routes.columns.area"), sortValue: (r) => r.iata, cell: (r) => {r.iata}, }, { - header: "Hops", + id: "hops", + header: t("routes.columns.hops"), sortValue: (r) => r.hopCount, cell: (r) => r.hopCount, }, { - header: "Route", + id: "route", + header: t("routes.columns.route"), cell: (r) => , }, { - header: "Obs", + id: "obs", + header: t("routes.columns.obs"), className: "text-text-muted", sortValue: (r) => r.observationCount, cell: (r) => r.observationCount.toLocaleString(), }, { - header: "First seen", + id: "firstSeen", + header: t("routes.columns.firstSeen"), className: "text-text-muted", sortValue: (r) => r.firstSeen, cell: (r) => , }, { - header: "Last seen", + id: "lastSeen", + header: t("routes.columns.lastSeen"), className: "text-text-muted", sortValue: (r) => r.lastSeen, cell: (r) => , }, ]; -function renderRouteCard(r: KnownRoute) { +function RouteCard({ route: r }: { route: KnownRoute }) { + const { t } = useTranslation(); return (
{r.iata} - {r.hopCount} hops · {r.observationCount.toLocaleString()} obs + {t("routes.hops", { count: r.hopCount })} · {t("routes.obs", { count: r.observationCount, value: r.observationCount.toLocaleString() })}
- first + {t("routes.first")} · - last + {t("routes.last")}
); @@ -140,19 +153,34 @@ function directedPairs(iatas: string[]): [string, string][] { return pairs; } -export function RouteTable() { - const { iatas, regionKey } = useRegion(); +const renderRouteCard = (r: KnownRoute) => ; +export function RouteTable(actions: RouteActions) { + const { t } = useTranslation(); + const columns = useMemo(() => buildColumns(t), [t]); + const { iatas, isResolved } = useRegion(); + const regionPending = isResolved === false; + const { selection } = useRegionSelection(); + const [params, setParams] = useSearchParams(); const [selectedKey, setSelectedKey] = useState(null); + const pathKey = params.get("route"), routeIata = params.get("routeIata"); + const closeRoute = useCallback(() => { + // no-op when nothing is open, so callers (e.g. the region-change effect) can call it unconditionally + if (!pathKey && !selectedKey) return; + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata"]) next.delete(key); return next; }, { replace: true }); + }, [pathKey, selectedKey, setParams]); + + // drop the selection when the region changes — the selected route may not be in the new region - const prevRegion = useRef(regionKey); + const prevRegion = useRef(selection); useEffect(() => { - if (prevRegion.current !== regionKey) { - prevRegion.current = regionKey; - setSelectedKey(null); + if (prevRegion.current !== selection) { + prevRegion.current = selection; + closeRoute(); } - }, [regionKey]); + }, [selection, closeRoute]); // path search form: source→dest hashes, scoped to a multi-select of IATAs. One IATA → within-IATA // /routes/search; two+ → /routes/cross across the directed pairs. Hashes + ≥1 IATA required. @@ -167,15 +195,16 @@ export function RouteTable() { // client-side below (a region can span several IATAs, which the endpoint can't express). const serverIata = iatas && iatas.length === 1 ? iatas[0] : undefined; - // Page the route set on demand (50 at a time, cursor = last route's lastSeen ms) — the DataTable + // Page the route set on demand (50 at a time, cursor = last route's lastSeen + id) — the DataTable // pulls the next page via loadMore() as you scroll, instead of eagerly loading the whole set. const { items: listRoutes, loadedCount, isPaging, isError, isLoading: listLoading, loadMore, hasMore } = - useInfinitePages({ + useInfinitePages({ queryKey: ["routes", serverIata ?? ""], queryFn: (cursor) => getKnownRoutesPage({ iata: serverIata, cursor }), getId: routeId, keepPrevious: true, auto: false, + enabled: !regionPending, }); const { data: searchRoutes, isLoading: searchLoading } = useQuery({ @@ -213,16 +242,29 @@ export function RouteTable() { // region = all). Filtering by IATA stays client-side, consistent with the other tabs. const rows = useMemo(() => { if (search) return isCross ? [] : searchRoutes; - if (!iatas || iatas.length === 0) return listRoutes; + if (regionPending) return []; + if (!iatas) return listRoutes; const set = new Set(iatas); return listRoutes.filter((r) => set.has(r.iata)); - }, [search, isCross, searchRoutes, listRoutes, iatas]); + }, [search, isCross, searchRoutes, listRoutes, iatas, regionPending]); const selectedRoute = useMemo( () => rows?.find((r) => String(r.id) === selectedKey), [rows, selectedKey], ); + const selectRoute = (id: string | null) => { + if (id === null) { closeRoute(); return; } + const route = rows?.find(row => String(row.id) === id); + if (route?.pathKey) { + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); return next; }, { replace: true }); + } else { + closeRoute(); + setSelectedKey(id); + } + }; + // A multi-IATA region filters globally-paged rows client-side, so the filtered list can be too // short to ever trigger scroll paging — or empty, with the region's routes deeper in the cursor // stream. Keep pulling pages until there's a screenful or the cap says the region is just sparse. @@ -233,67 +275,74 @@ export function RouteTable() { loadMore(); }, [search, serverIata, iatas, hasMore, isPaging, rows, loadedCount, loadMore]); + const isMobile = useIsMobile(); + const panelOpen = Boolean(pathKey && routeIata || selectedRoute); const canSearch = !!(from.trim() && to.trim() && searchIatas.length >= 1); // clear any selection when the visible list changes out from under it (search submit/clear) const submitSearch = useCallback(() => { if (!from.trim() || !to.trim() || searchIatas.length < 1) return; setSearch({ from: from.trim(), to: to.trim(), iatas: searchIatas }); - setSelectedKey(null); - }, [from, to, searchIatas]); + closeRoute(); + }, [from, to, searchIatas, closeRoute]); const clearSearch = useCallback(() => { setSearch(null); - setSelectedKey(null); - }, []); + closeRoute(); + }, [closeRoute]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") submitSearch(); }; return ( -
+
{/* stacks into two rows on mobile (the inputs would otherwise wrap around the arrow); one row at md+ */} -
+
- Find path + {t("routes.findPath")} setFrom(e.target.value)} onKeyDown={onKeyDown} /> → setTo(e.target.value)} onKeyDown={onKeyDown} />
- +
+ +
{search && ( )}
@@ -303,24 +352,24 @@ export function RouteTable() { {isCross ? (
{crossLoading ? ( -
Searching…
+
{t("routes.searching")}
) : crossRoutes && crossRoutes.length > 0 ? ( crossRoutes.map((r, i) => ) ) : ( -
No cross-IATA routes
+
{t("routes.noCross")}
)}
) : ( -
+
String(r.id)} - selectedKey={selectedKey} - onSelect={setSelectedKey} - isLoading={search ? searchLoading : listLoading} - emptyLabel={search ? "No matching routes" : "No routes"} - defaultSort={{ header: "Last seen", direction: "desc" }} + selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey} + onSelect={selectRoute} + isLoading={search ? searchLoading : listLoading || regionPending} + emptyLabel={t(search ? "routes.noMatches" : "routes.empty")} + defaultSort={{ id: "lastSeen", direction: "desc" }} onEndReached={search ? undefined : loadMore} renderCard={renderRouteCard} /> @@ -329,8 +378,10 @@ export function RouteTable() { )}
)} - {selectedRoute && ( - setSelectedKey(null)} /> + {pathKey && routeIata ? ( + row.pathKey === pathKey && row.iata === routeIata)} iata={routeIata} pathKey={pathKey} onClose={closeRoute} {...actions} /> + ) : selectedRoute && ( + setSelectedKey(null)} {...actions} /> )}
diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx index 7cc1be8..9218a56 100644 --- a/src/features/stats/ClockDriftTab.tsx +++ b/src/features/stats/ClockDriftTab.tsx @@ -1,3 +1,5 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useClockDrift } from "./useStats"; import { DataTable, type Column } from "../../components/DataTable"; import { Badge } from "../../components/Badge"; @@ -11,60 +13,84 @@ function driftClass(seconds: number) { return Math.abs(seconds) >= 3600 ? "text-danger" : "text-warn"; } -const columns: Column[] = [ - { - header: "Node", - cell: (e) => ( -
- - {e.nodeName ?? e.nodeId.slice(0, 8)} - - {e.nodeTypeName} -
- ), - sortValue: (e) => e.nodeName ?? e.nodeId, - }, - { - header: "Drift", - className: "tabular-nums", - cell: (e) => {formatClockDrift(e.clockDriftSeconds)}, - sortValue: (e) => Math.abs(e.clockDriftSeconds), - }, - { - header: "Checked", - cell: (e) => , - sortValue: (e) => e.clockCheckedAt, - }, - { - header: "IATAs", - cell: (e) => ( -
- {(e.iatas ?? []).map((i) => ( - {i.iata} - ))} -
- ), - }, -]; - // Repeaters/room servers whose advert-derived clock has drifted past the server threshold, worst // first. Not time-windowed (each row is the node's latest reading), so there's no range selector. export function ClockDriftTab() { + const { t } = useTranslation(); const clockDrift = useClockDrift(); + const loading = clockDrift.isPending || clockDrift.isLoading || clockDrift.isPlaceholderData; + const columns = useMemo[]>(() => [ + { + id: "node", + header: t("clockDrift.node"), + cell: (e) => ( +
+ + {e.nodeName ?? e.nodeId.slice(0, 8)} + + {e.nodeTypeName} +
+ ), + sortValue: (e) => e.nodeName ?? e.nodeId, + }, + { + id: "drift", + header: t("clockDrift.drift"), + className: "tabular-nums", + cell: (e) => {formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}, + sortValue: (e) => Math.abs(e.clockDriftSeconds), + }, + { + id: "checked", + header: t("clockDrift.checked"), + cell: (e) => , + sortValue: (e) => e.clockCheckedAt, + }, + { + id: "iatas", + header: t("clockDrift.areas"), + cell: (e) => ( +
+ {(e.iatas ?? []).map((i) => ( + {i.iata} + ))} +
+ ), + }, + ], [t]); + const drift = (e: ClockDriftEntry) => + formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") }); + const renderCard = (e: ClockDriftEntry) => ( +
+
+ {e.nodeName ?? e.nodeId.slice(0, 8)} + {e.nodeTypeName} +
+
+ {drift(e)} + +
+ {(e.iatas ?? []).length > 0 && ( +
{e.iatas!.map((i) => {i.iata})}
+ )} +
+ ); + return (
- Repeaters & room servers out of sync · worst first + {t("clockDrift.caption")}
e.nodeId} selectedKey={null} onSelect={() => {}} - isLoading={clockDrift.isLoading} - emptyLabel={clockDrift.isError ? "Failed to load" : "No repeaters out of sync"} - defaultSort={{ header: "Drift", direction: "desc" }} + isLoading={loading} + emptyLabel={clockDrift.isError ? t("common.loadFailed") : t("clockDrift.empty")} + defaultSort={{ id: "drift", direction: "desc" }} + renderCard={renderCard} />
); diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx new file mode 100644 index 0000000..da8fdce --- /dev/null +++ b/src/features/stats/CompareObserversTab.tsx @@ -0,0 +1,154 @@ +import { useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; +import { getObserver, getObserverComparison } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { useStatsRegion } from "./useStats"; +import { OBSERVER_UUID } from "../observers/observer-id"; +import { ObserverPicker } from "../observers/ObserverPicker"; +import { Card } from "./cards"; + +type Selection = { observerA: string; observerB: string; since: number; until: number }; +const fieldClass = "min-w-0 w-full max-w-full appearance-none rounded border border-border bg-bg-base px-2.5 py-1.5 font-mono text-base text-text-normal sm:text-[12px]"; +const labelClass = "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; + +function validation(value: Selection): string | null { + if (!OBSERVER_UUID.test(value.observerA) || !OBSERVER_UUID.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { + return "observerCompare.invalidPair"; + } + if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { + return "observerCompare.invalidRange"; + } + return null; +} + +// raw packets are kept 3-7 days depending on the instance +const RETENTION_HINT_MS = 7 * 86_400_000; + +function localTime(ms: number) { + const date = new Date(ms); + return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); +} + +function ObserverSelect({ label, value, onChange, excludeId }: { label: string; value: string; onChange: (id: string) => void; excludeId?: string }) { + const { t } = useTranslation(); + const selected = useQuery({ + queryKey: ["observer", value], queryFn: () => getObserver(value), + enabled: OBSERVER_UUID.test(value), staleTime: 30_000, retry: false, + }); + return ( +
+ {label} + +
+ ); +} + +function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) { + const { t } = useTranslation(); + const [a, setA] = useState(initial?.observerA ?? ""); + const [b, setB] = useState(initial?.observerB ?? ""); + const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); + const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000)); + const [error, setError] = useState(null); + const [retainedSince] = useState(() => Date.now() - RETENTION_HINT_MS); + function submit(e: FormEvent) { + e.preventDefault(); + const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() }; + const message = validation(value); + setError(message); + if (!message) onCompare(value); + } + return ( +
+
+ + + + +
+ {new Date(since).getTime() < retainedSince &&

{t("observerCompare.retentionWarning")}

} + {error &&

{t(error)}

} + + + ); +} + +export function CompareObserversTab() { + const { t } = useTranslation(); + const { iatas, emptyRegion } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); + const [params, setParams] = useSearchParams(); + const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; + const supplied = keys.some((key) => params.has(key)); + const parsed: Selection = { + observerA: params.get("compareA") ?? "", observerB: params.get("compareB") ?? "", + since: Number(params.get("compareSince")), until: Number(params.get("compareUntil")), + }; + const valid = keys.every((key) => params.getAll(key).length === 1 && params.get(key) !== "") && !validation(parsed); + const selection = valid ? parsed : null; + const result = useQuery({ + queryKey: ["observer-comparison", regionKey, selection], + queryFn: ({ signal }) => getObserverComparison(where, selection!, signal), + enabled: selection !== null && isResolved !== false, + retry: false, staleTime: 30_000, refetchOnWindowFocus: false, + }); + const observerA = useQuery({ + queryKey: ["observer", selection?.observerA], queryFn: () => getObserver(selection!.observerA), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + const observerB = useQuery({ + queryKey: ["observer", selection?.observerB], queryFn: () => getObserver(selection!.observerB), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + function compare(value: Selection) { + if (selection && Object.entries(value).every(([key, v]) => selection[key as keyof Selection] === v)) { + void result.refetch(); + return; + } + setParams((old) => { + const next = new URLSearchParams(old); + next.set("compareA", value.observerA); next.set("compareB", value.observerB); + next.set("compareSince", String(value.since)); next.set("compareUntil", String(value.until)); + return next; + }); + } + const data = result.data; + const groups = data ? [ + { name: t("observerCompare.onlyA"), count: data.onlyA, color: "var(--color-primary)" }, + { name: t("observerCompare.both"), count: data.both, color: "var(--color-green)" }, + { name: t("observerCompare.onlyB"), count: data.onlyB, color: "var(--color-secondary)" }, + ] : []; + return ( +
+ + + {supplied && !valid &&

{t("observerCompare.invalidLink")}

} + params.get(key)).join("|")} initial={selection} onCompare={compare} /> +
+ {selection && +

{t("observerCompare.pair", { a: observerA.data?.displayName ?? selection.observerA, b: observerB.data?.displayName ?? selection.observerB })}

+

{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas ? iatas.join(", ") || emptyRegion : t("observerCompare.allRegions")}

+ {(result.isFetching || (selection !== null && isResolved === false)) &&

{t("observerCompare.comparing")}

} + {result.isError &&

{result.error.message}

} + {data && !result.isError && <> +

{t("observerCompare.packetsTotal", { count: data.totalPackets, value: data.totalPackets.toLocaleString() })}

+ {data.totalPackets === 0 ?

{t("observerCompare.emptyRegion")}

: <> +
{groups.map((g) =>
)}
+
ObserverIATAHeard{t("observation.observer")}{t("observation.area")}{t("observation.heard")} SNR RSSIPropHopsPath{t("observation.prop")}{t("observation.hops")}{t("observation.path")}
+ + {groups.map((g) => )} +
{t("observerCompare.heardBy")}{t("observerCompare.packets")}{t("observerCompare.percentUnion")}
{g.name}{g.count.toLocaleString()}{(g.count / data.totalPackets * 100).toFixed(1)}%
+ } + } + } +
+ ); +} diff --git a/src/features/stats/EChart.tsx b/src/features/stats/EChart.tsx index 2c2abcc..4583e6a 100644 --- a/src/features/stats/EChart.tsx +++ b/src/features/stats/EChart.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { echarts, type EChartsInstance, type EChartsOption } from "./echarts-setup"; interface EChartProps { @@ -16,6 +17,9 @@ interface EChartProps { // 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, onInit }: EChartProps) { + const { i18n } = useTranslation(); + // ECharts fixes its locale at init, so a language switch rebuilds the instance + const locale = i18n.resolvedLanguage === "fr" ? "FR" : "EN"; const elRef = useRef(null); const chartRef = useRef(null); const onInitRef = useRef(onInit); @@ -25,7 +29,7 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro useEffect(() => { if (!elRef.current) return; - const chart = echarts.init(elRef.current, null, { renderer: "canvas" }); + const chart = echarts.init(elRef.current, null, { renderer: "canvas", locale }); chartRef.current = chart; onInitRef.current?.(chart); const ro = new ResizeObserver(() => chart.resize()); @@ -35,11 +39,11 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro chart.dispose(); chartRef.current = null; }; - }, []); + }, [locale]); useEffect(() => { chartRef.current?.setOption(option, { notMerge: true }); - }, [option]); + }, [option, locale]); useEffect(() => { const chart = chartRef.current; @@ -47,9 +51,10 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro const entries = Object.entries(onEvents); for (const [ev, handler] of entries) chart.on(ev, handler); return () => { + if (chart.isDisposed()) return; for (const [ev, handler] of entries) chart.off(ev, handler); }; - }, [onEvents]); + }, [onEvents, locale]); return
; } diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index f43a020..51b47f5 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,79 +1,80 @@ +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { useStatsSeries, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; -import { Card, ChartCard, StatCard } from "./cards"; -import { useLiveOverview } from "./useLiveStats"; -import { aggregatePresets, formatPreset } from "./transforms"; -import type { WsManager } from "../../api/ws-manager"; -import type { ObservationPoint, StatsRange } from "./types"; +import { ChartCard, StatCard } from "./cards"; +import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; +import type { StatsRange, StatsSeries, SeriesValues } from "./types"; -// The observations endpoint returns one row per hour+iata; collapse to one row per hour (a no-op for a -// single selected region). uniquePackets / activeObservers summed across iatas are approximate. -function aggregateByHour(points: ObservationPoint[]) { - const byHour = new Map(); - for (const p of points) { - const cur = byHour.get(p.hour) ?? { hour: p.hour, observationCount: 0, uniquePackets: 0, activeObservers: 0 }; - cur.observationCount += p.observationCount; - cur.uniquePackets += p.uniquePackets; - cur.activeObservers += p.activeObservers; - byHour.set(p.hour, cur); - } - return [...byHour.values()].sort((a, b) => a.hour - b.hour); +// Keep unavailable values in the cache, but do not display them under the current filters. +function readyData(query: { data: T | undefined; isSuccess: boolean; isPlaceholderData: boolean }) { + return query.isSuccess && !query.isPlaceholderData ? query.data : undefined; +} + +// Missing and partial hours carry no values; keep their slot as null so the sparkline shows a gap. +function spark(series: StatsSeries | undefined, pick: (v: SeriesValues) => number) { + return series?.hours.map((h) => (h.values ? pick(h.values) : null)); } interface MeshTabProps { range: StatsRange; onSelectObserver: (observerId: string) => void; - wsManager: WsManager; } -export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { +export function MeshTab({ range, onSelectObserver }: MeshTabProps) { + const { t } = useTranslation(); + const rangeLabel = t(`stats.ranges.${range}`); const colors = useChartColors(); - useLiveOverview(wsManager); - const overview = useStatsOverview(); - const observations = useStatsObservations(range); - // top-row KPIs are a fixed 24h snapshot, so their sparklines use a dedicated - // 24h series rather than the range-driven one (deduped by query key when range is 24h) - const overviewObs = useStatsObservations("24h"); + const series = useStatsSeries("24h"); + const observations = useStatsSeries(range); const payload = usePayloadBreakdown(range); - const topNodes = useTopNodes(10); + const topNodes = useTopNodes(range, 10); const topObservers = useTopObservers(range, 8); const radioPresets = useRadioPresets(); - const scopes = useScopes(); + const scopes = useScopes(range); const nodeTypes = useNodeTypes(); - const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]); - const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]); + const ov = readyData(series); + const observationsData = readyData(observations); + const payloadData = readyData(payload); + const topNodesData = readyData(topNodes); + const topObserversData = readyData(topObservers); + const radioPresetsData = readyData(radioPresets); + const scopesData = readyData(scopes); + const nodeTypesData = readyData(nodeTypes); + + const obs = useMemo( + () => (observationsData?.hours ?? []).map((h) => ({ hour: h.hour, observations: h.values?.observations ?? null, uniquePackets: h.values?.uniquePackets ?? null })), + [observationsData], + ); + const obsOption = useMemo( + () => observationsAreaOption(obs, colors, { observations: t("mesh.observations"), uniquePackets: t("charts.uniquePackets") }), + [obs, colors, t], + ); const nodeRows = useMemo( () => - (topNodes.data ?? []).map((n) => ({ - name: n.nodeName ?? n.nodeId.slice(0, 8), + (topNodesData ?? []).map((n) => ({ + name: n.nodeName ?? n.publicKey.slice(0, 8), value: n.observationCount, color: nodeTypeColor(n.nodeTypeName, colors), })), - [topNodes.data, colors], + [topNodesData, colors], ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); - const payloadItems = useMemo( - () => - (payload.data ?? []) - .map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })) - .sort((a, b) => b.value - a.value), - [payload.data], - ); + const payloadItems = useMemo(() => payloadBarItems(payloadData ?? []), [payloadData]); const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]); const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const observerRows = useMemo( - () => (topObservers.data ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), - [topObservers.data, colors], + () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), + [topObserversData, colors], ); const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]); - const observerIds = useMemo(() => (topObservers.data ?? []).map((o) => o.observerId), [topObservers.data]); + const observerIds = useMemo(() => (topObserversData ?? []).map((o) => o.observerId), [topObserversData]); const observerEvents = useMemo( () => ({ click: (params: unknown) => { @@ -86,84 +87,95 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const typeRows = useMemo( () => - [...(nodeTypes.data ?? [])] + [...(nodeTypesData ?? [])] .sort((a, b) => b.count - a.count) .map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })), - [nodeTypes.data, colors], + [nodeTypesData, colors], ); const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); - const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); + const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), t("mesh.nodesCenter")), [typeRows, colors, typeTotal, t]); const presetRows = useMemo( - () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), - [radioPresets.data], + () => aggregatePresets(radioPresetsData ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), + [radioPresetsData], + ); + const presetsOption = useMemo( + () => presetBarsOption(presetRows, colors, undefined, { nodes: t("mesh.nodes"), observers: t("mesh.observers") }), + [presetRows, colors, t], ); - const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]); const scopeRows = useMemo( - () => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount), - [scopes.data], + () => [...(scopesData ?? [])].sort((a, b) => b.packetCount - a.packetCount), + [scopesData], ); - const kpiObs = useMemo(() => aggregateByHour(overviewObs.data ?? []), [overviewObs.data]); - const obsSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.observationCount), [kpiObs]); - const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); + const sparks = useMemo(() => ({ + packets: spark(ov, (v) => v.uniquePackets), + observations: spark(ov, (v) => v.observations), + observers: spark(ov, (v) => v.activeObservers), + iatas: spark(ov, (v) => v.activeIatas), + }), [ov]); - const ov = overview.data; - const kpiLoading = overview.isLoading; - // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below - const ovWindow = `${ov?.windowHours ?? 24}h`; + // with no complete hours the summary is all zeros, which would read as a dead mesh + const kpis = ov?.completeHours ? ov.summary : undefined; + // top-row KPIs are the last 24 rollable hours, so they lag the clock by 35–95 min; range only drives the charts below + const ovWindow = ov + ? t("mesh.lastHoursTo", { hours: Math.round((ov.until - ov.since) / 3_600_000), time: formatUtc(ov.until, { timeOnly: true }) }) + : t("mesh.lastHours", { hours: 24 }); return ( -
+
- - - - + + + +
Observations · {range}} + title={t("mesh.observationsTitle", { range: rangeLabel })} height={200} option={obsOption} - isLoading={observations.isLoading} + isLoading={observations.isPending || observations.isPlaceholderData} isError={observations.isError} - isEmpty={obs.length === 0} + isEmpty={!observationsData?.completeHours} />
{/* range-driven charts lead the grid; the all-time ones follow below */} - Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> +
+ +
Payload types · {range}} - right={{formatCount(payloadTotal)} obs} + title={t("mesh.payloadTypes", { range: rangeLabel })} + right={{t("mesh.obs", { value: formatCount(payloadData === undefined ? undefined : payloadTotal) })}} height={208} option={payloadOption} - isLoading={payload.isLoading} + isLoading={payload.isPending || payload.isPlaceholderData} isError={payload.isError} isEmpty={payloadItems.length === 0} /> - {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} - - - + + + - Scopes · all regions · all time}> +
+ {t("stats.tabs.scopes")} +
{scopes.isError ? ( -
Failed to load
- ) : scopes.isLoading ? ( -
Loading…
+
{t("common.loadFailed")}
+ ) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? ( +
{t("common.loading")}
) : scopeRows.length === 0 ? ( -
No data
+
{t("common.noData")}
) : ( - - - - + + + + @@ -178,7 +190,8 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
ScopePacketsObserversNodes{t("mesh.scope")}{t("mesh.packets")}{t("mesh.observers")}{t("mesh.nodes")}
)} - +
+
); diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 952566b..0fe48d9 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useRegion } from "../../hooks/useRegion"; import { useMapNodesData } from "../map/useMapNodesData"; import { getNodeNeighbors } from "../../api/client"; @@ -7,20 +8,21 @@ 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"; +import { InfoTip } from "../../components/InfoTip"; +import { SearchBar } 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(); + const { t } = useTranslation(); + const { iatas, regionKey, isResolved } = useRegion(); + const pending = isResolved === false; // "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 { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll && !pending }); const colors = useChartColors(); const [selectedId, setSelectedId] = useState(null); const [search, setSearch] = useState(""); @@ -61,33 +63,35 @@ export function NeighbourGraphTab() { return buildEgoGraph(center, neighbours, dataUpdatedAt); }, [selectedId, selectedNode, neighbours, dataUpdatedAt]); - const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); + const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, t, { ego: !!ego }), [ego, graph, colors, t]); + const searchFields = useMemo(() => [{ value: "name", label: t("neighbourGraph.searchName") }], [t]); if (isAll) return ( ); - if (isError) 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 ; + if (isPaging || pending) 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 + {t("neighbourGraph.neighborhoodOf")} {selectedNode?.name ?? selectedId} · {t("neighbourGraph.neighbors", { count: ego.nodes.length - 1 })}
) : (
- - {graph.capped ? `Showing ${CAP} of ${graph.total} nodes — narrow to an IATA for the rest` : `${graph.total} nodes`} + + {graph.capped ? t("neighbourGraph.showing", { cap: CAP, total: graph.total }) : t("neighbourGraph.nodes", { count: graph.total })} + {graph.capped && }
- +
)} diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 71eab28..58bf2f0 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,187 +1,68 @@ -import { useEffect, useMemo, useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Badge } from "../../components/Badge"; +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; import { EmptyState } from "../../components/EmptyState"; -import { formatBattery, formatCount, formatUptime } from "../../lib/formatters"; -import { getObserversPage } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { formatRadioParts } from "../../lib/formatters"; +import { ObserverComparison } from "../observers/ObserverComparison"; +import { ObserverSummary } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; -import { useTopObservers } from "./useStats"; -import { useObserver, useObserverTelemetry } from "./useTelemetry"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "../observers/observer-status"; -import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions"; +import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; +import { + airtimeOption, + batteryOption, + noiseFloorOption, + queueOption, + receiveErrorsOption, + busyOption, + heardOption, + snrHeardOption, + typeBarOption, +} from "./chartOptions"; import { Card, ChartCard } from "./cards"; -import { hasTelemetry } from "./transforms"; +import { fillActivity, hasTelemetry, intervalToMs, latestAirtimePct, payloadBarItems } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; +import { isNotFound } from "../../api/client"; import type { WsManager } from "../../api/ws-manager"; -import type { Observer } from "../observers/types"; -import type { StatsRange } from "./types"; +import { RANGE_MS, type StatsRange } from "./types"; -function ObserverList({ - range, - selectedId, - onSelect, -}: { - range: StatsRange; - selectedId: string | null; - onSelect: (id: string) => void; -}) { - const { iatas, regionKey } = useRegion(); - const [query, setQuery] = useState(""); - // debounce so the server-side lookup fires once per pause, not once per keystroke - const [q, setQ] = useState(""); - useEffect(() => { - const id = setTimeout(() => setQ(query.trim()), 250); - return () => clearTimeout(id); - }, [query]); - const searching = q.length > 0; - - // default: top observers by activity (with count + activity bar). Searching swaps to a server-side - // name lookup across ALL observers in the region, not just the loaded top rows. - const top = useTopObservers(range, 15); - const max = useMemo(() => Math.max(1, ...(top.data ?? []).map((o) => o.observationCount)), [top.data]); - - const search = useQuery({ - queryKey: ["observer-search", regionKey, q], - queryFn: () => getObserversPage(iatas, { name: q, limit: 50 }), - enabled: searching, - staleTime: 30_000, - placeholderData: keepPreviousData, - }); - - type Row = { id: string; name: string; count?: number; iata?: string; online?: boolean }; - const rows: Row[] = searching - ? (search.data?.items ?? []).map((o) => ({ id: o.id, name: o.displayName ?? o.id.slice(0, 8), iata: o.iata, online: o.status === "online" })) - : (top.data ?? []).map((o) => ({ id: o.observerId, name: o.displayName ?? o.observerId.slice(0, 8), count: o.observationCount })); - - const loading = searching ? search.isLoading : top.isLoading; - - return ( - - setQuery(e.target.value)} - placeholder="Search observers…" - className="mb-2 w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-[12px] text-text-normal placeholder:text-text-dim" - /> -
- {loading &&
{searching ? "Searching…" : "Loading…"}
} - {searching && search.isError && ( -
Search failed
- )} - {!loading && !(searching && search.isError) && rows.length === 0 && ( -
{searching ? "No matches" : "No observers"}
- )} - {rows.map((r) => { - const active = r.id === selectedId; - return ( - - ); - })} -
-
- ); +function airtimeLabel(a: { rx: number | null; tx: number | null }): string { + return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); } - -function ObserverHeader({ observer }: { observer: Observer }) { - useTick(); // keep the recency-derived status badge fresh - const status = deriveObserverStatus(observer); - const radio = [ - observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, - observer.radioSf && `SF${observer.radioSf}`, - observer.radioBwKhz && `${observer.radioBwKhz} kHz`, - observer.radioCr && `CR 4/${observer.radioCr}`, - ].filter(Boolean) as string[]; - - return ( - - {observer.displayName ?? observer.id.slice(0, 8)} - {status} - {observer.observerType && {observer.observerType}} - - } - right={ - {observer.iata} - } - > -
- - - - {radio.length > 0 && } -
-
- ); -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ); -} - interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; - onSelectObserver: (observerId: string) => void; + onSelectObserver: (id: string) => void; wsManager: WsManager; + comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => number }; + // observer-level controls the host page puts on the header's name row + actions?: ReactNode; } - -export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { +export function ObserverTab({ range, selectedObserverId, wsManager, comparison, actions }: ObserverTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); - const topObservers = useTopObservers(range, 15); - const observer = useObserver(selectedObserverId); + const observer = useObserver(selectedObserverId, true); const telemetry = useObserverTelemetry(selectedObserverId, range); - - // default to the busiest observer once the list loads and nothing is selected - useEffect(() => { - if (selectedObserverId) return; - const first = topObservers.data?.[0]; - if (first) onSelectObserver(first.observerId); - }, [selectedObserverId, topObservers.data, onSelectObserver]); - + const activity = useObserverActivity(selectedObserverId, range, comparison?.until ?? undefined); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points - const bucketed = telemetry.data != null && telemetry.data.interval !== "1h"; - const airtime = useMemo(() => airtimeOption(points, colors, bucketed), [points, colors, bucketed]); - const battery = useMemo(() => batteryOption(points, colors), [points, colors]); - const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); - const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); + // Use the response interval so the plotted bucket width always matches the data. + const bucketMs = + telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; + const bucketed = bucketMs != null; + const airtime = useMemo(() => airtimeOption(points, colors, bucketMs), [points, colors, bucketMs]); + // Show the latest airtime percentage only when computed from unbucketed reports. + const latestAirtime = useMemo( + () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), + [points, bucketMs], + ); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.batteryV")), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noiseDbm")), [points, colors, t]); + const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); + const recvErrors = useMemo( + () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), + [points, colors, bucketed, t], + ); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. @@ -191,45 +72,201 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => ready && !points.some((p) => accessors.some((a) => a(p) != null)); - return ( -
-
- -
+ // A 404 without cached data means this server has no activity endpoint yet. + const heardUnavailable = !activity.data && activity.isError && isNotFound(activity.error); + const heardLoading = activity.isLoading; + const heardData = activity.data; + const intervalMs = heardData ? intervalToMs(heardData.interval) : null; + // the window ends at the last fetch so the right edge follows now on every poll + const heardWindow = useMemo(() => { + const end = heardData?.windowEnd ?? activity.dataUpdatedAt; + const span = (heardData && intervalToMs(heardData.range)) ?? RANGE_MS[range]; + return { start: heardData?.windowStart ?? end - span, end }; + }, [heardData, activity.dataUpdatedAt, range]); + const heard = useMemo( + () => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow, heardData) : []), + [heardData, intervalMs, heardWindow], + ); + const busy = useMemo( + () => busyOption(heard, colors, intervalMs, heardWindow, t("charts.busy")), + [heard, colors, intervalMs, heardWindow, t], + ); + const heardCount = useMemo( + () => heardOption(heard, colors, heardWindow, t("observerPage.packets")), + [heard, colors, heardWindow, t], + ); + const snr = useMemo( + () => + snrHeardOption(heard, colors, heardWindow, { + average: t("observerPage.mean"), + minimum: t("observerPage.minimum"), + }), + [heard, colors, heardWindow, t], + ); + const payloadItems = useMemo( + () => + payloadBarItems(heardData?.payloadTypes ?? []).map((item) => + item.name.toLowerCase() === "unknown" ? { ...item, name: t("observerPage.unknown") } : item, + ), + [heardData, t], + ); + const payload = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); + const nothingHeard = heardData != null && heardData.points.length === 0; + const costed = heardData?.points.some((p) => p.airtimeMs != null) ?? false; + const heardSnr = heard.some((p) => p.snrAvg != null); + const radioLabel = formatRadioParts(heardData?.radio ?? {}); + const interval = heardData?.interval ?? activityParamsFor(range).interval; + const perBucket = + interval === "1h" + ? t("observerPage.hour") + : interval === "24h" + ? t("observerPage.day") + : interval.replace("m", " min").replace("h", " h"); -
- {!selectedObserverId ? ( - - - - ) : ( - <> - {observer.data && } - {noTelemetry ? ( - - - - ) : ( - <> - Airtime TX / RX · {range}} - height={180} - option={airtime} - isLoading={telemetry.isLoading} - isError={telemetry.isError} - isEmpty={missing((p) => p.airtimeTxPct, (p) => p.airtimeRxPct)} - /> -
- p.batteryMv)} /> - p.noiseFloorDb)} /> - p.queueLength)} /> - p.receiveErrors)} /> -
- - )} - - )} + if (!selectedObserverId) return ; + if (observer.isError) + return ( +
+

{t("observerPage.loadFailed")}

+
-
+ ); + if (!observer.data) + return ( +

+ {t("observerPage.recording")} +

+ ); + return ( +
+ + {comparison && { + const nextUntil = comparison.onRefresh(); + void observer.refetch(); + void telemetry.refetch(); + if (nextUntil === comparison.until) void activity.refetch(); + return nextUntil; + }} />} + {heardUnavailable ? ( + +

{t("observerPage.summaryMissing")}

+
+ ) : nothingHeard ? ( + + + + ) : ( + <> +
+ + + +
+ {activity.isError && ( + + )} + + )} +

{t("observerPage.device")}

+ {noTelemetry ? ( + }> + {null} + + ) : ( + <> + {airtimeLabel(latestAirtime)}} + height={200} + option={airtime} + isLoading={telemetry.isLoading} + isError={!telemetry.data && telemetry.isError} + isEmpty={missing( + (p) => p.airtimeTxSecs, + (p) => p.airtimeRxSecs, + )} + /> +
+ p.batteryMv)} + /> + p.noiseFloorDb)} + /> + p.queueLength)} + /> + p.receiveErrors)} + /> +
+ {telemetry.isError && ( + + )} + + )} + {!heardUnavailable && costed && ( + {radioLabel}} + height={180} + option={busy} + isLoading={heardLoading} + isError={!heardData && activity.isError} + /> + )} +
); } diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx new file mode 100644 index 0000000..cd2b200 --- /dev/null +++ b/src/features/stats/PathsTab.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; +import { formatCount, formatUtc } from "../../lib/formatters"; +import { Card, ChartCard, StatCard } from "./cards"; +import { donutOption } from "./chartOptions"; +import { tooltipStyle, useChartColors } from "./chartTheme"; +import { pathHours, pathLengthOption, pathTrendOption } from "./paths"; +import { usePathStats } from "./usePathStats"; +import type { StatsRange } from "./types"; + +export function PathsTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); + const query = usePathStats(range), c = useChartColors(); + const loading = query.isPending || query.isPlaceholderData; + const data = loading || query.isError ? undefined : query.data; + const hours = useMemo(() => pathHours(data), [data]); + const categories = useMemo(() => [ + { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.primary }, + { name: t("paths.categories.empty"), value: data?.empty ?? 0, color: c.textDim }, + { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.warn }, + { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.secondary }, + ], [data, c, t]); + const charts = useMemo(() => { + const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: t("paths.hashWidth", { count: bin.bytes }), value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), t("paths.hashPathsCenter")); + const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), t("paths.receptionsCenter")); + return { + width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: t("paths.widthDescription") } } }, + coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: t("paths.coverageDescription") } } }, + lengths: pathLengthOption(data?.pathLengths ?? [], c, t), trend: pathTrendOption(hours, c, t), + }; + }, [data, c, categories, hours, t]); + const state = { isLoading: loading, isError: query.isError }; + const sparks = useMemo(() => { + const counts = hours.map(({ oneByte, twoByte, threeByte }) => + oneByte === null || twoByte === null || threeByte === null ? null : { hashed: oneByte + twoByte + threeByte, multi: twoByte + threeByte }); + return { + receptions: hours.map((h) => h.receptions), + hashed: counts.map((n) => n?.hashed ?? null), + multiShare: counts.map((n) => n?.hashed ? n.multi / n.hashed : null), + maxEntries: hours.map((h) => h.maxEntries), + }; + }, [hours]); + const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0; + const largest = data?.pathLengths.at(-1)?.entries; + + return
+ + {query.isError &&

{t("paths.error")}

} +
+ + + + +
+
+ + +
+ +
+ + }> + {!data ?

{query.isError ? t("common.dataUnavailable") : t("paths.loading")}

: !data.receptions ?

{t("paths.empty")}

: + + {categories.map((item) => )} +
{t("paths.category")}{t("paths.receptionsColumn")}{t("paths.share")}
{item.name}{item.value.toLocaleString()}{(100 * item.value / data.receptions).toFixed(1)}%
} +
+
+ {data && data.receptions > 0 &&
+ {t("paths.details")} +
+ {data.hashWidths.map((bin) => )}
{t("paths.bytesPerHash")}{t("paths.receptionsColumn")}
{bin.bytes}{bin.receptions.toLocaleString()}
+
{data.pathLengths.map((bin) => )}
{t("paths.headerEntries")}{t("paths.receptionsColumn")}
{bin.entries}{bin.receptions.toLocaleString()}
+
+
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{formatUtc(row.hour)}{n.toLocaleString()}
+
} +
; +} diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx new file mode 100644 index 0000000..f2e733d --- /dev/null +++ b/src/features/stats/ScopesTab.tsx @@ -0,0 +1,67 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; +import { rolledWindow, useScopes, useStatsSeries } from "./useStats"; +import { useChartColors } from "./chartTheme"; +import { Card, ChartCard, StatCard } from "./cards"; +import { scopeChartOption, scopeHourly, scopeSummary } from "./scopes"; +import { formatCount } from "../../lib/formatters"; +import type { StatsRange } from "./types"; + +export function ScopesTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); + const query = useScopes(range); + const series = useStatsSeries(range); + const colors = useChartColors(); + const [search, setSearch] = useState(""); + const loading = query.isPending || query.isLoading || query.isPlaceholderData; + const unavailable = loading || query.isError; + const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]); + const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]); + const totals = useMemo(() => scopeSummary(rows), [rows]); + const seriesData = series.isSuccess && !series.isPlaceholderData ? series.data : undefined; + const seriesHours = seriesData?.hours; + // packet counts come from rollups, so before the first rolled hour a zero means unknown + const packetsUnknown = seriesData?.completeHours === 0; + // the scopes fetch saw at most what was rolled when it landed, so its window can't reach past that + const scopeWindow = useMemo(() => query.dataUpdatedAt ? rolledWindow(range, query.dataUpdatedAt) : undefined, [query.dataUpdatedAt, range]); + const sparks = useMemo(() => unavailable ? null : scopeHourly(rows, seriesHours, scopeWindow), [rows, seriesHours, unavailable, scopeWindow]); + const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors, t), [rows, colors, t]); + const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors, t), [rows, colors, t]); + const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors, t), [rows, colors, t]); + const value = (number: number) => unavailable ? "—" : formatCount(number); + const height = Math.max(180, Math.min(13, rows.length) * 28 + 16); + + return ( +
+ + + {query.isError &&

{t("scopes.error")}

} +
+ + + + +
+
+ + + + {unavailable ? "—" : t("scopes.visibleCount", { visible: rows.length, count: all.length })}}> + {unavailable ?

{query.isError ? t("common.dataUnavailable") : t("scopes.loading")}

: !rows.length ?

{search ? t("scopes.noMatches") : t("scopes.empty")}

: ( +
+ + {rows.map((row) => + + + )} +
{t("scopes.scope")}{t("scopes.packets")}{t("scopes.observers")}{t("scopes.nodes")}
{row.name}{row.packetCount.toLocaleString()}{row.observerCount.toLocaleString()}{row.nodeCount.toLocaleString()}
+ )} +
+
+
+ ); +} diff --git a/src/features/stats/SectionInfo.tsx b/src/features/stats/SectionInfo.tsx new file mode 100644 index 0000000..f65a951 --- /dev/null +++ b/src/features/stats/SectionInfo.tsx @@ -0,0 +1,10 @@ +import { useContext, type ComponentProps } from "react"; +import { createPortal } from "react-dom"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfoSlot } from "./section-info-slot"; + +// Sits beside the section chooser in the analytics bar; a tab rendered outside it keeps the tip in place. +export function SectionInfo({ text }: ComponentProps) { + const slot = useContext(SectionInfoSlot); + return slot ? createPortal(, slot) : ; +} diff --git a/src/features/stats/Segmented.tsx b/src/features/stats/Segmented.tsx index ee905ac..b43e844 100644 --- a/src/features/stats/Segmented.tsx +++ b/src/features/stats/Segmented.tsx @@ -11,7 +11,7 @@ interface SegmentedProps { value: string; onChange: (value: string) => void; ariaLabel: string; - size?: "sm" | "md"; + size?: "xs" | "sm" | "md"; className?: string; } @@ -19,7 +19,8 @@ interface SegmentedProps { // tint + inset ring, inactive pills are muted. Used for the Stats sub-tabs (md, with icons) and the // time-range selector (sm). Active state is conveyed with aria-pressed, not color alone. export function Segmented({ options, value, onChange, ariaLabel, size = "sm", className }: SegmentedProps) { - const pad = size === "md" ? "px-3 py-1.5 text-xs" : "px-2.5 py-1 text-[11px]"; + // xs matches the 28px filter-bar controls it sits beside + const pad = size === "md" ? "px-3 py-1.5 text-xs" : size === "xs" ? "px-2.5 py-[3px] text-[11px] leading-4" : "px-2.5 py-1 text-[11px]"; return (
value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; + +export function SignalTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); + const query = useSignalStats(range); + const c = useChartColors(); + const loading = query.isPending || query.isPlaceholderData; + const data = loading || query.isError ? undefined : query.data; + const hours = useMemo(() => signalHours(data), [data]); + const charts = useMemo(() => ({ + snr: signalHistogramOption(data?.snr, "SNR", "dB", c, t), rssi: signalHistogramOption(data?.rssi, "RSSI", "dBm", c, t), + snrTrend: signalTrendOption(hours, "snr", c, t), rssiTrend: signalTrendOption(hours, "rssi", c, t), coverage: signalCoverageOption(data, c, t), + }), [data, hours, c, t]); + const state = { isLoading: loading, isError: query.isError }; + const sparks = useMemo(() => data && { + receptions: hours.map((h) => h.receptions), + snr: hours.map((h) => h.snrAverage), + rssi: hours.map((h) => h.rssiAverage), + reported: hours.map((h) => h.receptions !== null), + }, [data, hours]); + + return ( +
+ + {query.isError &&

{t("signal.error")}

} +
+ + + + +
+
+ + + + +
+
+ + }> + {!data ?

{query.isError ? t("common.dataUnavailable") : t("signal.loading")}

: !data.receptions ?

{t("signal.empty")}

: ( +
+ + {(["snr", "rssi"] as const).map((metric) => )} +
{t("signal.metric")}{t("signal.samples")}{t("signal.missing")}{t("signal.share")}
{metric.toUpperCase()}{data[metric].samples.toLocaleString()}{(data.receptions - data[metric].samples).toLocaleString()}{(100 * data[metric].samples / data.receptions).toFixed(1)}%
+ )} +
+
+ {data && data.receptions > 0 &&
+ {t("signal.details")} +
+ {(["snr", "rssi"] as const).map((metric) => + + {data[metric].histogram.map((bin, i) => )} +
{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}{t("signal.samples")}
{signalBinLabel(bin, t)}{bin.count.toLocaleString()}
)} +
+
+ + {data.hourly.map((row) => )} +
{t("signal.utcHour")}{t("signal.receptionsColumn")}{t("signal.metricSamples", { metric: "SNR" })}{t("signal.meanUnit", { unit: "dB" })}{t("signal.metricSamples", { metric: "RSSI" })}{t("signal.meanUnit", { unit: "dBm" })}
{formatUtc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
+
} +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 3159c2e..8f1ef4f 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,32 +1,38 @@ -import { useCallback } from "react"; -import { useSearchParams } from "react-router-dom"; -import type { WsManager } from "../../api/ws-manager"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { StatsSubHeader } from "./StatsSubHeader"; +import { SectionInfoSlot } from "./section-info-slot"; import { MeshTab } from "./MeshTab"; +import { TrafficTab } from "./TrafficTab"; +import { SignalTab } from "./SignalTab"; +import { PathsTab } from "./PathsTab"; +import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; -import { ObserverTab } from "./ObserverTab"; +import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; +import { observerDestination } from "../observers/observer-navigation"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); interface StatsOverviewProps { - wsManager: WsManager; + onViewNode?: (nodeId: string) => void; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active -// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the -// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. -export function StatsOverview({ wsManager }: StatsOverviewProps) { +// Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live +// in the URL (?statsTab/?range) so the view is shareable. +export function StatsOverview({ onViewNode }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); + const navigate = useNavigate(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); - const observerId = params.get("observerId"); + const legacyObserver = params.get("statsTab") === "observer"; + const [infoSlot, setInfoSlot] = useState(null); const patch = useCallback( (updates: Record) => { @@ -47,20 +53,29 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]); const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]); - const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); + const handleSelectObserver = useCallback((id: string) => navigate({ search: "?" + observerDestination(params, id, range).toString() }), [navigate, params, range]); + + // The observer dashboard moved to the Observers tab; old ?statsTab=observer links land there. + useEffect(() => { + if (legacyObserver) navigate({ search: "?" + observerDestination(params, params.get("observerId"), range).toString() }, { replace: true }); + }, [legacyObserver, navigate, params, range]); return ( -
- +
+ +
- {tab === "mesh" && } - {tab === "talkers" && } + {tab === "mesh" && } + {tab === "traffic" && } + {tab === "signal" && } + {tab === "paths" && } + {tab === "scopes" && } + {tab === "talkers" && } {tab === "clockdrift" && } - {tab === "observer" && ( - - )} {tab === "graph" && } + {tab === "compare" && }
+
); } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index b4bc6ca..5150838 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,8 +1,28 @@ import { Segmented } from "./Segmented"; +import { useTranslation } from "react-i18next"; import { SelectDropdown } from "../../components/SelectDropdown"; import { useIsMobile } from "../../hooks/useMediaQuery"; import type { StatsRange, StatsTab } from "./types"; +function TrafficIcon() { + return + + ; +} + +function ScopesIcon() { + return + + ; +} + +function CompareIcon() { + return + + + ; +} + function MeshIcon() { return ( @@ -14,13 +34,10 @@ function MeshIcon() { ); } -function ObserverIcon() { - return ( - - - - - ); +function SignalIcon() { + return + + ; } function TalkersIcon() { @@ -41,6 +58,13 @@ function ClockDriftIcon() { ); } +function PathsIcon() { + return + + + ; +} + function GraphIcon() { return ( @@ -55,62 +79,66 @@ function GraphIcon() { } const TAB_OPTIONS = [ - { value: "mesh", label: "Mesh", icon: }, - { value: "talkers", label: "Talkers", icon: }, - { value: "clockdrift", label: "Clock Drift", icon: }, - { value: "observer", label: "Observer", icon: }, - { value: "graph", label: "Neighbour Graph", icon: }, + { value: "mesh", icon: }, + { value: "traffic", icon: }, + { value: "signal", icon: }, + { value: "paths", icon: }, + { value: "scopes", icon: }, + { value: "talkers", icon: }, + { value: "clockdrift", icon: }, + { value: "compare", icon: }, + { value: "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" }, - { value: "30d", label: "30d" }, -]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; interface Props { tab: StatsTab; onTabChange: (tab: StatsTab) => void; range: StatsRange; onRangeChange: (range: StatsRange) => void; + infoSlot?: (element: HTMLElement | null) => void; } -export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { +export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, infoSlot }: Props) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const tabOptions = TAB_OPTIONS.map((option) => ({ ...option, label: t(`stats.tabs.${option.value}`) })); + const rangeOptions = RANGES.map((value) => ({ value, label: t(`stats.ranges.${value}`) })); return (
+
{/* pills don't scale on a phone as sections grow — swap to a compact dropdown there */} {isMobile ? ( onTabChange(v as StatsTab)} /> ) : (
onTabChange(v as StatsTab)} - ariaLabel="Stats section" + ariaLabel={t("stats.section")} size="md" />
)} - {/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */} - {tab !== "graph" && tab !== "clockdrift" && ( + +
+ {/* Comparison has explicit dates; graph and clock drift have no rolling window. */} + {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && ( onRangeChange(v as StatsRange)} - ariaLabel="Time range" + ariaLabel={t("stats.timeRange")} /> )}
diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 0ef7587..f60b5a3 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useChartColors } from "./chartTheme"; -import { useTopAdvertisers, useTopTalkers } from "./useStats"; +import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; import { Card, ChartCard } from "./cards"; import { DataTable, type Column } from "../../components/DataTable"; @@ -12,6 +13,7 @@ import type { TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { range: StatsRange; + onViewNode?: (nodeId: string) => void; } // grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing @@ -19,17 +21,27 @@ function leaderboardHeight(count: number) { return Math.max(260, count * 34 + 24); } +// The server reads rolled hours from the hour holding `since`, so the counts stop at the rolled edge. +function coveredMs(range: StatsRange, fetchedAt: number) { + const hour = 3_600_000; + return rolledWindow(range, fetchedAt).until - Math.floor((fetchedAt - RANGE_MS[range]) / hour) * hour; +} + // The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers list // their flood/direct advert split with a per-day rate; talkers are grouped by sender display-name. -export function TalkersTab({ range }: TalkersTabProps) { +export function TalkersTab({ range, onViewNode }: TalkersTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertisers = topAdvertisers.data ?? []; + const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData; + const talkersLoading = topTalkers.isPending || topTalkers.isPlaceholderData; + const talkersUnavailable = talkersLoading || topTalkers.isError; + const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []); const advertiserColumns = useMemo[]>(() => { - const windowMs = RANGE_MS[range]; + const windowMs = coveredMs(range, topAdvertisers.dataUpdatedAt); // count over the compacted total, then the per-day rate for the same window in muted text const split = (count: number) => ( @@ -38,50 +50,51 @@ export function TalkersTab({ range }: TalkersTabProps) { ); return [ { - header: "Node", + header: t("talkers.node"), cell: (a) => (
- {a.nodeName ?? a.nodeId.slice(0, 8)} + {a.nodeName ?? a.publicKey.slice(0, 8)} {a.nodeTypeName} {a.iata}
), - sortValue: (a) => a.nodeName ?? a.nodeId, + sortValue: (a) => a.nodeName ?? a.publicKey, }, - { header: "Flood", className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount }, - { header: "Direct", className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount }, + { header: t("talkers.flood"), className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount }, + { header: t("talkers.direct"), className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount }, ]; - }, [range]); + }, [range, t, topAdvertisers.dataUpdatedAt]); const talkerRows = useMemo( - () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), - [topTalkers.data, colors], + () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.secondary })), + [topTalkers.data, colors, talkersUnavailable], ); const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); + const rangeLabel = t(`stats.ranges.${range}`); return ( -
- Top advertisers · {range}} right={flood · direct}> +
+ {t("talkers.floodDirect")}}>
a.nodeId} + rowKey={(a) => a.publicKey} selectedKey={null} - onSelect={() => {}} - isLoading={topAdvertisers.isLoading} - emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} + onSelect={(key) => { const id = advertisers.find((a) => a.publicKey === key)?.nodeId; if (id) onViewNode?.(id); }} + isLoading={advertisersLoading} + emptyLabel={topAdvertisers.isError ? t("common.loadFailed") : t("talkers.noAdvertisers")} />
Top talkers · {range}} - right={by name} + title={t("talkers.topTalkers", { range: rangeLabel })} + right={{t("talkers.byName")}} height={leaderboardHeight(talkerRows.length)} option={talkersOption} - isLoading={topTalkers.isLoading} + isLoading={talkersLoading} isError={topTalkers.isError} isEmpty={talkerRows.length === 0} /> diff --git a/src/features/stats/TrafficTab.tsx b/src/features/stats/TrafficTab.tsx new file mode 100644 index 0000000..f14ee51 --- /dev/null +++ b/src/features/stats/TrafficTab.tsx @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; +import { formatCount } from "../../lib/formatters"; +import { useStatsObservations } from "./useStats"; +import { tooltipStyle, useChartColors } from "./chartTheme"; +import { donutOption } from "./chartOptions"; +import { Card, ChartCard, StatCard } from "./cards"; +import { trafficAreaLabel, trafficModel, trafficHeatmapOption, trafficTrendOption } from "./traffic"; +import type { StatsRange } from "./types"; + +export function TrafficTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); + const query = useStatsObservations(range); + const colors = useChartColors(); + const loading = query.isPending || query.isLoading || query.isPlaceholderData; + const unavailable = loading || query.isError; + const model = useMemo(() => trafficModel(unavailable ? [] : (query.data ?? []), range, query.dataUpdatedAt), [query.data, query.dataUpdatedAt, range, unavailable]); + const trend = useMemo(() => trafficTrendOption(model, colors, t), [model, colors, t]); + const heatmap = useMemo(() => trafficHeatmapOption(model, colors, t), [model, colors, t]); + const share = useMemo(() => { + const option = donutOption(model.series.map((area, i) => ({ name: trafficAreaLabel(area.name, t), value: area.total, color: colors.series[i] })), colors, formatCount(model.total), t("traffic.receptionsCenter")); + return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: t("traffic.shareDescription") } } }; + }, [model, colors, t]); + const value = (number: number) => unavailable ? "—" : formatCount(number); + const sparks = useMemo(() => unavailable ? null : { + totals: model.hours.map((h) => h.total), + areas: model.hours.map((h) => h.areas), + reported: model.hours.map((h) => h.total !== null), + }, [model, unavailable]); + + return ( +
+ + {query.isError &&

{t("traffic.error")}

} +
+ + area.total > 0).length)} accent={colors.green} spark={sparks?.areas} /> + + +
+ {t("traffic.hourlyRecords")}} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> + +
+ + }> + {unavailable ?

{query.isError ? t("common.dataUnavailable") : t("traffic.loading")}

: !model.areas.length ?

{t("traffic.empty")}

: ( +
+ + {model.areas.map((area, i) => + + + )} +
{t("traffic.area")}{t("traffic.receptionsColumn")}{t("traffic.share")}
{trafficAreaLabel(area.name, t)}{area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%
+ )} +
+
+
+ ); +} diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 6dd4611..d10e237 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { EChart } from "./EChart"; import type { EChartsOption } from "./echarts-setup"; @@ -25,45 +26,85 @@ export function Card({ ); } -function Sparkline({ values, color }: { values: number[]; color: string }) { - if (values.length < 2) return
; - const w = 120; - const h = 20; - const max = Math.max(...values); - const min = Math.min(...values); +const SPARK_W = 120; +const SPARK_H = 20; +const sparkSpacer =
; + +function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) { + const known = values.filter((v): v is number => v !== null); + if (values.length < 2 || known.length < 2) return sparkSpacer; + const max = Math.max(...known); + const min = Math.min(...known); const range = max - min || 1; - const pts = values - .map((v, i) => `${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`) - .join(" "); + const x = (i: number) => (i / (values.length - 1)) * SPARK_W; + const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2); + // A null hour breaks the line, so an outage shows as a gap rather than a straight join. + const runs: string[][] = [[]]; + values.forEach((v, i) => { + if (v === null) runs.push([]); + else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`); + }); + const peak = values.indexOf(max); return ( - - + + {runs.filter((run) => run.length > 1).map((run) => ( + + ))} + {markPeak && ( + // zero-length round-capped stroke: stays a circle under preserveAspectRatio="none" + + )} ); } -// KPI tile: label, big mono value, optional sparkline + sub-label. +// One bar per run of hours that reported, so gaps read at a glance. +function PresenceStrip({ hours, color }: { hours: boolean[]; color: string }) { + if (hours.length === 0) return sparkSpacer; + const slot = SPARK_W / hours.length; + const bars: { start: number; length: number }[] = []; + hours.forEach((on, i) => { + const last = bars.at(-1); + if (!on) return; + if (last && last.start + last.length === i) last.length++; + else bars.push({ start: i, length: 1 }); + }); + return ( + + + {bars.map((bar) => ( + + ))} + + ); +} + +// KPI tile: label, big mono value, optional sparkline (or hourly presence strip) + sub-label. export function StatCard({ label, value, accent, spark, + markPeak, + presence, sublabel, }: { label: string; value: ReactNode; accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)" - spark?: number[]; + spark?: (number | null)[]; + markPeak?: boolean; + presence?: boolean[]; sublabel?: ReactNode; }) { return (
-
- {label} - {sublabel && {sublabel}} +
+ {label} + {sublabel && {sublabel}}
-
{value}
- {spark ? :
} +
{value}
+ {presence ? : spark ? : sparkSpacer}
); } @@ -96,15 +137,16 @@ export function ChartCard({ onEvents?: Record void>; className?: string; }) { + const { t } = useTranslation(); return (
{isError ? ( - Failed to load + {t("common.loadFailed")} ) : isLoading ? ( - Loading… + {t("common.loading")} ) : isEmpty ? ( - No data + {t("common.noData")} ) : ( )} diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 9b0e840..42a3f0c 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -1,7 +1,8 @@ import type { EChartsOption } from "./echarts-setup"; import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme"; import { formatCount } from "../../lib/formatters"; -import type { TelemetryPoint } from "./types"; +import { airtimePctSeries, busyPct } from "./transforms"; +import type { ActivityPoint, TelemetryPoint } from "./types"; const MONO = "JetBrains Mono, monospace"; @@ -28,19 +29,20 @@ function valueAxis(c: ChartColors, extra: Record = {}) { // ---- Mesh ---- +// null values are hours the server hasn't rolled (or never can); echarts breaks the line there. export function observationsAreaOption( - points: { hour: number; observationCount: number; uniquePackets: number }[], + points: { hour: number; observations: number | null; uniquePackets: number | null }[], c: ChartColors, + labels = { observations: "Observations", uniquePackets: "Unique packets" }, ): EChartsOption { - const obs = points.map((p) => [p.hour, p.observationCount]); - const uniq = points.map((p) => [p.hour, p.uniquePackets]); return { animation: false, + useUTC: true, backgroundColor: "transparent", grid: { left: 48, right: 14, top: 12, bottom: 24 }, tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "line", lineStyle: { color: c.primary } } }, legend: { - data: ["Observations", "Unique packets"], + data: [labels.observations, labels.uniquePackets], right: 8, top: 0, itemWidth: 10, @@ -52,11 +54,11 @@ export function observationsAreaOption( yAxis: valueAxis(c), series: [ { - name: "Observations", + name: labels.observations, type: "line", smooth: true, symbol: "none", - data: obs, + data: points.map((p) => [p.hour, p.observations]), lineStyle: { color: c.primary, width: 2 }, itemStyle: { color: c.primary }, areaStyle: { @@ -71,11 +73,11 @@ export function observationsAreaOption( }, }, { - name: "Unique packets", + name: labels.uniquePackets, type: "line", smooth: true, symbol: "none", - data: uniq, + data: points.map((p) => [p.hour, p.uniquePackets]), lineStyle: { color: c.secondary, width: 1.3, type: "dashed" }, itemStyle: { color: c.secondary }, }, @@ -153,6 +155,7 @@ export function presetBarsOption( rows: { name: string; nodes: number; observers: number }[], c: ChartColors, gridLeft = 172, // fits a full "910.525 · 62.5k · SF7" label + labels = { nodes: "Nodes", observers: "Observers" }, ): EChartsOption { const totals = rows.map((r) => r.nodes + r.observers); const segment = (data: number[], color: string) => ({ @@ -169,7 +172,7 @@ export function presetBarsOption( grid: { left: gridLeft, right: 56, top: 22, bottom: 6 }, tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "shadow" } }, legend: { - data: ["Nodes", "Observers"], + data: [labels.nodes, labels.observers], right: 8, top: 0, itemWidth: 10, @@ -195,9 +198,9 @@ export function presetBarsOption( }, }, series: [ - { name: "Nodes", ...segment(rows.map((r) => r.nodes), c.primary) }, + { name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.primary) }, { - name: "Observers", + name: labels.observers, ...segment(rows.map((r) => r.observers), c.secondary), // outer segment carries the row total so it sits at the end of the whole stack label: { @@ -307,33 +310,25 @@ export function typeBarOption( // ---- Observer telemetry ---- // `t` arrives in epoch ms. -// 1h points are cumulative counters, so chart per-report deltas (clamped at 0 for resets); -// bucketed points already arrive as per-bucket deltas, so chart those as-is. -function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") { - const out: [number, number | null][] = []; - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]![key]; - const cur = points[i]![key]; - const d = prev != null && cur != null ? Math.max(0, cur - prev) : null; - out.push([points[i]!.t, d]); - } - return out; +function percentAxis(c: ChartColors) { + return valueAxis(c, { axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value}%" } }); } -export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketed: boolean): EChartsOption { - const series = (key: "airtimeRxPct" | "airtimeTxPct") => - bucketed ? points.map((p) => [p.t, p[key]]) : deltaSeries(points, key); +const pctLabel = (v: unknown) => (typeof v === "number" ? `${v}%` : "—"); + +export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs: number | null): EChartsOption { return { animation: false, + useUTC: true, backgroundColor: "transparent", - grid: { left: 44, right: 14, top: 24, bottom: 22 }, + grid: { left: 48, right: 14, top: 24, bottom: 22 }, legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, - tooltip: { trigger: "axis", ...tooltipStyle(c) }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: pctLabel }, xAxis: timeAxis(c), - yAxis: valueAxis(c), + yAxis: percentAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } @@ -358,6 +353,7 @@ function metricLineOption( ): EChartsOption { return { animation: false, + useUTC: true, backgroundColor: "transparent", grid: { left: 50, right: 14, top: 14, bottom: 22 }, tooltip: { trigger: "axis", ...tooltipStyle(c) }, @@ -379,15 +375,99 @@ function metricLineOption( }; } -export const batteryOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); +export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Battery V") => + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); -export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb }); +export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => + metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); -export const queueOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); +export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => + metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => - metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => + metricLineOption(p, c, { name, color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); + +// ---- Observer activity (what it heard) ---- + +export interface TimeWindow { + start: number; // epoch ms + end: number; +} + +// Pin the axis to the selected range so a quiet observer shows empty space up to now. +function windowAxis(c: ChartColors, w: TimeWindow) { + return { ...timeAxis(c), min: w.start, max: w.end }; +} + +// The bucket still in progress is measured against the time elapsed so far, not the full width. +function busySpanMs(t: number, intervalMs: number, w: TimeWindow): number { + return t < w.end && t + intervalMs > w.end ? w.end - t : intervalMs; +} + +export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: number | null, w: TimeWindow, name = "Busy"): EChartsOption { + const pct = (p: ActivityPoint) => (intervalMs == null ? null : busyPct(p.airtimeMs, busySpanMs(p.t, intervalMs, w))); + return { + animation: false, + useUTC: true, + backgroundColor: "transparent", + grid: { left: 48, right: 14, top: 14, bottom: 22 }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: pctLabel }, + xAxis: windowAxis(c, w), + yAxis: percentAxis(c), + series: [ + { + name, + type: "line", + symbol: "none", + connectNulls: false, + data: points.map((p) => [p.t, pct(p)]), + lineStyle: { width: 1.5, color: c.green }, + areaStyle: { color: withAlpha(c.green, 0.28) }, + itemStyle: { color: c.green }, + }, + ], + }; +} + +export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, name = "Heard"): EChartsOption { + return { + animation: false, + useUTC: true, + backgroundColor: "transparent", + grid: { left: 48, right: 14, top: 14, bottom: 22 }, + tooltip: { trigger: "axis", ...tooltipStyle(c) }, + xAxis: windowAxis(c, w), + yAxis: valueAxis(c, { minInterval: 1 }), + series: [ + { + name, + type: "line", + symbol: "none", + data: points.map((p) => [p.t, p.observations]), + lineStyle: { width: 1.5, color: c.primary }, + areaStyle: { color: withAlpha(c.primary, 0.28) }, + itemStyle: { color: c.primary }, + }, + ], + }; +} + +const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—"); + +export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, labels = { average: "Avg", minimum: "Min" }): EChartsOption { + return { + animation: false, + useUTC: true, + backgroundColor: "transparent", + grid: { left: 54, right: 14, top: 24, bottom: 22 }, + legend: { data: [labels.average, labels.minimum], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: dbLabel }, + xAxis: windowAxis(c, w), + yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), + series: [ + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, + ], + }; +} diff --git a/src/features/stats/echarts-locales.d.ts b/src/features/stats/echarts-locales.d.ts new file mode 100644 index 0000000..5340c38 --- /dev/null +++ b/src/features/stats/echarts-locales.d.ts @@ -0,0 +1,5 @@ +// echarts ships no typings next to its ESM locale files +declare module "echarts/lib/i18n/langFR.js" { + const lang: Parameters[1]; + export default lang; +} diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index b41cf57..e94708d 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, GraphChart } from "echarts/charts"; +import { LineChart, BarChart, PieChart, GaugeChart, GraphChart, HeatmapChart } from "echarts/charts"; import { GridComponent, TitleComponent, @@ -12,8 +12,11 @@ import { GraphicComponent, DataZoomComponent, MarkLineComponent, + VisualMapComponent, + AriaComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; +import langFR from "echarts/lib/i18n/langFR.js"; echarts.use([ LineChart, @@ -21,6 +24,7 @@ echarts.use([ PieChart, GaugeChart, GraphChart, + HeatmapChart, GridComponent, TitleComponent, TooltipComponent, @@ -28,9 +32,14 @@ echarts.use([ GraphicComponent, DataZoomComponent, MarkLineComponent, + VisualMapComponent, + AriaComponent, CanvasRenderer, ]); +// time-axis month/day names; EN is built in +echarts.registerLocale("FR", langFR); + export { echarts }; export type EChartsInstance = ReturnType; export type EChartsOption = Parameters[0]; diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 6586c4f..ce544c2 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; 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"; @@ -172,10 +173,10 @@ export function buildEgoGraph( // 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) { +function graphCategories(c: ChartColors, t: TFunction) { return [ - ...NODE_TYPES.map((t) => ({ name: t.label, itemStyle: { color: nodeTypeColor(t.name, c) } })), - { name: "Other", itemStyle: { color: c.primaryDim } }, + ...NODE_TYPES.map((type) => ({ name: t(`nodeTypes.${type.name}`), itemStyle: { color: nodeTypeColor(type.name, c) } })), + { name: t("neighbourGraph.other"), itemStyle: { color: c.primaryDim } }, ]; } @@ -185,6 +186,7 @@ function graphCategories(c: ChartColors) { export function neighbourGraphOption( graph: NeighbourGraph, c: ChartColors, + t: TFunction, opts: { ego?: boolean } = {}, ): EChartsOption { const ego = !!opts.ego; @@ -207,16 +209,16 @@ export function neighbourGraphOption( 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`}`; + return days === 0 ? t("neighbourGraph.edgeToday", { obs }) : t("neighbourGraph.edgeAgo", { obs, days }); } 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}`; + const type = d.nodeTypeName || t("neighbourGraph.unknownType"); + return d.degree > 0 ? `${d.name}\n${type} · ${t("neighbourGraph.neighbors", { count: d.degree })}` : `${d.name}\n${type}`; }, }, legend: [ { - data: graphCategories(c).map((cat) => cat.name), + data: graphCategories(c, t).map((cat) => cat.name), bottom: 4, left: "center", icon: "circle", @@ -233,7 +235,7 @@ export function neighbourGraphOption( roam: true, draggable: true, scaleLimit: { min: 0.2, max: 8 }, - categories: graphCategories(c), + categories: graphCategories(c, t), 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 }, diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts new file mode 100644 index 0000000..d583e82 --- /dev/null +++ b/src/features/stats/paths.ts @@ -0,0 +1,47 @@ +import type { TFunction } from "i18next"; +import type { EChartsOption } from "echarts"; +import type { PathLengthBin, PathStats } from "./types"; +import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; + +const HOUR = 3_600_000; +export function pathHours(data: PathStats | undefined) { + if (!data || data.until <= data.since) return []; + const first = Math.floor(data.since / HOUR) * HOUR; + const rows = new Map(data.hourly.map((row) => [row.hour, row])); + return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { + const hour = first + i * HOUR, row = rows.get(hour); + return { hour, receptions: row?.receptions ?? null, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null, maxEntries: row?.maxEntries ?? null }; + }); +} + +export function pathLengthOption(bins: PathLengthBin[], c: ChartColors, t: TFunction): EChartsOption { + const max = Math.min(63, Math.max(0, ...bins.map((bin) => bin.entries))); + const counts = new Map(bins.map((bin) => [bin.entries, bin.receptions])); + const entries = Array.from({ length: max + 1 }, (_, i) => i); + return { + animation: false, + aria: { enabled: true, label: { description: t("paths.lengthDescription") } }, + grid: { left: 8, right: 12, top: 18, bottom: 38, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "category", data: entries, name: t("paths.entries"), nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + }; +} + +export function pathTrendOption(hours: ReturnType, c: ChartColors, t: TFunction): EChartsOption { + return { + animation: false, useUTC: true, + aria: { enabled: true, label: { description: t("paths.trendDescription") } }, + legend: { top: 0, textStyle: { color: c.textNormal, fontSize: 10 }, itemWidth: 10, itemHeight: 10 }, + grid: { left: 8, right: 18, top: 35, bottom: 15, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: (["oneByte", "twoByte", "threeByte"] as const).map((field, i) => ({ + name: t("paths.hashWidth", { count: i + 1 }), type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4, + lineStyle: { width: 1.5, color: c.series[i] }, itemStyle: { color: c.series[i] }, areaStyle: { color: withAlpha(c.series[i]!, 0.32) }, + data: hours.map((hour) => [hour.hour, hour[field]]), + })), + }; +} diff --git a/src/features/stats/scopes.ts b/src/features/stats/scopes.ts new file mode 100644 index 0000000..1622634 --- /dev/null +++ b/src/features/stats/scopes.ts @@ -0,0 +1,58 @@ +import type { TFunction } from "i18next"; +import type { ScopeStats } from "./types"; +import { leaderboardOption } from "./chartOptions"; +import { tooltipStyle, type ChartColors } from "./chartTheme"; +import type { EChartsOption } from "./echarts-setup"; + +export type ScopeMetric = "packetCount" | "observerCount" | "nodeCount"; + +export function scopeSummary(rows: ScopeStats[]) { + return rows.reduce((summary, row) => ({ + active: summary.active + (row.packetCount || row.observerCount || row.nodeCount ? 1 : 0), + packets: summary.packets + row.packetCount, + memberships: summary.memberships + row.observerCount, + nodes: summary.nodes + row.nodeCount, + }), { active: 0, packets: 0, memberships: 0, nodes: 0 }); +} + +export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors, t: TFunction): EChartsOption { + const indices = new Map(rows.map((row) => row.name).sort().map((name, index) => [name, index])); + const ranked = rows.map((row) => ({ name: row.name, value: row[metric], color: colors.series[(indices.get(row.name) ?? 0) % colors.series.length] ?? colors.primary })) + .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); + const shown = ranked.slice(0, 12); + if (ranked.length > 12) shown.push({ name: t("scopes.other"), value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim }); + return { ...leaderboardOption(shown, colors, 126), tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, + aria: { enabled: true, label: { description: t("scopes.chartDescription") } } }; +} + +// Per-hour activity for the shown rows. The series says which hours are rolled: an hour missing from a +// scope's hourly is zero, an unrolled hour is a gap. Like the cards, observers and nodes add up per scope. +// Hours outside `window` (what the scopes fetch covered) are gaps too, since its hourly can't speak for them. +export function scopeHourly(rows: ScopeStats[], hours: { hour: number; status: string }[] | undefined, window?: { since: number; until: number }) { + if (!hours || rows.some((row) => !row.hourly)) return null; + const zero = { packets: 0, active: 0, observers: 0, nodes: 0 }; + const byHour = new Map(); + let hasActivity = true; + for (const row of rows) { + for (const entry of row.hourly ?? []) { + if (entry.observers === undefined || entry.nodes === undefined) hasActivity = false; + const observers = entry.observers ?? 0, nodes = entry.nodes ?? 0; + const sum = byHour.get(entry.hour) ?? { ...zero }; + byHour.set(entry.hour, { + packets: sum.packets + entry.packets, + active: sum.active + (entry.packets || observers || nodes ? 1 : 0), + observers: sum.observers + observers, + nodes: sum.nodes + nodes, + }); + } + } + const covered = (hour: number) => !window || (hour >= window.since && hour < window.until); + const slots = hours.map((h) => (h.status === "complete" && covered(h.hour) ? (byHour.get(h.hour) ?? zero) : null)); + const line = (key: keyof typeof zero) => slots.map((slot) => slot && slot[key]); + return { + packets: line("packets"), + active: line("active"), + observers: hasActivity ? line("observers") : undefined, + nodes: hasActivity ? line("nodes") : undefined, + }; +} diff --git a/src/features/stats/section-info-slot.ts b/src/features/stats/section-info-slot.ts new file mode 100644 index 0000000..cd5a344 --- /dev/null +++ b/src/features/stats/section-info-slot.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const SectionInfoSlot = createContext(null); diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts new file mode 100644 index 0000000..dddad10 --- /dev/null +++ b/src/features/stats/signal.ts @@ -0,0 +1,70 @@ +import type { EChartsOption } from "echarts"; +import type { TFunction } from "i18next"; +import type { SignalBin, SignalMetric, SignalStats } from "./types"; +import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; + +const HOUR = 3_600_000; +export function signalHours(data: SignalStats | undefined) { + if (!data || data.until <= data.since) return []; + const first = Math.floor(data.since / HOUR) * HOUR; + const rows = new Map(data.hourly.map((row) => [row.hour, row])); + return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { + const hour = first + i * HOUR, row = rows.get(hour); + return { + hour, receptions: row?.receptions ?? null, + snrAverage: row && row.snrSamples > 0 ? row.snrAverage : null, + rssiAverage: row && row.rssiSamples > 0 ? row.rssiAverage : null, + }; + }); +} + +export function signalBinLabel(bin: SignalBin, t: TFunction) { + if (bin.lower === null) return `< ${bin.upper}`; + if (bin.upper === null) return `≥ ${bin.lower}`; + return t("signal.binInterval", { lower: bin.lower, upper: bin.upper }); +} + +export function signalHistogramOption(metric: SignalMetric | undefined, name: string, unit: string, c: ChartColors, t: TFunction): EChartsOption { + const bins = metric?.histogram ?? []; + return { + animation: false, + aria: { enabled: true, label: { description: t("signal.histogramDescription", { metric: name, unit }) } }, + grid: { left: 8, right: 10, top: 20, bottom: 16, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin, t)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], + }; +} + +export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors, t: TFunction): EChartsOption { + const color = metric === "snr" ? c.secondary : c.green; + const unit = metric === "snr" ? "dB" : "dBm"; + return { + animation: false, useUTC: true, + aria: { enabled: true, label: { description: t("signal.trendDescription", { metric: metric.toUpperCase(), unit }) } }, + grid: { left: 8, right: 18, top: 20, bottom: 16, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c), valueFormatter: (value) => typeof value === "number" ? `${value.toFixed(2)} ${unit}` : t("signal.noSample") }, + xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", scale: true, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: `${metric.toUpperCase()} · ${unit}`, type: "line", connectNulls: false, showSymbol: true, symbolSize: 5, + lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { color: withAlpha(color, 0.08) }, + data: hours.map((hour) => [hour.hour, hour[metric === "snr" ? "snrAverage" : "rssiAverage"]]) }], + }; +} + +export function signalCoverageOption(data: SignalStats | undefined, c: ChartColors, t: TFunction): EChartsOption { + return { + animation: false, + aria: { enabled: true, label: { description: t("signal.coverageDescription") } }, + grid: { left: 8, right: 12, top: 30, bottom: 12, containLabel: true }, + legend: { top: 0, textStyle: { color: c.textMuted } }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, hideOverlap: true }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + yAxis: { type: "category", data: ["SNR", "RSSI"], axisLabel: { color: c.textNormal }, axisLine: { show: false }, axisTick: { show: false } }, + series: [ + { name: t("signal.available"), type: "bar", stack: "samples", barMaxWidth: 22, itemStyle: { color: c.primary }, data: [data?.snr.samples ?? 0, data?.rssi.samples ?? 0] }, + { name: t("signal.unavailable"), type: "bar", stack: "samples", itemStyle: { color: c.warn }, data: [data ? data.receptions - data.snr.samples : 0, data ? data.receptions - data.rssi.samples : 0] }, + ], + }; +} diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts new file mode 100644 index 0000000..3b24501 --- /dev/null +++ b/src/features/stats/traffic.ts @@ -0,0 +1,95 @@ +import type { TFunction } from "i18next"; +import type { EChartsOption } from "./echarts-setup"; +import { tooltipStyle, type ChartColors } from "./chartTheme"; +import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types"; + +const HOUR = 3_600_000; +const MONO = "JetBrains Mono, monospace"; + +export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf: number) { + const end = Math.floor(asOf / HOUR) * HOUR; + const length = RANGE_MS[range] / HOUR; + const start = end - (length - 1) * HOUR; + const buckets = new Map>(); + const totals = new Map(); + for (const point of points) { + if (!Number.isFinite(point.hour) || point.hour < start || point.hour > end || !Number.isFinite(point.observationCount) || point.observationCount < 0) continue; + const hour = Math.floor(point.hour / HOUR) * HOUR; + const name = point.iata.trim().toUpperCase() || "Unassigned"; + const bucket = buckets.get(hour) ?? new Map(); + bucket.set(name, (bucket.get(name) ?? 0) + point.observationCount); + buckets.set(hour, bucket); + totals.set(name, (totals.get(name) ?? 0) + point.observationCount); + } + const areas = [...totals].map(([name, total]) => ({ name, total })).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); + const names = areas.slice(0, 7).map((area) => area.name); + const hours = Array.from({ length }, (_, i) => { + const hour = start + i * HOUR; + const bucket = buckets.get(hour); + return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null, areas: bucket ? bucket.size : null }; + }); + const series = names.map((name) => ({ + name, total: totals.get(name) ?? 0, + values: hours.map(({ hour }) => buckets.has(hour) ? (buckets.get(hour)?.get(name) ?? 0) : null), + })); + if (areas.length > names.length) { + series.push({ name: "Other IATAs", total: areas.slice(7).reduce((sum, area) => sum + area.total, 0), + values: hours.map(({ hour, total }) => total === null ? null : total - names.reduce((sum, name) => sum + (buckets.get(hour)?.get(name) ?? 0), 0)) }); + } + const days = [...new Set(hours.map(({ hour }) => new Date(hour).toISOString().slice(0, 10)))]; + const heatmap: number[][] = []; + let peak: { hour: number; total: number } | null = null; + for (const { hour, total } of hours) { + if (total === null) continue; + heatmap.push([new Date(hour).getUTCHours(), days.indexOf(new Date(hour).toISOString().slice(0, 10)), total]); + if (!peak || total > peak.total) peak = { hour, total }; + } + return { hours, days, heatmap, peak, areas, series, reportedHours: buckets.size, total: areas.reduce((sum, area) => sum + area.total, 0) }; +} + +export type TrafficModel = ReturnType; + +export function trafficAreaLabel(name: string, t: TFunction): string { + return name === "Unassigned" ? t("traffic.unassigned") : name === "Other IATAs" ? t("traffic.otherIatas") : name; +} + +export function trafficTrendOption(model: TrafficModel, c: ChartColors, t: TFunction): EChartsOption { + const present = model.hours.filter((hour) => hour.total !== null); + const first = present[0]?.hour; + const last = present.at(-1)?.hour; + return { + animation: false, useUTC: true, backgroundColor: "transparent", + aria: { enabled: true, label: { description: t("traffic.trendDescription") } }, + grid: { left: 54, right: 18, top: 42, bottom: 28 }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + legend: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: c.textNormal, fontSize: 10 }, inactiveColor: c.textDim }, + xAxis: { type: "time", min: first === last && first !== undefined ? first - HOUR / 2 : first, max: first === last && last !== undefined ? last + HOUR / 2 : last, + axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } }, splitLine: { show: false } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.border, opacity: 0.4 } } }, + series: model.series.map((series, index) => ({ + name: trafficAreaLabel(series.name, t), type: "line", stack: "receptions", connectNulls: false, smooth: false, + symbol: "circle", symbolSize: 5, showSymbol: present.length <= 2, + data: model.hours.map((hour, i) => [hour.hour, series.values[i]]), + lineStyle: { color: c.series[index], width: 1.5 }, itemStyle: { color: c.series[index] }, areaStyle: { color: c.series[index], opacity: 0.45 }, + })), + }; +} + +export function trafficHeatmapOption(model: TrafficModel, c: ChartColors, t: TFunction): EChartsOption { + return { + animation: false, backgroundColor: "transparent", + aria: { enabled: true, label: { description: t("traffic.heatmapDescription") } }, + grid: { left: 58, right: 14, top: 8, bottom: 62 }, + tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(c), formatter: (item: { value: unknown }) => { + const value = item.value as [number, number, number]; + return t("traffic.heatmapTooltip", { day: model.days[value[1]], hour: String(value[0]).padStart(2, "0"), count: value[2], value: value[2].toLocaleString() }); + } }, + xAxis: { type: "category", data: Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0")), + axisLabel: { color: c.textMuted, fontSize: 9, interval: 2 }, axisTick: { show: false }, axisLine: { show: false }, splitArea: { show: true, areaStyle: { color: [c.bgRaised, c.bgSurface] } } }, + yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false }, + axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } }, + visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130, + text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } }, + series: [{ name: t("traffic.receptionsColumn"), type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], + }; +} diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index a6d3256..43cd752 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -1,4 +1,4 @@ -import type { RadioPreset, TelemetryPoint } from "./types"; +import type { ActivityPoint, PayloadBreakdownItem, RadioPreset, TelemetryPoint } from "./types"; // Collapse presets to one row each (keeping the node/observer split), dropping junk "0,0,0" configs. export function aggregatePresets(rows: RadioPreset[]): { preset: string; nodes: number; observers: number }[] { @@ -37,11 +37,94 @@ export function hasTelemetry(points: TelemetryPoint[]): boolean { return points.some( (p) => live(p.batteryMv) || - live(p.airtimeTxPct) || - live(p.airtimeRxPct) || + live(p.airtimeTxSecs) || + live(p.airtimeRxSecs) || live(p.noiseFloorDb) || live(p.uptimeSeconds) || live(p.queueLength) || live(p.receiveErrors), ); } + +// "15m" / "6h" → ms; null for anything the server wouldn't send. +export function intervalToMs(interval: string): number | null { + const m = /^(\d+)([mh])$/.exec(interval); + if (!m) return null; + const n = Number(m[1]); + return m[2] === "m" ? n * 60_000 : n * 3_600_000; +} + +// share of `windowSeconds` spent on air, rounded so tooltips don't show float noise +function pct(seconds: number, windowSeconds: number): number { + return Math.round(((seconds * 100) / windowSeconds) * 1000) / 1000; +} + +type AirtimeKey = "airtimeRxSecs" | "airtimeTxSecs"; + +// The values are on-air seconds: cumulative on raw 1h points, per-bucket on +// bucketed ones. Chart the increase as a percent of the elapsed time, clamped at 0 on counter resets. +export function airtimePctSeries(points: TelemetryPoint[], key: AirtimeKey, bucketMs: number | null): [number, number | null][] { + if (bucketMs != null) { + return points.map((p) => { + const v = p[key]; + return [p.t, v == null ? null : pct(v, bucketMs / 1000)]; + }); + } + const out: [number, number | null][] = []; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const cur = points[i]!; + const a = prev[key]; + const b = cur[key]; + const gapMs = cur.t - prev.t; + out.push([cur.t, a != null && b != null && gapMs > 0 ? pct(Math.max(0, b - a), gapMs / 1000) : null]); + } + return out; +} + +// Most recent RX / TX percent, for the header stat. +export function latestAirtimePct(points: TelemetryPoint[], bucketMs: number | null): { rx: number | null; tx: number | null } { + const last = (key: AirtimeKey) => { + const s = airtimePctSeries(points, key, bucketMs); + return s.length ? s[s.length - 1]![1] : null; + }; + return { rx: last("airtimeRxSecs"), tx: last("airtimeTxSecs") }; +} + +// The server skips empty buckets; fill them so a quiet stretch draws as zero instead of a skipped line. +// Starts at the first complete bucket (the server rounds its window start up the same way) and lets +// one bucket past the window end through, so a client clock behind the server can't hide fresh data. +// Buckets between rolledUntil and rawFrom were never read (rollup backlog, or no rollup yet when +// rolledUntil is absent), so they stay gaps. +export function fillActivity( + points: ActivityPoint[], + intervalMs: number, + window: { start: number; end: number }, + coverage?: { rolledUntil?: number; rawFrom?: number }, +): ActivityPoint[] { + const snap = (t: number) => Math.floor(t / intervalMs) * intervalMs; + const first = Math.ceil(window.start / intervalMs) * intervalMs; + const current = snap(window.end); + const last = points.some((p) => snap(p.t) === current + intervalMs) ? current + intervalMs : current; + const byBucket = new Map(points.map((p) => [snap(p.t), p])); + const { rolledUntil, rawFrom } = coverage ?? {}; + const unread = (t: number) => rawFrom != null && t < rawFrom && (rolledUntil == null || t >= rolledUntil); + const out: ActivityPoint[] = []; + for (let t = first; t <= last; t += intervalMs) { + const filled = unread(t) + ? { t, observations: null, airtimeMs: null, snrAvg: null, snrMin: null, rssiAvg: null } + : { t, observations: 0, airtimeMs: 0, snrAvg: null, snrMin: null, rssiAvg: null }; + out.push(byBucket.get(t) ?? filled); + } + return out; +} + +// Payload breakdown rows as the bar chart wants them: busiest first, lowercase names. +export function payloadBarItems(items: PayloadBreakdownItem[]): { name: string; value: number }[] { + return [...items].sort((a, b) => b.count - a.count).map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })); +} + +// Percent of a bucket spent receiving; null when the server couldn't cost the bucket. +export function busyPct(airtimeMs: number | null, intervalMs: number): number | null { + return airtimeMs == null ? null : pct(airtimeMs / 1000, intervalMs / 1000); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index f3c72f7..965a64c 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -1,21 +1,55 @@ -// Response shapes for the /stats/* endpoints and observer telemetry. Verified against beacon-server. +// Response shapes for the /stats/* endpoints and observer telemetry. Verified against beacon-server, +// except the activity block, which follows the endpoint handoff until the server ships it. import type { NodeIATA } from "../nodes/types"; -export interface StatsOverview { +// beacon-server /stats/observer-comparison: disjoint groups of distinct flood +// packet hashes, selected by reception time in [since, until). +export interface ObserverComparison { + observerA: string; + observerB: string; + since: number; + until: number; totalPackets: number; - totalObservations: number; + onlyA: number; + onlyB: number; + both: number; +} + +// beacon-server /stats/series: hourly rollups. Only complete hours carry values; the summary covers +// those, and observers/IATAs/scopes are distinct across the window so they don't sum from the hours. +export interface SeriesValues { + observations: number; + uniquePackets: number; activeObservers: number; activeIatas: number; - windowHours: number; + scopedPackets: number; + activeScopes: number; + maxPathEntries: number; + snrSum: number; + snrSamples: number; + rssiSum: number; + rssiSamples: number; +} +export interface SeriesHour { + hour: number; // epoch ms, UTC hour start + status: "complete" | "partial" | "missing"; + values: SeriesValues | null; +} +export interface StatsSeries { + since: number; + until: number; + revision: number; + earliestComplete: number | null; + completeHours: number; + hours: SeriesHour[]; + summary: SeriesValues; } export interface ObservationPoint { hour: number; // epoch ms, start of the hourly bucket iata: string; observationCount: number; - uniquePackets: number; - activeObservers: number; } export interface PayloadBreakdownItem { @@ -25,7 +59,8 @@ export interface PayloadBreakdownItem { } export interface TopNode { - nodeId: string; + nodeId: string | null; // null once the node row is deleted; key rows by publicKey + publicKey: string; nodeName: string | null; nodeType: number; nodeTypeName: string; @@ -43,7 +78,8 @@ export interface TopObserver { } export interface TopAdvertiser { - nodeId: string; + nodeId: string | null; // null once the node row is deleted; key rows by publicKey + publicKey: string; nodeName: string | null; nodeType: number; nodeTypeName: string; @@ -93,13 +129,15 @@ export interface ScopeStats { packetCount: number; observerCount: number; nodeCount: number; + // hours where all three are zero are omitted; older servers omit hourly, or observers/nodes within it + hourly?: { hour: number; packets: number; observers?: number; nodes?: number }[]; } export interface TelemetryPoint { t: number; // epoch ms batteryMv: number | null; - airtimeTxPct: number | null; - airtimeRxPct: number | null; + airtimeTxSecs: number | null; // cumulative since boot on 1h points, per-bucket delta otherwise + airtimeRxSecs: number | null; noiseFloorDb: number | null; uptimeSeconds: number | null; queueLength: number | null; @@ -112,8 +150,50 @@ export interface ObserverTelemetry { points: TelemetryPoint[]; } +// GET /observers/{id}/activity: what the observer heard per `interval`. The tab hides these charts on a 404. +export interface ActivityPoint { + t: number; // epoch ms, bucket start + observations: number | null; // null only for a filled gap the server never read + airtimeMs: number | null; // summed LoRa time-on-air; null when no row in the bucket could be costed + snrAvg: number | null; + snrMin: number | null; + rssiAvg: number | null; +} + +export interface ActivityRadio { + freqMhz: number | null; + sf: number | null; + bwKhz: number | null; + cr: number | null; + preambleSymbols: number | null; +} + +export interface ObserverActivitySummary { + recordedPackets: number; + lastCompleteHour: number; + lastCompleteHourStart: number; + lastCompleteHourEnd: number; + latestRecordedAt: number | null; +} +export interface ObserverActivity { + windowStart?: number; + windowEnd?: number; + generatedAt?: number; + source?: "raw" | "hourly"; + // hourly only: rollups cover buckets before rolledUntil (absent before the first rollup), raw rows + // from rawFrom; hours between are unread + rolledUntil?: number; + rawFrom?: number; + summary?: ObserverActivitySummary; + range: string; + interval: string; + radio: ActivityRadio | null; + payloadTypes: PayloadBreakdownItem[]; + points: ActivityPoint[]; +} + // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "graph"; +export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "compare" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { @@ -121,3 +201,50 @@ export const RANGE_MS: Record = { "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; + +// beacon-server /stats/signal: retained observation rows in [since, until). +export interface SignalBin { lower: number | null; upper: number | null; count: number } +export interface SignalMetric { samples: number; average: number | null; histogram: SignalBin[] } +export interface SignalHour { + hour: number; + receptions: number; + snrSamples: number; + snrAverage: number | null; + rssiSamples: number; + rssiAverage: number | null; +} +export interface SignalStats { + since: number; + until: number; + receptions: number; + snr: SignalMetric; + rssi: SignalMetric; + hourly: SignalHour[]; +} + +// beacon-server /stats/paths: retained receptions; the four categories partition the total. +export interface PathHashWidth { bytes: number; receptions: number } +export interface PathLengthBin { entries: number; receptions: number } +export interface PathHour { + hour: number; + receptions: number; + oneByte: number; + twoByte: number; + threeByte: number; + empty: number; + trace: number; + unclassified: number; + maxEntries?: number; // longest path that hour; servers before beacon-server's maxEntries omit it +} +export interface PathStats { + since: number; + until: number; + receptions: number; + hashed: number; + empty: number; + trace: number; + unclassified: number; + hashWidths: PathHashWidth[]; + pathLengths: PathLengthBin[]; + hourly: PathHour[]; +} diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index 50624d3..f14e3fe 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -1,56 +1,9 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useRegion } from "../../hooks/useRegion"; -import { useWsPacketHandler, useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; +import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; import type { WsManager } from "../../api/ws-manager"; -import type { WsPacketObservation, WsObserverStatus } from "../../types/ws"; -import type { StatsOverview, StatsRange } from "./types"; - -// Live overview KPIs: every packetObservation bumps the cached overview counters (no refetch). High -// frequency, so increments are coalesced and flushed once per animation frame. The overview query also -// refetches periodically (useStatsOverview) so the live deltas self-correct against the server. -// both totalPackets and totalObservations feed the top KPIs, so both bumps count. -export function useLiveOverview(wsManager: WsManager) { - const { regionKey } = useRegion(); - const qc = useQueryClient(); - const pending = useRef({ packets: 0, obs: 0 }); - const raf = useRef(null); - - const flush = useCallback(() => { - raf.current = null; - const { packets, obs } = pending.current; - if (!packets && !obs) return; - pending.current = { packets: 0, obs: 0 }; - qc.setQueryData(["stats-overview", regionKey], (old) => - old - ? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs } - : old, - ); - }, [qc, regionKey]); - - const onPacket = useCallback( - (data: WsPacketObservation["data"]) => { - pending.current.obs += 1; - if (data.packet?.isFirstObservation) pending.current.packets += 1; - if (raf.current == null) raf.current = requestAnimationFrame(flush); - }, - [flush], - ); - - useWsPacketHandler(wsManager, onPacket); - - // drop counts accumulated for the previous region so they don't bleed into the new one's KPIs - useEffect(() => { - pending.current = { packets: 0, obs: 0 }; - }, [regionKey]); - - useEffect( - () => () => { - if (raf.current != null) cancelAnimationFrame(raf.current); - }, - [], - ); -} +import type { WsObserverStatus } from "../../types/ws"; +import type { StatsRange } from "./types"; // When the selected observer reports a status update, refresh its header + telemetry so battery, // uptime, and the newest points reflect the change. Status messages are infrequent, so a refetch is fine. diff --git a/src/features/stats/usePathStats.ts b/src/features/stats/usePathStats.ts new file mode 100644 index 0000000..3fe7e7c --- /dev/null +++ b/src/features/stats/usePathStats.ts @@ -0,0 +1,7 @@ +import { getPathStats } from "../../api/client"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; + +export function usePathStats(range: StatsRange) { + return useWindowedStats("stats-paths", range, getPathStats); +} diff --git a/src/features/stats/useSignalStats.ts b/src/features/stats/useSignalStats.ts new file mode 100644 index 0000000..297b55a --- /dev/null +++ b/src/features/stats/useSignalStats.ts @@ -0,0 +1,7 @@ +import { getSignalStats } from "../../api/client"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; + +export function useSignalStats(range: StatsRange) { + return useWindowedStats("stats-signal", range, getSignalStats); +} diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 1c5f72c..f5680fb 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -1,7 +1,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useRegion } from "../../hooks/useRegion"; import { - getStatsOverview, + getStatsSeries, getStatsObservations, getPayloadBreakdown, getTopNodes, @@ -12,35 +12,63 @@ import { getStatsScopes, getStatsNodeTypes, getClockDrift, + type StatsRegion, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; -// Shared query options: cache for 30s, keep previous data so region/range switches don't flash. +// Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch. const common = { staleTime: 30_000, placeholderData: keepPreviousData, refetchOnWindowFocus: false, } as const; -// `since` is computed inside queryFn so refetches use a fresh window without churning the query key. -const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range]; +// A region with no member IATAs is sent by slug so the server returns zeros rather than every IATA. +export function useStatsRegion() { + const { iatas, regionKey, isResolved, emptyRegion } = useRegion(); + const where: StatsRegion = emptyRegion ? { region: emptyRegion } : iatas; + return { where, regionKey, isResolved }; +} -export function useStatsOverview() { - const { iatas, regionKey } = useRegion(); +// Holds a query until the selected region resolves; an unresolved slug would otherwise fetch every IATA. +export function gatedOn(isResolved: boolean | undefined, fetch: (signal: AbortSignal) => Promise) { + return { + enabled: isResolved !== false, + queryFn: ({ signal }: { signal: AbortSignal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + return fetch(signal); + }, + }; +} + +// `since` is computed inside queryFn so refetches use a fresh window without churning the query key. +export const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range]; + +const HOUR_MS = 3_600_000; +// Same end as the server's overview window: the hour starting at H can be rolled from H + 95 min. +export function rolledWindow(range: StatsRange, now = Date.now()) { + const until = Math.floor((now - 95 * 60_000) / HOUR_MS) * HOUR_MS + HOUR_MS; + return { since: until - RANGE_MS[range], until }; +} + +export function useStatsSeries(range: StatsRange) { + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-overview", regionKey], - queryFn: () => getStatsOverview(iatas), + queryKey: ["stats-series", regionKey, range], + ...gatedOn(isResolved, (signal) => { + const { since, until } = rolledWindow(range); + return getStatsSeries(since, until, where, signal); + }), ...common, - // self-correct the WS-accumulated live counters against the server refetchInterval: 60_000, }); } export function useStatsObservations(range: StatsRange) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-observations", regionKey, range], - queryFn: () => getStatsObservations(iatas, sinceFor(range)), + ...gatedOn(isResolved, (signal) => getStatsObservations(where, sinceFor(range), signal)), ...common, // feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh refetchInterval: 60_000, @@ -48,84 +76,87 @@ export function useStatsObservations(range: StatsRange) { } export function usePayloadBreakdown(range: StatsRange) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-payload", regionKey, range], - queryFn: () => getPayloadBreakdown(iatas, sinceFor(range)), + ...gatedOn(isResolved, () => getPayloadBreakdown(where, sinceFor(range))), ...common, }); } -export function useTopNodes(limit = 10) { - const { iatas, regionKey } = useRegion(); +export function useTopNodes(range: StatsRange, limit = 10) { + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-top-nodes", regionKey, limit], - queryFn: () => getTopNodes(iatas, limit), + queryKey: ["stats-top-nodes", regionKey, range, limit], + ...gatedOn(isResolved, () => getTopNodes(where, sinceFor(range), limit)), ...common, }); } export function useTopObservers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-observers", regionKey, range, limit], - queryFn: () => getTopObservers(iatas, sinceFor(range), limit), + ...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), limit)), ...common, }); } export function useTopAdvertisers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-advertisers", regionKey, range, limit], - queryFn: () => getTopAdvertisers(iatas, sinceFor(range), limit), + ...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), limit)), ...common, }); } export function useTopTalkers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-talkers", regionKey, range, limit], - queryFn: () => getTopTalkers(iatas, sinceFor(range), limit), + ...gatedOn(isResolved, () => getTopTalkers(where, sinceFor(range), limit)), ...common, }); } export function useRadioPresets() { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-radio-presets", regionKey], - queryFn: () => getRadioPresets(iatas), + ...gatedOn(isResolved, () => getRadioPresets(where)), ...common, }); } // node-types is a population census (no time window), so the key is region-only export function useNodeTypes() { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-node-types", regionKey], - queryFn: () => getStatsNodeTypes(iatas), + ...gatedOn(isResolved, () => getStatsNodeTypes(where)), ...common, }); } // clock drift reflects each node's latest measured drift, not a windowed aggregate, so region-only export function useClockDrift(limit = 100) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-clock-drift", regionKey, limit], - queryFn: () => getClockDrift(iatas, limit), + ...gatedOn(isResolved, () => getClockDrift(where, limit)), ...common, }); } -// scopes are reported globally by the backend (no region filter), so the key is region-independent -export function useScopes() { +// Packet counts are windowed from the series' first rolled hour so both cover the same hours; +// observer and node counts are current membership. +export function useScopes(range: StatsRange) { + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-scopes"], - queryFn: getStatsScopes, + queryKey: ["stats-scopes", regionKey, range], + ...gatedOn(isResolved, (signal) => getStatsScopes(where, rolledWindow(range).since, signal)), ...common, + refetchInterval: 60_000, }); } diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index f2ec34c..a65e309 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,5 +1,5 @@ -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { getObserver, getObserverTelemetry } from "../../api/client"; +import { useQuery } from "@tanstack/react-query"; +import { getObserver, getObserverTelemetry, getObserverActivity, isNotFound } from "../../api/client"; import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. @@ -17,16 +17,45 @@ const INTERVAL_PARAM: Record = { "30d": "24h", }; -export function useObserver(observerId: string | null) { +export function useObserver(observerId: string | null, poll = false) { return useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId!), enabled: !!observerId, staleTime: 30_000, + refetchInterval: poll ? 60_000 : false, refetchOnWindowFocus: false, }); } +// Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. +const ACTIVITY_INTERVAL: Record = { + "24h": "15m", + "7d": "1h", + "30d": "6h", +}; + +export function activityParamsFor(range: StatsRange): { range: string; interval: string } { + return { range: RANGE_PARAM[range], interval: ACTIVITY_INTERVAL[range] }; +} + +// Heard activity moves with packets, so poll; stop once a 404 says this server has no endpoint. +export function activityRefetchInterval(error: unknown): number | false { + return isNotFound(error) ? false : 60_000; +} + +export function useObserverActivity(observerId: string | null, range: StatsRange, until?: number) { + const params = activityParamsFor(range); + return useQuery({ + queryKey: ["observer-activity", observerId, range, until], + queryFn: () => getObserverActivity(observerId!, params.range, params.interval, until), + enabled: !!observerId, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchInterval: (query) => until == null ? activityRefetchInterval(query.state.error) : false, + }); +} + export function useObserverTelemetry(observerId: string | null, range: StatsRange) { const interval = INTERVAL_PARAM[range]; return useQuery({ @@ -34,7 +63,6 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } diff --git a/src/features/stats/useWindowedStats.ts b/src/features/stats/useWindowedStats.ts new file mode 100644 index 0000000..7ff4df5 --- /dev/null +++ b/src/features/stats/useWindowedStats.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import type { StatsRegion } from "../../api/client"; +import { gatedOn, useStatsRegion } from "./useStats"; +import { RANGE_MS, type StatsRange } from "./types"; + +// Shared minute boundaries let viewers reuse server aggregates without changing the key every render. +const minuteFloor = () => Math.floor(Date.now() / 60_000) * 60_000; + +// Shared by usePathStats/useSignalStats (and any future /stats/* window query): resolves the +// region, floors the window to minute boundaries and polls every minute. +export function useWindowedStats( + key: string, + range: StatsRange, + fetch: (since: number, until: number, where: StatsRegion, signal?: AbortSignal) => Promise, +) { + const { where, regionKey, isResolved } = useStatsRegion(); + return useQuery({ + queryKey: [key, regionKey, range], + ...gatedOn(isResolved, (signal) => { + const until = minuteFloor(); + return fetch(until - RANGE_MS[range], until, where, signal); + }), + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/features/traces/TraceDetailPanel.tsx b/src/features/traces/TraceDetailPanel.tsx index e31398e..114119b 100644 --- a/src/features/traces/TraceDetailPanel.tsx +++ b/src/features/traces/TraceDetailPanel.tsx @@ -1,11 +1,11 @@ import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { getTraceDetail } from "../../api/client"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; -import { ResolvedHopBlock } from "../packets/PathData"; +import { HopSnr, ResolvedHopBlock } from "../packets/PathData"; import { ScopeTag } from "../../components/ScopeTag"; -import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import type { RawHop, ResolvedHop, TracePacket } from "../../types/api"; // A trace packet's path, rendered exactly like the TRACE payload view: the raw path-hash byte as the @@ -17,27 +17,19 @@ function TraceHopChain({ rawPath, resolvedRoute, onViewNode }: { resolvedRoute: ResolvedHop[]; onViewNode?: (nodeId: string) => void; }) { - if (rawPath.length === 0) return no path; + const { t } = useTranslation(); + if (rawPath.length === 0) return {t("traces.noPath")}; return (
- {rawPath.map((raw, i) => { - const snr = raw.snr; - const level = snr != null ? snrLevel(snr) : null; - const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; - return ( - - {i > 0 && →} - - - {snr != null ? ( - {formatSnr(snr)} dB - ) : ( - - - )} - + {rawPath.map((raw, i) => ( + + {i > 0 && →} + + + - ); - })} + + ))}
); } @@ -49,6 +41,7 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: { onAnalyze: (hash: string) => void; onViewNode?: (nodeId: string) => void; }) { + const { t } = useTranslation(); // A div, not a button: the hop popover nests clickable node buttons, so the row can't itself be a // button. Mirrors TraceTagCard's role/tabIndex/onKeyDown; hop clicks stopPropagation so they don't analyze. return ( @@ -65,16 +58,16 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: { }} >
- {pkt.routeTypeName || "Unknown"} + {pkt.routeTypeName || t("traces.unknown")} {pkt.scope && {pkt.scope}} - analyze → + {t("traces.analyze")}
- } /> - } /> + } /> + } />
- +
); @@ -91,6 +84,7 @@ interface TraceDetailPanelProps { // packets stand in for the packet analyzer's "Observations": a "Packets" section listing each packet, // any of which opens the packet analyzer. export function TraceDetailPanel({ tag, onClose, onAnalyze, onViewNode }: TraceDetailPanelProps) { + const { t } = useTranslation(); const { data: detail, isLoading } = useQuery({ queryKey: ["trace", tag], queryFn: () => getTraceDetail(tag), @@ -102,18 +96,18 @@ export function TraceDetailPanel({ tag, onClose, onAnalyze, onViewNode }: TraceD return ( -
+
{packets.length > 0 ? (
- {packets.length} packet{packets.length === 1 ? "" : "s"} + {t("traces.packetCount", { count: packets.length })} {packets.map((pkt) => ( ))}
) : ( - No packets + {t("traces.noPackets")} )}
diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index 38f49a3..b1d6765 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -1,11 +1,13 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { getTraces } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { SkeletonRows } from "../../components/SkeletonRows"; import { EmptyState } from "../../components/EmptyState"; import { Timestamp } from "../../components/Timestamp"; import { Badge } from "../../components/Badge"; +import { InfoTip } from "../../components/InfoTip"; import { Segmented } from "../stats/Segmented"; import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters"; import { TraceDetailPanel } from "./TraceDetailPanel"; @@ -15,13 +17,6 @@ import type { TraceTagSummary, TraceType } from "../../types/api"; // the card list (the /traces cursor is sound if pagination is ever needed). const TRACE_LIST_LIMIT = 200; -// "" = both; the backend takes TRACE or PING and omits the param to mean all. -const TYPE_OPTIONS = [ - { value: "", label: "All" }, - { value: "TRACE", label: "Trace" }, - { value: "PING", label: "Ping" }, -]; - interface TraceListProps { onAnalyze: (hash: string | null) => void; onViewNode?: (nodeId: string) => void; @@ -63,6 +58,7 @@ function TraceTagCard({ tag, selected, onSelect }: { selected: boolean; onSelect: (tag: string) => void; }) { + const { t } = useTranslation(); return (
- {tag.packetCount} pkt · {tag.iataCount} iata + {t("traces.pkt", { count: tag.packetCount })} · {t("traces.iata", { count: tag.iataCount })}
{tag.pathHashes?.length ? : null}
@@ -93,7 +89,14 @@ function TraceTagCard({ tag, selected, onSelect }: { } export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { - const { iatas, regionKey } = useRegion(); + const { t } = useTranslation(); + const { iatas, regionKey, isResolved } = useRegion(); + // "" = both; the backend takes TRACE or PING and omits the param to mean all. + const typeOptions = useMemo(() => [ + { value: "", label: t("traces.all") }, + { value: "TRACE", label: t("traces.trace") }, + { value: "PING", label: t("traces.ping") }, + ], [t]); const [selectedTag, setSelectedTag] = useState(null); const [typeFilter, setTypeFilter] = useState<"" | TraceType>(""); @@ -110,6 +113,7 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { queryKey: ["traces", regionKey, typeFilter], queryFn: () => getTraces(iatas, { limit: TRACE_LIST_LIMIT, type: typeFilter || undefined }), staleTime: 30_000, + enabled: isResolved !== false, }); return ( @@ -117,23 +121,26 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
- {tags ? `${tags.length} tag${tags.length === 1 ? "" : "s"}` : ""} + {tags ? t("traces.tagCount", { count: tags.length }) : ""} - setTypeFilter(v as "" | TraceType)} - ariaLabel="Trace type" - /> +
+ + setTypeFilter(v as "" | TraceType)} + ariaLabel={t("traces.typeLabel")} + /> +
- {isLoading ? ( + {isLoading || isResolved === false ? ( ) : (tags?.length ?? 0) === 0 ? ( - + ) : ( - tags!.map((t) => ( - + tags!.map((tag) => ( + )) )}
diff --git a/src/hooks/region-selection.ts b/src/hooks/region-selection.ts index 0469598..befefd1 100644 --- a/src/hooks/region-selection.ts +++ b/src/hooks/region-selection.ts @@ -32,6 +32,16 @@ export function resolveIatas( return [...set].sort(); } +// A selection of loaded regions with no member IATAs (and no picked IATAs) resolves like "all"; return +// one of its slugs so callers can ask the server for that region instead of every IATA. +export function emptyRegionSlug( + selection: RegionSelection, + regionIatas: ReadonlyMap, +): string | undefined { + if (isAllRegions(selection) || !selection.regions.every((slug) => regionIatas.has(slug))) return undefined; + return resolveIatas(selection, regionIatas) ? undefined : selection.regions[0]; +} + // Stable query-key fragment for a resolved IATA list. "*" stands in for "all regions". export function regionKey(iatas: string[] | undefined): string { return iatas && iatas.length > 0 ? iatas.join(",") : "*"; diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000..2df6495 --- /dev/null +++ b/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type CopyState = "idle" | "copied" | "failed"; + +// Copies text and flashes the outcome for 1.5s. navigator.clipboard is undefined outside a secure +// context (plain http), so the call goes through a promise executor to turn that throw into a rejection. +export function useCopyToClipboard(): { state: CopyState; copy: (text: string) => void } { + const [state, setState] = useState("idle"); + const timer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + + const copy = useCallback((text: string) => { + const flash = (next: CopyState) => { + setState(next); + clearTimeout(timer.current); + timer.current = setTimeout(() => setState("idle"), 1500); + }; + new Promise((resolve) => resolve(navigator.clipboard.writeText(text))).then( + () => flash("copied"), + () => flash("failed"), + ); + }, []); + + return { state, copy }; +} diff --git a/src/hooks/useInfinitePages.ts b/src/hooks/useInfinitePages.ts index 3184c12..4bd13a9 100644 --- a/src/hooks/useInfinitePages.ts +++ b/src/hooks/useInfinitePages.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo } from "react"; import { useInfiniteQuery, keepPreviousData, type QueryKey } from "@tanstack/react-query"; import type { CursorPage } from "../types/api"; -interface UseInfinitePagesOptions { +interface UseInfinitePagesOptions { queryKey: QueryKey; // fetch one page; cursor is the previous page's nextCursor (undefined for the first page) - queryFn: (cursor: number | undefined) => Promise>; + queryFn: (cursor: C | undefined) => Promise>; // stable id accessor for dedup — pass a module-level fn so the memo isn't rebuilt every render getId: (item: T) => string; // keep the prior key's rows on screen while a new key (e.g. a filter change) loads its first page @@ -23,13 +23,13 @@ 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, enabled = 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, - queryFn: ({ pageParam }) => queryFn(pageParam), + queryFn: ({ pageParam }) => queryFn(pageParam as C | undefined), getNextPageParam: (last) => last.nextCursor ?? undefined, - initialPageParam: undefined as number | undefined, + initialPageParam: undefined as C | undefined, staleTime: Infinity, enabled, placeholderData: keepPrevious ? keepPreviousData : undefined, diff --git a/src/hooks/useRateLimit.ts b/src/hooks/useRateLimit.ts new file mode 100644 index 0000000..8f7388b --- /dev/null +++ b/src/hooks/useRateLimit.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; +import { getRateLimitedUntil, subscribeRateLimit } from "../api/rate-limit"; + +// epoch ms until which the API is throttling us, or null +export function useRateLimit(): number | null { + return useSyncExternalStore(subscribeRateLimit, getRateLimitedUntil); +} diff --git a/src/hooks/useRegion.tsx b/src/hooks/useRegion.tsx index bb6310e..694cf5e 100644 --- a/src/hooks/useRegion.tsx +++ b/src/hooks/useRegion.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { getRegions, getRegion } from "../api/client"; import { resolveIatas, + emptyRegionSlug, regionKey as toRegionKey, serializeSelection, type RegionSelection, @@ -50,6 +51,7 @@ export interface RegionsData { regions: Region[]; // full detail (member IATAs + map-focus hints), ordered by the API bySlug: ReadonlyMap; regionIatas: ReadonlyMap; // slug → member IATA codes, for selection resolution + settled: boolean; // the region list has loaded } // Loads the region list and each region's detail (member IATAs). Regions are near-static, so this is @@ -60,7 +62,13 @@ export function useRegions(): RegionsData { queryKey: ["regions"], queryFn: async () => { const summaries = await getRegions(); - return Promise.all(summaries.map((s) => getRegion(s.id))); + // a listed region whose detail 404s is dropped like an unknown slug; 4xx is never retried, + // so failing the whole query would leave the selection unresolved for good + const regions = await Promise.all(summaries.map((s) => getRegion(s.id).catch((err: unknown) => { + if ((err as { status?: unknown } | null)?.status !== 404) throw err; + return null; + }))); + return regions.filter((r): r is Region => r !== null); }, staleTime: 5 * 60_000, }); @@ -73,22 +81,34 @@ export function useRegions(): RegionsData { bySlug.set(r.slug, r); regionIatas.set(r.slug, r.iatas); } - return { regions, bySlug, regionIatas }; + return { regions, bySlug, regionIatas, settled: data !== undefined }; }, [data]); } export interface RegionFilter { - iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions + iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions, [] = a region with none regionKey: string; // stable query-key fragment ("*" = all) + isResolved?: boolean; // false while a selected region's member IATAs are unavailable + emptyRegion?: string; // slug of a selected region that has no member IATAs } // The resolved geographic filter consumers pass to queries: the flattened IATA list plus a stable key. export function useRegion(): RegionFilter { const { selection } = useRegionSelection(); - const { regionIatas } = useRegions(); + const { regionIatas, settled } = useRegions(); return useMemo(() => { const iatas = resolveIatas(selection, regionIatas); - return { iatas, regionKey: toRegionKey(iatas) }; - }, [selection, regionIatas]); + const emptyRegion = emptyRegionSlug(selection, regionIatas); + // a slug missing from a loaded list (deleted region, stale link) must not hold everything up + const isResolved = settled || selection.regions.every((slug) => regionIatas.has(slug)); + const key = emptyRegion ? `region:${emptyRegion}` : toRegionKey(iatas); + return { + iatas: emptyRegion ? [] : iatas, + // a pending key keeps half-resolved results out of the resolved region's cache + regionKey: isResolved ? key : `${key}:pending`, + isResolved, + emptyRegion, + }; + }, [selection, regionIatas, settled]); } diff --git a/src/hooks/useScopes.ts b/src/hooks/useScopes.ts index 9f52594..d429d64 100644 --- a/src/hooks/useScopes.ts +++ b/src/hooks/useScopes.ts @@ -1,17 +1,24 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { getScopes } from "../api/client"; +import { useRegion } from "./useRegion"; -// The configured transport scope names (e.g. "#bc", "#west"), from /scopes. This is the authoritative -// list — the scope filters use it for their options so they show every configured scope even before any -// packet/node/observer has been matched to one. Scopes are near-static, so cache long and share across -// tabs via React Query. The filtering itself stays client-side on each record's scope. -export function useScopes(): string[] { +// Scope names for the filter dropdowns, limited to the selected region ("All" lists every scope). +// `keep` is the current selection: it stays an option even when outside the region, so a shared link +// still filters. The filtering itself stays client-side on each record's scope. +export function useScopes(keep?: string | string[]): string[] { + const { iatas, regionKey, isResolved } = useRegion(); const { data } = useQuery({ - queryKey: ["scopes"], - queryFn: getScopes, + queryKey: ["scopes", regionKey], + queryFn: () => getScopes(iatas), + enabled: isResolved !== false, staleTime: 5 * 60_000, }); - return useMemo(() => [...(data ?? [])].sort(), [data]); + const kept = typeof keep === "string" ? keep : keep?.join("\n"); + return useMemo(() => { + const names = new Set(data ?? []); + for (const k of kept ? kept.split("\n") : []) names.add(k); + return [...names].sort(); + }, [data, kept]); } diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts index 803552d..4fd666f 100644 --- a/src/hooks/useTick.ts +++ b/src/hooks/useTick.ts @@ -1,11 +1,11 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; // Forces a re-render on a fixed interval so relative time labels ("2m ago") stay fresh. Backed by one // shared interval per interval-length (module-level), so the many instances across the app // subscribe to a single timer instead of each spinning up its own setInterval. interface Ticker { - version: number; + time: number; listeners: Set<() => void>; id: ReturnType | null; } @@ -15,7 +15,7 @@ const tickers = new Map(); function getTicker(intervalMs: number): Ticker { let t = tickers.get(intervalMs); if (!t) { - t = { version: 0, listeners: new Set(), id: null }; + t = { time: Date.now(), listeners: new Set(), id: null }; tickers.set(intervalMs, t); } return t; @@ -25,8 +25,9 @@ function subscribe(intervalMs: number, listener: () => void): () => void { const t = getTicker(intervalMs); t.listeners.add(listener); if (t.id === null) { + t.time = Date.now(); t.id = setInterval(() => { - t!.version++; + t!.time = Date.now(); t!.listeners.forEach((l) => l()); }, intervalMs); } @@ -39,10 +40,11 @@ function subscribe(intervalMs: number, listener: () => void): () => void { }; } -export function useTick(intervalMs = 10_000): void { - useSyncExternalStore( - (listener) => subscribe(intervalMs, listener), - () => getTicker(intervalMs).version, +export function useTick(intervalMs = 10_000): number { + const listen = useCallback((listener: () => void) => subscribe(intervalMs, listener), [intervalMs]); + return useSyncExternalStore( + listen, + () => getTicker(intervalMs).time, () => 0, ); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..51cc930 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,54 @@ +import { createInstance } from "i18next"; +import { initReactI18next } from "react-i18next"; + +interface Catalog { + name: string; + translation: Record; +} + +const catalogs = import.meta.glob("./locales/*.json", { eager: true, import: "default" }); +const resources = Object.fromEntries(Object.entries(catalogs).map(([path, catalog]) => [ + path.slice("./locales/".length, -".json".length), { translation: catalog.translation }, +])); + +export const languages = Object.entries(catalogs).map(([path, catalog]) => ({ + code: path.slice("./locales/".length, -".json".length), + name: catalog.name, +})); + +export function readLanguagePreference(): string { + try { + const saved = localStorage.getItem("beacon-language"); + if (saved && languages.some(({ code }) => code === saved)) return saved; + } catch { + // Storage can be unavailable; language selection still works for this visit. + } + return "en"; +} + +const i18n = createInstance(); +i18n.on("languageChanged", () => { + const language = i18n.resolvedLanguage ?? "en"; + document.documentElement.lang = language; + document.documentElement.dir = i18n.dir(language); + // init() itself fires this event before any user choice exists — don't persist that first, implicit one. + if (!i18n.isInitialized) return; + try { + localStorage.setItem("beacon-language", language); + } catch { + // Keep the in-memory choice even when persistence is blocked. + } +}); + +void i18n.use(initReactI18next).init({ + resources, + lng: readLanguagePreference(), + supportedLngs: languages.map(({ code }) => code), + fallbackLng: "en", + initAsync: false, + returnEmptyString: false, + interpolation: { escapeValue: false }, // React escapes text content. + react: { useSuspense: false }, +}); + +export default i18n; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..614c8e6 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,908 @@ +{ + "name": "English", + "translation": { + "language": { + "label": "Language", + "help": "Choose a language." + }, + "tabs": { + "Packets": "Packets", + "Channels": "Channels", + "Map": "Map", + "Nodes": "Nodes", + "Observers": "Observers", + "Routes": "Routes", + "Traces": "Traces", + "Analytics": "Analytics" + }, + "navigation": { + "primary": "Primary", + "more": "More", + "moreTabs": "More tabs" + }, + "connection": { + "live": "LIVE", + "stale": "STALE {{age}}", + "offline": "OFFLINE", + "rateLimited": "RATE LIMITED {{seconds}}s" + }, + "region": { + "label": "Region", + "allShort": "ALL", + "allRegions": "All Regions", + "group": "Regions", + "areaGroup": "Areas", + "count_one": "{{count}} region", + "count_other": "{{count}} regions", + "iataCount_one": "{{count}} area", + "iataCount_other": "{{count}} areas", + "filter": "Search region or area code…", + "failed": "Failed to load", + "noMatches": "No matches" + }, + "theme": { + "label": "Theme" + }, + "common": { + "loading": "Loading…", + "loadFailed": "Failed to load", + "noData": "No data", + "dataUnavailable": "Data unavailable", + "info": "More information", + "closeDetailPanel": "Close detail panel", + "expandDetailPanel": "Expand detail panel", + "minimizeDetailPanel": "Minimize detail panel", + "notFound": "Not found", + "loadingDots": "Loading...", + "copyLink": "Copy link", + "copied": "Copied", + "copyFailed": "Copy failed", + "filters": "Filters", + "closeFilters": "Close filters", + "clearAll": "Clear all", + "done": "Done", + "all": "All", + "noOptions": "No options", + "copy": "Copy", + "close": "Close", + "clear": "Clear", + "none": "None", + "noMatches": "No matches", + "filterPlaceholder": "Filter..." + }, + "mesh": { + "totalPackets": "Total packets", + "observations": "Observations", + "activeObservers": "Active observers", + "activeIatas": "Active areas", + "lastHours": "Last {{hours}}h", + "lastHoursTo": "Last {{hours}}h · to {{time}} UTC", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Top observers · {{range}}", + "payloadTypes": "Payload types · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Top nodes · {{range}}", + "nodeTypes": "Node types · all time", + "radioPresets": "Radio presets · all time", + "scope": "Scope", + "packets": "Packets", + "observers": "Observers", + "nodes": "Nodes", + "nodesCenter": "NODES" + }, + "stats": { + "section": "Stats section", + "mobileSection": "Section", + "timeRange": "Time range", + "tabs": { + "mesh": "Mesh", + "traffic": "Traffic", + "signal": "RF / Signal", + "paths": "Paths & hashes", + "scopes": "Scopes", + "talkers": "Talkers", + "clockdrift": "Clock drift", + "compare": "Compare observers", + "graph": "Neighbor graph" + }, + "ranges": { + "24h": "24h", + "7d": "7d", + "30d": "30d" + } + }, + "signal": { + "subtitle": "Signal strength of packets heard by observers.", + "error": "Could not load signal data. Try refreshing or choosing a shorter time period.", + "receptions": "Observations", + "meanSnr": "Mean SNR", + "meanRssi": "Mean RSSI", + "hoursWithRecords": "Active hours", + "measurement": "Measured by each observer on the last hop. Times are UTC.", + "window": "{{since}} to {{until}} UTC, current hour excluded. Blank hours don't mean an outage.", + "histogramTitle": "{{metric}} distribution · {{unit}}", + "meanTitle": "Hourly mean {{metric}} · {{unit}}", + "availability": "Sample availability", + "averagesTitle": "What the averages include", + "loading": "Loading signal…", + "empty": "No observations in this window.", + "availabilityTable": "Signal sample availability", + "metric": "Metric", + "samples": "Samples", + "missing": "Missing", + "share": "Share", + "averagesHelp": "Each observation counts equally; missing readings are left out.", + "details": "Exact histogram and hourly values", + "histogramTable": "{{metric}} histogram", + "binsHelp": "Each bin includes its lower bound; the outer bins catch everything beyond.", + "hourlyTable": "Hourly signal values", + "utcHour": "UTC hour", + "receptionsColumn": "Observations", + "metricSamples": "{{metric}} samples", + "meanUnit": "Mean {{unit}}", + "binInterval": "{{lower}} to < {{upper}}", + "histogramDescription": "{{metric}} distribution in {{unit}}. Exact bin boundaries and counts follow below.", + "trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Gaps mean no readings. Exact values are in the hourly table.", + "coverageDescription": "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table.", + "available": "Available", + "unavailable": "Unavailable", + "noSample": "No sample" + }, + "paths": { + "subtitle": "How received packets carry route hashes.", + "error": "Could not load path data. Try refreshing or choosing a shorter time period.", + "receptions": "Observations", + "withHashPaths": "With hash paths", + "multiByteShare": "Multi-byte share", + "mostEntries": "Most path entries", + "measurement": "Path entries counted per observation.", + "window": "{{since}} to {{until}} UTC, current hour excluded.", + "widthTitle": "Observed hash widths", + "lengthTitle": "Received path entries", + "trendTitle": "Hash-path observations over time", + "coverageTitle": "Path format coverage", + "countsTitle": "What the counts include", + "loading": "Loading paths…", + "empty": "No observations in this window.", + "classificationTable": "Path classification counts", + "category": "Category", + "receptionsColumn": "Observations", + "share": "Share", + "categories": { + "hashed": "Hash paths", + "empty": "Empty", + "trace": "Trace", + "unclassified": "Unclassified" + }, + "countsHelp": "Empty paths have no hash width, and unclassified packets had no usable path data.", + "details": "Exact width, path-length and hourly values", + "widthTable": "Hash width counts", + "bytesPerHash": "Bytes per hash", + "lengthTable": "Path entry counts", + "headerEntries": "Header entries", + "lengthsHelp": "Zero means an empty path; hours with no data are left as gaps.", + "hourlyTable": "Hourly path counts", + "utcHour": "UTC hour", + "hashWidth_one": "{{count}}-byte", + "hashWidth_other": "{{count}}-byte", + "hashPathsCenter": "HASH PATHS", + "receptionsCenter": "OBSERVATIONS", + "widthDescription": "Hash-width share among non-empty paths. Exact counts follow below.", + "coverageDescription": "Path type for every observation. Exact counts follow alongside.", + "lengthDescription": "Path entries per observation. Zero means an empty path. Exact counts follow in the table.", + "entries": "Path entries", + "trendDescription": "Hourly observations with 1, 2 or 3-byte hash paths, UTC. Gaps mean no data. Exact values follow in the hourly table." + }, + "traffic": { + "subtitle": "When and where observers hear packets.", + "error": "Could not load traffic. Try refreshing.", + "receptions": "Observations", + "reportingIatas": "Active areas", + "busiestHour": "Busiest hour", + "hoursWithRecords": "Active hours", + "measurement": "Counts are observations, not unique packets. Times are UTC; blank hours don't mean an outage.", + "trendTitle": "Observations by area", + "hourlyRecords": "Hourly · UTC", + "hourlyTitle": "Hourly activity · {{range}}", + "shareTitle": "Observation share", + "byIata": "Observations by area", + "loading": "Loading traffic…", + "empty": "No observations in this window.", + "receptionsColumn": "Observations", + "area": "Area", + "share": "Share", + "groupingHelp": "Charts group smaller areas into “Other areas”; this table keeps every area.", + "receptionsCenter": "OBSERVATIONS", + "shareDescription": "Observation share by area. Exact counts and percentages follow in the table.", + "unassigned": "Unassigned", + "otherIatas": "Other areas", + "trendDescription": "Hourly observations by area. Gaps mean no data. Exact totals follow in the data table.", + "heatmapDescription": "Activity by UTC day and hour. Brighter cells mean more observations.", + "more": "More", + "fewer": "Fewer", + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" + }, + "scopes": { + "subtitle": "Scope activity for the selected region.", + "search": "Find a scope", + "error": "Could not load scopes. Try refreshing.", + "active": "Scopes with data", + "scopedPackets": "Scoped packets", + "memberships": "Observers", + "defaultNodes": "Default-scope nodes", + "observersLine": "Line: hearing per hour", + "nodesLine": "Line: advertising per hour", + "measurement": "Packet counts cover the selected range. Observer and node counts are current, and an observer can belong to more than one scope.", + "packetsTitle": "Packets by scope", + "membershipsTitle": "Observers by scope", + "countsTitle": "Scope counts", + "visibleCount_one": "{{visible}} of {{count}} scope", + "visibleCount_other": "{{visible}} of {{count}} scopes", + "loading": "Loading scopes…", + "noMatches": "No scopes match this search.", + "empty": "No scope data available.", + "scope": "Scope", + "packets": "Packets", + "observers": "Observers", + "nodes": "Nodes", + "groupingHelp": "Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.", + "other": "Other scopes", + "chartDescription": "Counts by transport scope. Exact values for all matching scopes are listed in the table." + }, + "nodeDetail": { + "viewOnMap": "View on map", + "title": "Node Detail", + "copyLink": "Copy node link", + "notFound": "Node not found", + "summary": "Summary", + "copyKey": "Copy public key", + "viewObserver": "View observer →", + "location": "Location", + "lat": "Lat", + "lng": "Lng", + "source": "Source", + "capabilities": "Capabilities", + "minFirmware": "Min firmware", + "multibytePaths": "Multi-byte paths", + "multibyteTraces": "Multi-byte traces", + "yes": "yes", + "no": "no", + "radio": "Radio", + "scope": "Scope", + "timestamps": "Timestamps", + "first": "First", + "last": "Last", + "advert": "Advert", + "clockDrift": "Clock drift", + "neighbors": "Neighbors", + "neighborsCount": "Neighbors ({{count}})", + "noNeighbors": "No known neighbors", + "recentPackets": "Recent packets", + "noRecentPackets": "No recent packets", + "observations_one": "{{formatted}} obs", + "observations_other": "{{formatted}} obs", + "hops": "Hops" + }, + "nodes": { + "lastHeard": "last heard {{ago}}", + "colName": "Name", + "colType": "Type", + "colRadio": "Radio", + "colAreas": "Areas", + "colNeighbors": "Neighbors", + "colLocation": "Location", + "observer": "Observer", + "empty": "No nodes", + "neighbors_one": "{{formatted}} neighbors", + "neighbors_other": "{{formatted}} neighbors", + "possiblyForeign": "Possibly foreign", + "possiblyForeignHint": "This node's position is outside this server's local area." + }, + "packetRow": { + "noResolution": "No path resolution available", + "observer": "observer", + "first": "first", + "last": "last", + "spread": "spread", + "noPath": "No resolved path to map", + "noObservations": "No observations", + "loadFailed": "Failed to load observations", + "heardBy_one": "Heard by {{count}} observer", + "heardBy_other": "Heard by {{count}} observers", + "unknown": "Unknown" + }, + "clockDrift": { + "caption": "Repeaters & room servers out of sync · worst first", + "node": "Node", + "drift": "Drift", + "checked": "Checked", + "inSync": "in sync", + "ahead": "ahead", + "behind": "behind", + "empty": "No repeaters out of sync", + "areas": "Areas" + }, + "timestamp": { + "ago": "{{duration}} ago", + "unit": { + "s": "{{count}}s", + "m": "{{count}}m", + "h": "{{count}}h", + "d": "{{count}}d" + } + }, + "observerPage": { + "back": "Back to observers", + "detail": "Observer detail", + "range": "Time range", + "open": "Open observer dashboard", + "legacy": "Observations (all time)", + "metrics": "Observer metrics", + "records": "Packets heard", + "lastPacket": "Last packet", + "battery": "Battery", + "batteryV": "Battery V", + "uptime": "Uptime", + "noise": "Noise floor", + "noiseDbm": "Noise dBm", + "packets": "Packets", + "search": "Find an observer", + "choose": "Choose an observer", + "searchHelp": "Search by name to find observers outside the first 50 results.", + "summaryMissing": "Packet totals unavailable.", + "statusRecent": "Recent status", + "statusStale": "Status is stale", + "statusMissing": "No status report", + "activity": "Packets heard per {{interval}}", + "mix": "Packet-type mix", + "signal": "Received signal", + "airtime": "Airtime TX / RX", + "device": "Device telemetry", + "queue": "Queue length", + "errors": "Receive errors", + "busy": "Channel busy", + "empty": "No packets heard in this period", + "noTelemetry": "No telemetry", + "noTelemetryHelp": "This observer sent no device telemetry for this period.", + "loadFailed": "Observer could not be loaded.", + "retry": "Retry", + "model": "Model", + "firmware": "Firmware", + "client": "Client", + "radio": "Radio", + "publicKey": "Public key", + "copy": "Copy", + "copied": "Copied", + "packetArrival": "Packet arrival", + "firstSeen": "First seen", + "copyLink": "Copy observer link", + "window": "UTC window", + "time": "Time (UTC)", + "mean": "Average", + "minimum": "Minimum", + "unknown": "Unknown", + "recording": "Loading observer…", + "hour": "hour", + "day": "day", + "sidebarSearch": "Search observers", + "noMatches": "No matches", + "none": "No observers", + "packetsLastHour": "Packets last hour", + "copyKey": "Copy public key", + "sort": "Sort observers", + "sortActivity": "Activity", + "sortName": "Name", + "deviceInfo": "Device", + "brokers": "Brokers", + "copyLinkShort": "Copy link", + "advertsHeard": "Adverts heard", + "noAdverts": "No adverts heard", + "unknownNode": "unknown", + "hops": "Hops" + }, + "observerCompare": { + "open": "Compare with…", + "close": "Close comparison", + "title": "Compare observers", + "partner": "Observer B", + "choose": "Choose an observer to compare", + "invalidObserver": "Choose a different observer with a valid identifier.", + "invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.", + "refresh": "Refresh", + "unavailable": "Comparison not available on this server.", + "currentNote": "Totals cover this window; battery, uptime and noise floor are current values.", + "metrics": "Observer comparison metrics", + "metric": "Metric", + "activity": "Packets heard", + "overlap": "Heard by both", + "retention": "Only packets the server still keeps are compared.", + "total_one": "{{count}} flood packet", + "total_other": "{{count}} flood packets", + "empty": "Neither observer heard a flood packet in this window.", + "onlyA": "Only A", + "both": "Both", + "onlyB": "Only B", + "definition": "Each flood packet counts once. Not a packet-loss measure.", + "retainedWindow": "Packets are only kept for a few days, so older windows may be incomplete.", + "overlapShortRanges": "Packet overlap is available for 24h and 7d.", + "invalidPair": "Choose two different observers.", + "invalidRange": "Choose a valid start and a later end time.", + "observerA": "Observer A", + "chooseObserver": "Choose an observer", + "start": "Start (local time)", + "end": "End (local time)", + "retentionWarning": "Only recent packets are kept, so the start of this window may be empty.", + "compare": "Compare", + "selection": "Selection", + "invalidLink": "This comparison link has invalid or missing values. Choose observers and dates below.", + "floodHeard": "Flood packets heard", + "pair": "A: {{a}} · B: {{b}}", + "allRegions": "All", + "localTimeNote": "Local time; the end is excluded.", + "comparing": "Comparing…", + "retryComparison": "Retry comparison", + "packetsTotal_one": "{{value}} flood packets", + "packetsTotal_other": "{{value}} flood packets", + "percentNote": "Percentages are of all flood packets either observer heard; each packet counts once. Not a packet-loss measure.", + "emptyRegion": "Neither observer heard a flood packet in this period and region.", + "tableLabel": "Flood packet comparison", + "heardBy": "Heard by", + "packets": "Packets", + "percentUnion": "% of union" + }, + "investigation": { + "title": "Observations", + "observations_one": "{{count}} observation", + "observations_other": "{{count}} observations", + "heardBy_one": "Heard by {{count}} observer", + "heardBy_other": "Heard by {{count}} observers", + "heardTimes_one": "Heard once", + "heardTimes_other": "Heard {{count}} times", + "byObservers_one": "by {{count}} observer", + "byObservers_other": "by {{count}} observers", + "pathNote": "Grouped by the repeaters each copy came through. Short IDs can match more than one node.", + "traceNote": "TRACE entries show the planned route, not the path the packet took.", + "empty": "No observations for this packet.", + "path": "Path {{number}}", + "tracePath": "Intended route {{number}}", + "noEntries": "No path entries", + "unavailablePath": "Path bytes unavailable", + "inspect": "Select observation", + "observer": "Inspect observer", + "map": "Map path", + "unmappable": "Needs at least two repeaters with known locations.", + "retention": "One entry per observer. Older packets expire after the server's packet retention.", + "unavailableReport": "That observation is no longer available or the link is invalid. Choose one below.", + "copy": "Copy link", + "copyPacket": "Copy packet link", + "viewAll": "Map all paths", + "mapDialog": "Packet path map", + "mapTitle": "Packet Path", + "copyPath": "Copy path link", + "closeMap": "Close path map", + "allPaths": "All paths", + "traceRoute": "Trace route", + "mapNote": "Lines join repeaters with known locations. They don't prove a direct radio link.", + "unavailableMap": "This path can't be mapped. Choose another path or All paths.", + "first": "First", + "last": "Last", + "span": "Heard over", + "nodeDialog": "Node detail", + "packetDialog": "Packet analyzer", + "closeObserver": "Close observer details", + "loadMore": "Load more ({{count}} left)" + }, + "routeEvidence": { + "recent": "Recent observations", + "loading": "Loading observations…", + "missing": "Recent observations aren't available for this route on this server.", + "error": "Could not load recent observations. Try again.", + "retry": "Retry", + "match": "Observations along exactly these hops at {{width}} byte(s) per hop, newest first. TRACE packets aren't included.", + "caution": "Matching short IDs don't prove the same physical path.", + "retention": "Older packets drop off; the Heard count above covers the route's whole life.", + "unavailable": "This route can't be matched against packets.", + "empty": "No observations on this route are still kept. Older ones may have expired.", + "more": "Load more", + "cap": "Showing the newest 500 observations.", + "inspect": "Inspect packet" + }, + "channelMessages": { + "select": "Select a channel", + "back": "Back to channels", + "hash": "hash: {{hash}}", + "keyKnown": "key known", + "noKey": "no key", + "hashtag": "hashtag", + "keyMissing": "No decryption key is configured for this channel.", + "scope": "Scope", + "allScopes": "All scopes", + "unknownScope": "Unknown scope", + "inspect": "Inspect packet from {{sender}}", + "loading": "Loading messages…", + "loadingOlder": "Loading older messages… ({{count}} shown)", + "loadOlder": "Load older messages", + "empty": "No messages for this channel.", + "emptyScope": "No messages match {{scope}}.", + "emptyNoKey": "Messages can't be decrypted without the channel key.", + "error": "Could not load messages.", + "retry": "Retry", + "viewPacket": "View packet", + "heardTimes_one": "Heard {{count}} time", + "heardTimes_other": "Heard {{count}} times" + }, + "header": { + "settings": "Settings", + "github": "GitHub" + }, + "channels": { + "noMatches": "No matching channels loaded.", + "loadError": "Could not load channels.", + "loadingChannels": "Loading channels...", + "retryChannels": "Retry loading channels", + "loadOthers": "Load other channels", + "loadMore": "Load more channels", + "key": "key", + "filters": "Channel filters", + "fieldName": "Name", + "fieldHash": "Hash", + "keyFilter": "Key", + "hashtagFilter": "Hashtag", + "keyKnownOption": "Known", + "keyUnknownOption": "Unknown", + "any": "Any", + "yes": "Yes", + "no": "No" + }, + "map": { + "settings": "Map Settings", + "tiles": "Map Tiles", + "nodeType": "Node Type", + "nodeTypeLabel": "Node type", + "clustering": "Clustering", + "neighborLines": "Neighbor Lines", + "neighborLinesLabel": "Neighbor lines", + "areaBorders": "Area Borders", + "areaBordersLabel": "Area borders", + "all": "All", + "on": "On", + "off": "Off", + "selected": "Selected", + "areaOutline": "Area outline", + "observations": "Observations", + "fainter": "fainter = heard longer ago", + "copyLink": "Copy map link", + "copyLinkLabel": "Copy a link to this map view", + "liveMap": "Live Map", + "playFlow": "Play live map packet flow", + "stopFlow": "Stop live map packet flow", + "loadFailed": "Map failed to load", + "loadFailedHint": "Check your connection and reload", + "style": "Map style", + "styles": { + "dark": "Dark", + "liberty": "Liberty", + "positron": "Light" + }, + "controls": { + "toggleAttribution": "Toggle attribution", + "mapFeedback": "Map feedback", + "map": "Map", + "resetBearing": "Drag to rotate map, click to reset north", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "closePopup": "Close popup" + } + }, + "nodeFilters": { + "label": "Node filters", + "type": "Type", + "multibytePaths": "Multi-byte paths", + "multibyteTraces": "Multi-byte traces", + "scope": "Scope", + "any": "Any", + "yes": "Yes", + "no": "No", + "fieldName": "Name", + "fieldPubkey": "Public Key", + "all": "All" + }, + "packetAnalyzer": { + "title": "Packet Analyzer", + "close": "Close analyzer", + "empty": "Select a packet to analyze", + "summary": "Summary", + "rawPacket": "Raw Packet", + "structure": "Packet Structure", + "headerByte": "Header Byte", + "ver": "Ver", + "type": "Type", + "route": "Route", + "transportCodes": "Transport Codes", + "scopeCode": "Scope code", + "noKnownScope": "no known scope", + "code2": "Code 2", + "pathLength": "Path Length", + "hashSize": "Hash Size", + "pathData": "Path Data", + "originPubkey": "Origin Pubkey", + "payloadBreakdown": "Payload Breakdown", + "payloadData": "Payload Data", + "bytes_one": "{{count}} bytes", + "bytes_other": "{{count}} bytes", + "bits": { + "ver": "ver", + "type": "type", + "route": "route", + "hash": "hash={{size}}B", + "hops": "hops={{value}}", + "loc": "loc", + "name": "name" + }, + "payload": { + "yes": "Yes", + "no": "No", + "encrypted": "Encrypted — key not available", + "to": "To", + "from": "From", + "ciphertext": "Ciphertext", + "decrypted": "Decrypted", + "channel": "Channel", + "publicKey": "Public Key", + "timestamp": "Timestamp", + "signature": "Signature", + "appFlags": "App Flags", + "role": "Role", + "loc": "Loc", + "name": "Name", + "location": "Location", + "traceTag": "Trace Tag", + "authCode": "Auth Code", + "flags": "Flags", + "tracePath": "Trace Path", + "sender": "Sender", + "message": "Message", + "attempt": "Attempt {{value}}", + "flagsValue": "Flags {{value}}", + "data": "Data", + "time": "Time", + "tag": "Tag", + "checksum": "Checksum", + "bundledType": "Bundled Type", + "bundledData": "Bundled Data", + "typeFilter": "Type Filter", + "typeN": "Type {{value}}", + "any": "any", + "since": "Since", + "prefixOnly": "Prefix Only", + "nodeType": "Node Type", + "requestSnr": "Request SNR", + "nodeToNode": "(node-to-node)", + "keyPrefix": "Key Prefix", + "raw": "Raw", + "destHash": "Dest Hash", + "ephemeralKey": "Ephemeral Key" + } + }, + "observation": { + "prop": "Prop", + "path": "Path", + "observer": "Observer", + "area": "Area", + "heard": "Heard", + "hops": "Hops" + }, + "packetEndpoints": { + "na": "n/a" + }, + "routes": { + "columns": { + "area": "Area", + "hops": "Hops", + "route": "Route", + "obs": "Obs", + "firstSeen": "First seen", + "lastSeen": "Last seen" + }, + "hops_one": "{{count}} hops", + "hops_other": "{{count}} hops", + "obs_one": "{{value}} obs", + "obs_other": "{{value}} obs", + "first": "first", + "last": "last", + "findPath": "Find path", + "fromPlaceholder": "from hash", + "fromLabel": "From hash", + "toPlaceholder": "to hash", + "toLabel": "To hash", + "areas": "Areas", + "search": "Search", + "clear": "Clear", + "searching": "Searching…", + "noCross": "No routes between these areas", + "noMatches": "No matching routes", + "empty": "No routes" + }, + "routeDetail": { + "title": "Route Detail", + "summary": "Summary", + "hops": "Hops", + "heard": "Heard", + "route": "Route", + "timestamps": "Timestamps", + "firstSeen": "First seen", + "lastSeen": "Last seen" + }, + "traces": { + "packets": "Packets", + "packetCount_one": "{{count}} packet", + "packetCount_other": "{{count}} packets", + "noPackets": "No packets", + "noPath": "no path", + "unknown": "Unknown", + "analyze": "analyze →", + "first": "First", + "last": "Last", + "tagCount_one": "{{count}} tag", + "tagCount_other": "{{count}} tags", + "pkt_one": "{{count}} pkt", + "pkt_other": "{{count}} pkt", + "iata_one": "{{count}} iata", + "iata_other": "{{count}} iata", + "typeLabel": "Trace type", + "typeHint": "Filters whole tags: TRACE if any packet in the tag is multi-hop, otherwise PING. Scope and time filters match the tag's first hearing; counts and times always cover the whole tag.", + "all": "All", + "trace": "Trace", + "ping": "Ping", + "empty": "No traces" + }, + "loadingPill": { + "nodes": { + "loading_one": "Loading nodes… ({{count}})", + "loading_other": "Loading nodes… ({{count}})", + "partial_one": "Some nodes failed to load ({{count}} shown)", + "partial_other": "Some nodes failed to load ({{count}} shown)", + "failed": "Failed to load nodes" + }, + "routes": { + "loading_one": "Loading routes… ({{count}})", + "loading_other": "Loading routes… ({{count}})", + "partial_one": "Some routes failed to load ({{count}} shown)", + "partial_other": "Some routes failed to load ({{count}} shown)", + "failed": "Failed to load routes" + }, + "packets": { + "loading_one": "Loading packets… ({{count}})", + "loading_other": "Loading packets… ({{count}})", + "partial_one": "Some packets failed to load ({{count}} shown)", + "partial_other": "Some packets failed to load ({{count}} shown)", + "failed": "Failed to load packets" + }, + "messages": { + "loading_one": "Loading messages… ({{count}})", + "loading_other": "Loading messages… ({{count}})", + "partial_one": "Some messages failed to load ({{count}} shown)", + "partial_other": "Some messages failed to load ({{count}} shown)", + "failed": "Failed to load messages" + }, + "observers": { + "loading_one": "Loading observers… ({{count}})", + "loading_other": "Loading observers… ({{count}})", + "partial_one": "Some observers failed to load ({{count}} shown)", + "partial_other": "Some observers failed to load ({{count}} shown)", + "failed": "Failed to load observers" + } + }, + "talkers": { + "node": "Node", + "flood": "Flood", + "direct": "Direct", + "topAdvertisers": "Top advertisers · {{range}}", + "floodDirect": "flood · direct", + "noAdvertisers": "No advertisers", + "topTalkers": "Top talkers · {{range}}", + "byName": "by name" + }, + "neighbourGraph": { + "pickRegion": "Pick a region", + "allTooBig": "All regions is 5,000+ nodes — choose a region from the REGION picker above, or narrow to a single area, to view its mesh.", + "loadFailed": "Failed to load nodes", + "loadingMesh": "Loading mesh…", + "nodes_one": "{{count}} nodes", + "nodes_other": "{{count}} nodes", + "noNodes": "No nodes in this region", + "neighborhoodOf": "Neighborhood of", + "neighbors_one": "{{count}} neighbors", + "neighbors_other": "{{count}} neighbors", + "clickEmpty": "Click empty space for the full mesh.", + "showing": "Showing {{cap}} of {{total}} nodes", + "narrow": "Narrow to a single area for the rest.", + "searchName": "Name", + "other": "Other", + "unknownType": "unknown", + "edgeToday": "{{obs}} obs · seen today", + "edgeAgo": "{{obs}} obs · seen {{days}}d ago" + }, + "nodeTypes": { + "companion": "Companion", + "repeater": "Repeater", + "room_server": "Room", + "sensor": "Sensor" + }, + "observerFilters": { + "label": "Observer filters", + "online": "Online", + "offline": "Offline", + "name": "Name", + "status": "Status", + "type": "Type", + "broker": "Broker", + "scope": "Scope" + }, + "charts": { + "uniquePackets": "Unique packets", + "busy": "Busy" + }, + "packetList": { + "pathSearchInvalid": "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4).", + "pathSearchHint": "Searches the latest relay path in loaded packets. Use whole hop hashes, e.g. 7f a4 (spaces, commas or →). Trace packets are excluded.", + "selectedPacket": "Selected packet", + "invalidHash": "Invalid packet hash.", + "notFound": "Packet not found.", + "loadFailed": "Failed to load selected packet.", + "retry": "Retry", + "retryLabel": "Retry selected packet", + "outsideResults": "Selected packet is outside the loaded results.", + "openAnalyzer": "Open analyzer", + "loadingSelected": "Loading selected packet…", + "dismissSelected": "Dismiss selected packet", + "dropped_one": "{{count}} packet dropped — data may be incomplete", + "dropped_other": "{{count}} packets dropped — data may be incomplete", + "dismiss": "dismiss", + "newPackets_one": "{{count}} new packet", + "newPackets_other": "{{count}} new packets", + "scrollToTop": "· scroll to top", + "live": "Live Packets", + "noMatching": "No matching packets loaded.", + "loadingPackets": "Loading packets...", + "loadOlder": "Load older packets" + }, + "packetTable": { + "hash": "Hash", + "obs": "Obs", + "hops": "Hops", + "summary": "Summary / Src → Dst", + "summaryTitle": "Summary and source / destination", + "area": "Area", + "age": "Age", + "unknown": "Unknown" + }, + "filters": { + "types": "Types", + "routes": "Routes", + "observers": "Observers", + "scope": "Scope", + "packetFilters": "Packet filters", + "field": { + "hash": "Hash", + "path": "Latest path", + "payload": "Payload" + } + }, + "search": { + "placeholder": "Search by {{field}}..." + }, + "errorBoundary": { + "message": "Something went wrong rendering this view.", + "reload": "reload", + "retry": "try again" + }, + "splash": { + "tagline": "MeshCore Network Analyzer" + }, + "units": { + "perDay": "{{value}}/d" + } + } +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 0000000..fc29388 --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,944 @@ +{ + "name": "Français", + "translation": { + "language": { + "label": "Langue", + "help": "Choisissez une langue." + }, + "tabs": { + "Packets": "Paquets", + "Channels": "Canaux", + "Map": "Carte", + "Nodes": "Nœuds", + "Observers": "Observateurs", + "Routes": "Routes", + "Traces": "Traces", + "Analytics": "Analyses" + }, + "navigation": { + "primary": "Navigation principale", + "more": "Plus", + "moreTabs": "Autres onglets" + }, + "connection": { + "live": "EN DIRECT", + "stale": "EN RETARD {{age}}", + "offline": "HORS LIGNE", + "rateLimited": "DÉBIT LIMITÉ {{seconds}} s" + }, + "region": { + "label": "Région", + "allShort": "TOUT", + "allRegions": "Toutes les régions", + "group": "Régions", + "areaGroup": "Zones", + "count_one": "{{count}} région", + "count_many": "{{count}} régions", + "count_other": "{{count}} régions", + "iataCount_one": "{{count}} zone", + "iataCount_many": "{{count}} zones", + "iataCount_other": "{{count}} zones", + "filter": "Rechercher une région ou un code de zone…", + "failed": "Échec du chargement", + "noMatches": "Aucun résultat" + }, + "theme": { + "label": "Thème" + }, + "common": { + "loading": "Chargement…", + "loadFailed": "Échec du chargement", + "noData": "Aucune donnée", + "dataUnavailable": "Données indisponibles", + "info": "Plus d’informations", + "closeDetailPanel": "Fermer le panneau de détails", + "expandDetailPanel": "Agrandir le panneau de détails", + "minimizeDetailPanel": "Réduire le panneau de détails", + "notFound": "Introuvable", + "loadingDots": "Chargement…", + "copyLink": "Copier le lien", + "copied": "Copié", + "copyFailed": "Échec de la copie", + "filters": "Filtres", + "closeFilters": "Fermer les filtres", + "clearAll": "Tout effacer", + "done": "Terminé", + "all": "Tous", + "noOptions": "Aucune option", + "copy": "Copier", + "close": "Fermer", + "clear": "Effacer", + "none": "Aucun", + "noMatches": "Aucun résultat", + "filterPlaceholder": "Filtrer…" + }, + "mesh": { + "totalPackets": "Paquets totaux", + "observations": "Observations", + "activeObservers": "Observateurs actifs", + "activeIatas": "Zones actives", + "lastHours": "Dernières {{hours}} h", + "lastHoursTo": "Dernières {{hours}} h · jusqu’à {{time}} UTC", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Meilleurs observateurs · {{range}}", + "payloadTypes": "Types de paquets · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Meilleurs nœuds · {{range}}", + "nodeTypes": "Types de nœuds · historique complet", + "radioPresets": "Préréglages radio · historique complet", + "scope": "Scope", + "packets": "Paquets", + "observers": "Observateurs", + "nodes": "Nœuds", + "nodesCenter": "NŒUDS" + }, + "stats": { + "section": "Section des statistiques", + "mobileSection": "Section", + "timeRange": "Période", + "tabs": { + "mesh": "Réseau", + "traffic": "Trafic", + "signal": "RF / Signal", + "paths": "Chemins et hachages", + "scopes": "Scopes", + "talkers": "Émetteurs", + "clockdrift": "Dérive d’horloge", + "compare": "Comparer les observateurs", + "graph": "Graphe des voisins" + }, + "ranges": { + "24h": "24 h", + "7d": "7 j", + "30d": "30 j" + } + }, + "signal": { + "subtitle": "Force du signal des paquets reçus par les observateurs.", + "error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.", + "receptions": "Observations", + "meanSnr": "SNR moyen", + "meanRssi": "RSSI moyen", + "hoursWithRecords": "Heures actives", + "measurement": "Mesuré par chaque observateur sur le dernier saut. Heures en UTC.", + "window": "{{since}} à {{until}} UTC, heure en cours exclue. Les heures vides ne signifient pas une panne.", + "histogramTitle": "Distribution du {{metric}} · {{unit}}", + "meanTitle": "Moyenne horaire du {{metric}} · {{unit}}", + "availability": "Disponibilité des échantillons", + "averagesTitle": "Ce que les moyennes incluent", + "loading": "Chargement du signal…", + "empty": "Aucune observation dans cette période.", + "availabilityTable": "Disponibilité des échantillons du signal", + "metric": "Mesure", + "samples": "Échantillons", + "missing": "Manquants", + "share": "Part", + "averagesHelp": "Chaque observation compte autant ; les mesures manquantes sont ignorées.", + "details": "Valeurs exactes des histogrammes et par heure", + "histogramTable": "Histogramme {{metric}}", + "binsHelp": "Chaque classe inclut sa borne inférieure ; les classes extrêmes regroupent tout le reste.", + "hourlyTable": "Valeurs horaires du signal", + "utcHour": "Heure UTC", + "receptionsColumn": "Observations", + "metricSamples": "Échantillons {{metric}}", + "meanUnit": "Moyenne {{unit}}", + "binInterval": "{{lower}} à < {{upper}}", + "histogramDescription": "Distribution du {{metric}} en {{unit}}. Les bornes exactes des classes et leurs effectifs figurent ci-dessous.", + "trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les lacunes indiquent l’absence de mesures. Les valeurs exactes figurent dans le tableau horaire.", + "coverageDescription": "Mesures disponibles et indisponibles du SNR et du RSSI. Les effectifs et pourcentages exacts figurent dans le tableau de disponibilité des échantillons.", + "available": "Disponibles", + "unavailable": "Indisponibles", + "noSample": "Aucun échantillon" + }, + "paths": { + "subtitle": "Les hachages de route portés par les paquets reçus.", + "error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.", + "receptions": "Observations", + "withHashPaths": "Avec chemins hachés", + "multiByteShare": "Part multi-octets", + "mostEntries": "Maximum d’entrées", + "measurement": "Entrées de chemin comptées par observation.", + "window": "{{since}} à {{until}} UTC, heure en cours exclue.", + "widthTitle": "Largeurs de hachage observées", + "lengthTitle": "Entrées de chemin reçues", + "trendTitle": "Observations avec chemins hachés au fil du temps", + "coverageTitle": "Répartition des formats de chemin", + "countsTitle": "Ce que les effectifs incluent", + "loading": "Chargement des chemins…", + "empty": "Aucune observation dans cette période.", + "classificationTable": "Effectifs par catégorie de chemin", + "category": "Catégorie", + "receptionsColumn": "Observations", + "share": "Part", + "categories": { + "hashed": "Chemins hachés", + "empty": "Vides", + "trace": "Trace", + "unclassified": "Non classés" + }, + "countsHelp": "Les chemins vides n’ont pas de largeur de hachage, et les paquets non classés n’avaient pas de données de chemin utilisables.", + "details": "Valeurs exactes des largeurs, longueurs et par heure", + "widthTable": "Effectifs par largeur de hachage", + "bytesPerHash": "Octets par hachage", + "lengthTable": "Effectifs par nombre d’entrées", + "headerEntries": "Entrées d’en-tête", + "lengthsHelp": "Zéro indique un chemin vide ; les heures sans données restent des lacunes.", + "hourlyTable": "Effectifs horaires des chemins", + "utcHour": "Heure UTC", + "hashWidth_one": "{{count}} octet", + "hashWidth_many": "{{count}} octets", + "hashWidth_other": "{{count}} octets", + "hashPathsCenter": "CHEMINS HACHÉS", + "receptionsCenter": "OBSERVATIONS", + "widthDescription": "Part de chaque largeur de hachage parmi les chemins non vides. Les effectifs exacts figurent ci-dessous.", + "coverageDescription": "Type de chemin pour chaque observation. Les effectifs exacts figurent à côté.", + "lengthDescription": "Entrées de chemin par observation. Zéro indique un chemin vide. Les effectifs exacts figurent dans le tableau.", + "entries": "Entrées du chemin", + "trendDescription": "Observations horaires avec des chemins hachés de 1, 2 ou 3 octets, UTC. Les lacunes indiquent l’absence de données. Les valeurs exactes figurent dans le tableau horaire." + }, + "traffic": { + "subtitle": "Quand et où les observateurs reçoivent des paquets.", + "error": "Impossible de charger le trafic. Réessayez.", + "receptions": "Observations", + "reportingIatas": "Zones actives", + "busiestHour": "Pic horaire", + "hoursWithRecords": "Heures actives", + "measurement": "Les effectifs comptent les observations, pas les paquets uniques. Heures en UTC ; une heure vide ne signifie pas une panne.", + "trendTitle": "Observations par zone", + "hourlyRecords": "Par heure · UTC", + "hourlyTitle": "Activité horaire · {{range}}", + "shareTitle": "Part des observations", + "byIata": "Observations par zone", + "loading": "Chargement du trafic…", + "empty": "Aucune observation dans cette période.", + "receptionsColumn": "Observations", + "area": "Zone", + "share": "Part", + "groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres zones » ; ce tableau conserve toutes les zones.", + "receptionsCenter": "OBSERVATIONS", + "shareDescription": "Part des observations par zone. Les effectifs et pourcentages exacts figurent dans le tableau.", + "unassigned": "Non attribué", + "otherIatas": "Autres zones", + "trendDescription": "Observations horaires par zone. Les lacunes indiquent l’absence de données. Les totaux exacts figurent dans le tableau.", + "heatmapDescription": "Activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage d’observations.", + "more": "Plus", + "fewer": "Moins", + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation", + "heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} observations", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" + }, + "scopes": { + "subtitle": "Activité des scopes pour la région sélectionnée.", + "search": "Rechercher un scope", + "error": "Impossible de charger les scopes. Réessayez.", + "active": "Scopes avec données", + "scopedPackets": "Paquets avec scope", + "memberships": "Observateurs", + "defaultNodes": "Nœuds du scope par défaut", + "observersLine": "Courbe : à l'écoute par heure", + "nodesLine": "Courbe : annonçant par heure", + "measurement": "Les paquets couvrent la période sélectionnée. Les observateurs et les nœuds sont comptés à l’heure actuelle, et un observateur peut appartenir à plusieurs scopes.", + "packetsTitle": "Paquets par scope", + "membershipsTitle": "Observateurs par scope", + "countsTitle": "Effectifs des scopes", + "visibleCount_one": "{{visible}} sur {{count}} scope", + "visibleCount_many": "{{visible}} sur {{count}} scopes", + "visibleCount_other": "{{visible}} sur {{count}} scopes", + "loading": "Chargement des scopes…", + "noMatches": "Aucun scope ne correspond à cette recherche.", + "empty": "Aucune donnée de scope disponible.", + "scope": "Scope", + "packets": "Paquets", + "observers": "Observateurs", + "nodes": "Nœuds", + "groupingHelp": "Les graphiques affichent les 12 scopes les plus importants et regroupent le reste. Le tableau conserve tous les scopes correspondants.", + "other": "Autres scopes", + "chartDescription": "Effectifs par scope de transport. Les valeurs exactes de tous les scopes correspondants figurent dans le tableau." + }, + "nodeDetail": { + "viewOnMap": "Voir sur la carte", + "title": "Détail du nœud", + "copyLink": "Copier le lien du nœud", + "notFound": "Nœud introuvable", + "summary": "Résumé", + "copyKey": "Copier la clé publique", + "viewObserver": "Voir l’observateur →", + "location": "Position", + "lat": "Lat", + "lng": "Long", + "source": "Source", + "capabilities": "Capacités", + "minFirmware": "Firmware min.", + "multibytePaths": "Chemins multi-octets", + "multibyteTraces": "Traces multi-octets", + "yes": "oui", + "no": "non", + "radio": "Radio", + "scope": "Portée", + "timestamps": "Horodatages", + "first": "Premier", + "last": "Dernier", + "advert": "Annonce", + "clockDrift": "Dérive d’horloge", + "neighbors": "Voisins", + "neighborsCount": "Voisins ({{count}})", + "noNeighbors": "Aucun voisin connu", + "recentPackets": "Paquets récents", + "noRecentPackets": "Aucun paquet récent", + "observations_one": "{{formatted}} obs.", + "observations_other": "{{formatted}} obs.", + "observations_many": "{{formatted}} obs.", + "hops": "Sauts" + }, + "nodes": { + "lastHeard": "dernier contact {{ago}}", + "colName": "Nom", + "colType": "Type", + "colRadio": "Radio", + "colAreas": "Zones", + "colNeighbors": "Voisins", + "colLocation": "Position", + "observer": "Observateur", + "empty": "Aucun nœud", + "neighbors_one": "{{formatted}} voisin", + "neighbors_other": "{{formatted}} voisins", + "neighbors_many": "{{formatted}} voisins", + "possiblyForeign": "Possiblement étranger", + "possiblyForeignHint": "La position de ce nœud est hors de la zone locale de ce serveur." + }, + "packetRow": { + "noResolution": "Aucune résolution de chemin disponible", + "observer": "observateur", + "first": "premier", + "last": "dernier", + "spread": "écart", + "noPath": "Aucun trajet résolu à cartographier", + "noObservations": "Aucune observation", + "loadFailed": "Impossible de charger les observations", + "heardBy_one": "Reçu par {{count}} observateur", + "heardBy_many": "Reçu par {{count}} observateurs", + "heardBy_other": "Reçu par {{count}} observateurs", + "unknown": "Inconnu" + }, + "clockDrift": { + "caption": "Répéteurs et serveurs de salon désynchronisés · écart maximal en premier", + "node": "Nœud", + "drift": "Dérive", + "checked": "Vérifié", + "inSync": "synchronisé", + "ahead": "en avance", + "behind": "en retard", + "empty": "Aucun répéteur désynchronisé", + "areas": "Zones" + }, + "timestamp": { + "ago": "il y a {{duration}}", + "unit": { + "s": "{{count}} s", + "m": "{{count}} min", + "h": "{{count}} h", + "d": "{{count}} j" + } + }, + "observerPage": { + "back": "Retour aux observateurs", + "detail": "Détail de l'observateur", + "range": "Période", + "open": "Ouvrir le tableau de bord", + "legacy": "Observations (historique complet)", + "metrics": "Mesures de l’observateur", + "records": "Paquets reçus", + "lastPacket": "Dernier paquet", + "battery": "Batterie", + "batteryV": "Batterie V", + "uptime": "Durée de fonctionnement", + "noise": "Bruit de fond", + "noiseDbm": "Bruit dBm", + "packets": "Paquets", + "search": "Rechercher un observateur", + "choose": "Choisir un observateur", + "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", + "summaryMissing": "Totaux de paquets indisponibles.", + "statusRecent": "État récent", + "statusStale": "État périmé", + "statusMissing": "Aucun rapport d’état", + "activity": "Paquets reçus par {{interval}}", + "mix": "Répartition des types", + "signal": "Signal reçu", + "airtime": "Temps radio TX / RX", + "device": "Télémétrie de l’appareil", + "queue": "File d’attente", + "errors": "Erreurs de réception", + "busy": "Occupation du canal", + "empty": "Aucun paquet reçu sur cette période", + "noTelemetry": "Aucune télémétrie", + "noTelemetryHelp": "Cet observateur n’a envoyé aucune télémétrie pour cette période.", + "loadFailed": "Impossible de charger l’observateur.", + "retry": "Réessayer", + "model": "Modèle", + "firmware": "Micrologiciel", + "client": "Client", + "radio": "Radio", + "publicKey": "Clé publique", + "copy": "Copier", + "copied": "Copié", + "packetArrival": "Réception de paquet", + "firstSeen": "Première réception", + "copyLink": "Copier le lien de l’observateur", + "window": "Période UTC", + "time": "Heure (UTC)", + "mean": "Moyenne", + "minimum": "Minimum", + "unknown": "Inconnu", + "recording": "Chargement de l’observateur…", + "hour": "heure", + "day": "jour", + "sidebarSearch": "Rechercher les observateurs", + "noMatches": "Aucun résultat", + "none": "Aucun observateur", + "packetsLastHour": "Paquets dernière heure", + "copyKey": "Copier la clé publique", + "sort": "Trier les observateurs", + "sortActivity": "Activité", + "sortName": "Nom", + "deviceInfo": "Appareil", + "brokers": "Courtiers", + "copyLinkShort": "Copier le lien", + "advertsHeard": "Annonces reçues", + "noAdverts": "Aucune annonce reçue", + "unknownNode": "inconnu", + "hops": "Sauts" + }, + "observerCompare": { + "open": "Comparer avec…", + "close": "Fermer la comparaison", + "title": "Comparer les observateurs", + "partner": "Observateur B", + "choose": "Choisissez un observateur à comparer", + "invalidObserver": "Choisissez un autre observateur avec un identifiant valide.", + "invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.", + "refresh": "Actualiser", + "unavailable": "Comparaison non disponible sur ce serveur.", + "currentNote": "Les totaux couvrent cette période ; batterie, durée de fonctionnement et bruit de fond sont des valeurs actuelles.", + "metrics": "Mesures de comparaison des observateurs", + "metric": "Mesure", + "activity": "Paquets reçus", + "overlap": "Reçus par les deux", + "retention": "Seuls les paquets encore conservés par le serveur sont comparés.", + "total_one": "{{count}} paquet flood", + "total_other": "{{count}} paquets flood", + "total_many": "{{count}} paquets flood", + "empty": "Aucun des deux observateurs n’a reçu de paquet flood pendant cette période.", + "onlyA": "A seulement", + "both": "Les deux", + "onlyB": "B seulement", + "definition": "Chaque paquet flood compte une fois. Ce n’est pas une mesure de perte de paquets.", + "retainedWindow": "Les paquets ne sont conservés que quelques jours : les périodes plus anciennes peuvent être incomplètes.", + "overlapShortRanges": "Le chevauchement des paquets est disponible pour 24 h et 7 j.", + "invalidPair": "Choisissez deux observateurs différents.", + "invalidRange": "Choisissez un début valide et une fin postérieure.", + "observerA": "Observateur A", + "chooseObserver": "Choisissez un observateur", + "start": "Début (heure locale)", + "end": "Fin (heure locale)", + "retentionWarning": "Seuls les paquets récents sont conservés : le début de cette période peut être vide.", + "compare": "Comparer", + "selection": "Sélection", + "invalidLink": "Ce lien de comparaison contient des valeurs invalides ou manquantes. Choisissez les observateurs et les dates ci-dessous.", + "floodHeard": "Paquets flood reçus", + "pair": "A : {{a}} · B : {{b}}", + "allRegions": "Toutes", + "localTimeNote": "Heure locale ; la fin est exclue.", + "comparing": "Comparaison…", + "retryComparison": "Relancer la comparaison", + "packetsTotal_one": "{{value}} paquet flood", + "packetsTotal_other": "{{value}} paquets flood", + "packetsTotal_many": "{{value}} paquets flood", + "percentNote": "Les pourcentages portent sur tous les paquets flood reçus par l’un ou l’autre observateur ; chaque paquet compte une fois. Ce n’est pas une mesure de perte de paquets.", + "emptyRegion": "Aucun des deux observateurs n’a reçu de paquet flood pendant cette période et dans cette région.", + "tableLabel": "Comparaison des paquets flood", + "heardBy": "Reçus par", + "packets": "Paquets", + "percentUnion": "% de l’union" + }, + "investigation": { + "title": "Observations", + "observations_one": "{{count}} observation", + "observations_other": "{{count}} observations", + "observations_many": "{{count}} observations", + "heardBy_one": "Reçu par {{count}} observateur", + "heardBy_other": "Reçu par {{count}} observateurs", + "heardBy_many": "Reçu par {{count}} observateurs", + "heardTimes_one": "Reçu une fois", + "heardTimes_other": "Reçu {{count}} fois", + "heardTimes_many": "Reçu {{count}} fois", + "byObservers_one": "par {{count}} observateur", + "byObservers_other": "par {{count}} observateurs", + "byObservers_many": "par {{count}} observateurs", + "pathNote": "Regroupé selon les répéteurs par lesquels chaque copie est passée. Un identifiant court peut correspondre à plusieurs nœuds.", + "traceNote": "Les entrées TRACE montrent l’itinéraire prévu, pas le trajet réellement suivi.", + "empty": "Aucune observation pour ce paquet.", + "path": "Trajet {{number}}", + "tracePath": "Itinéraire prévu {{number}}", + "noEntries": "Aucune entrée de trajet", + "unavailablePath": "Octets de trajet indisponibles", + "inspect": "Sélectionner l’observation", + "observer": "Examiner l’observateur", + "map": "Carte du trajet", + "unmappable": "Il faut au moins deux répéteurs dont la position est connue.", + "retention": "Une entrée par observateur. Les paquets anciens expirent selon la rétention du serveur.", + "unavailableReport": "Cette observation n’est plus disponible ou le lien est invalide. Choisissez-en une ci-dessous.", + "copy": "Copier le lien", + "copyPacket": "Copier le lien du paquet", + "viewAll": "Carte de tous les trajets", + "mapDialog": "Carte du trajet du paquet", + "mapTitle": "Trajet du paquet", + "copyPath": "Copier le lien du trajet", + "closeMap": "Fermer la carte du trajet", + "allPaths": "Tous les trajets", + "traceRoute": "Itinéraire de trace", + "mapNote": "Les lignes relient les répéteurs dont la position est connue. Elles ne prouvent pas une liaison radio directe.", + "unavailableMap": "Ce trajet ne peut pas être cartographié. Choisissez un autre trajet ou Tous les trajets.", + "first": "Premier", + "last": "Dernier", + "span": "Durée de réception", + "nodeDialog": "Détails du nœud", + "packetDialog": "Analyseur de paquets", + "closeObserver": "Fermer les détails de l’observateur", + "loadMore": "Afficher plus ({{count}} restants)" + }, + "routeEvidence": { + "recent": "Observations récentes", + "loading": "Chargement des observations…", + "missing": "Les observations récentes ne sont pas disponibles pour ce trajet sur ce serveur.", + "error": "Impossible de charger les observations récentes. Réessayez.", + "retry": "Réessayer", + "match": "Observations le long de ces sauts exacts, avec {{width}} octet(s) par saut, de la plus récente à la plus ancienne. Les paquets TRACE sont exclus.", + "caution": "Des identifiants courts identiques ne prouvent pas un même trajet physique.", + "retention": "Les paquets anciens disparaissent ; le nombre « Reçu » ci-dessus couvre toute la vie du trajet.", + "unavailable": "Ce trajet ne peut pas être comparé aux paquets.", + "empty": "Aucune observation de ce trajet n’est encore conservée. Les plus anciennes ont pu expirer.", + "more": "Charger plus", + "cap": "Affichage des 500 observations les plus récentes.", + "inspect": "Examiner le paquet" + }, + "channelMessages": { + "select": "Choisir un canal", + "back": "Retour aux canaux", + "hash": "empreinte : {{hash}}", + "keyKnown": "clé connue", + "noKey": "sans clé", + "hashtag": "mot-dièse", + "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", + "scope": "Portée", + "allScopes": "Toutes les portées", + "unknownScope": "Portée inconnue", + "inspect": "Inspecter le paquet de {{sender}}", + "loading": "Chargement des messages…", + "loadingOlder": "Chargement des messages plus anciens… ({{count}} affichés)", + "loadOlder": "Charger les messages plus anciens", + "empty": "Aucun message pour ce canal.", + "emptyScope": "Aucun message ne correspond à {{scope}}.", + "emptyNoKey": "Les messages ne peuvent pas être déchiffrés sans la clé du canal.", + "error": "Impossible de charger les messages.", + "retry": "Réessayer", + "viewPacket": "Voir le paquet", + "heardTimes_one": "Entendu {{count}} fois", + "heardTimes_other": "Entendu {{count}} fois", + "heardTimes_many": "Entendu {{count}} fois" + }, + "header": { + "settings": "Réglages", + "github": "GitHub" + }, + "channels": { + "noMatches": "Aucun canal correspondant chargé.", + "loadError": "Impossible de charger les canaux.", + "loadingChannels": "Chargement des canaux...", + "retryChannels": "Réessayer de charger les canaux", + "loadOthers": "Charger les autres canaux", + "loadMore": "Charger plus de canaux", + "key": "clé", + "filters": "Filtres des canaux", + "fieldName": "Nom", + "fieldHash": "Empreinte", + "keyFilter": "Clé", + "hashtagFilter": "Mot-dièse", + "keyKnownOption": "Connue", + "keyUnknownOption": "Inconnue", + "any": "Toutes", + "yes": "Oui", + "no": "Non" + }, + "map": { + "settings": "Paramètres de la carte", + "tiles": "Fond de carte", + "nodeType": "Type de nœud", + "nodeTypeLabel": "Type de nœud", + "clustering": "Regroupement", + "neighborLines": "Liens de voisinage", + "neighborLinesLabel": "Liens de voisinage", + "areaBorders": "Limites de zone", + "areaBordersLabel": "Limites de zone", + "all": "Tous", + "on": "Activé", + "off": "Désactivé", + "selected": "Sélection", + "areaOutline": "Contour de zone", + "observations": "Observations", + "fainter": "plus pâle = entendu il y a plus longtemps", + "copyLink": "Copier le lien de la carte", + "copyLinkLabel": "Copier un lien vers cette vue de la carte", + "liveMap": "Carte en direct", + "playFlow": "Lancer le flux de paquets en direct sur la carte", + "stopFlow": "Arrêter le flux de paquets en direct sur la carte", + "loadFailed": "Échec du chargement de la carte", + "loadFailedHint": "Vérifiez votre connexion et rechargez la page", + "style": "Style de carte", + "styles": { + "dark": "Sombre", + "liberty": "Liberty", + "positron": "Clair" + }, + "controls": { + "toggleAttribution": "Afficher ou masquer les crédits", + "mapFeedback": "Signaler un problème sur la carte", + "map": "Carte", + "resetBearing": "Faites glisser pour pivoter la carte, cliquez pour revenir au nord", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "closePopup": "Fermer la fenêtre" + } + }, + "nodeFilters": { + "label": "Filtres des nœuds", + "type": "Type", + "multibytePaths": "Chemins multi-octets", + "multibyteTraces": "Traces multi-octets", + "scope": "Portée", + "any": "Indifférent", + "yes": "Oui", + "no": "Non", + "fieldName": "Nom", + "fieldPubkey": "Clé publique", + "all": "Tous" + }, + "packetAnalyzer": { + "title": "Analyseur de paquets", + "close": "Fermer l’analyseur", + "empty": "Sélectionnez un paquet à analyser", + "summary": "Résumé", + "rawPacket": "Paquet brut", + "structure": "Structure du paquet", + "headerByte": "Octet d’en-tête", + "ver": "Ver", + "type": "Type", + "route": "Routage", + "transportCodes": "Codes de transport", + "scopeCode": "Code de portée", + "noKnownScope": "aucune portée connue", + "code2": "Code 2", + "pathLength": "Longueur du trajet", + "hashSize": "Taille du hash", + "pathData": "Données du trajet", + "originPubkey": "Clé publique d’origine", + "payloadBreakdown": "Détail de la charge utile", + "payloadData": "Données de la charge utile", + "bytes_one": "{{count}} octet", + "bytes_other": "{{count}} octets", + "bytes_many": "{{count}} octets", + "bits": { + "ver": "ver", + "type": "type", + "route": "routage", + "hash": "hash={{size}}B", + "hops": "sauts={{value}}", + "loc": "pos", + "name": "nom" + }, + "payload": { + "yes": "Oui", + "no": "Non", + "encrypted": "Chiffré — clé non disponible", + "to": "Vers", + "from": "De", + "ciphertext": "Texte chiffré", + "decrypted": "Déchiffré", + "channel": "Canal", + "publicKey": "Clé publique", + "timestamp": "Horodatage", + "signature": "Signature", + "appFlags": "Indicateurs d’application", + "role": "Rôle", + "loc": "Pos", + "name": "Nom", + "location": "Position", + "traceTag": "Étiquette de trace", + "authCode": "Code d’authentification", + "flags": "Indicateurs", + "tracePath": "Trajet de trace", + "sender": "Expéditeur", + "message": "Message", + "attempt": "Tentative {{value}}", + "flagsValue": "Indicateurs {{value}}", + "data": "Données", + "time": "Heure", + "tag": "Étiquette", + "checksum": "Somme de contrôle", + "bundledType": "Type joint", + "bundledData": "Données jointes", + "typeFilter": "Filtre de type", + "typeN": "Type {{value}}", + "any": "tous", + "since": "Depuis", + "prefixOnly": "Préfixe seul", + "nodeType": "Type de nœud", + "requestSnr": "SNR de la requête", + "nodeToNode": "(nœud à nœud)", + "keyPrefix": "Préfixe de clé", + "raw": "Brut", + "destHash": "Hash de destination", + "ephemeralKey": "Clé éphémère" + } + }, + "observation": { + "prop": "Prop.", + "path": "Trajet", + "observer": "Observateur", + "area": "Zone", + "heard": "Reçu", + "hops": "Sauts" + }, + "packetEndpoints": { + "na": "n/d" + }, + "routes": { + "columns": { + "area": "Zone", + "hops": "Sauts", + "route": "Trajet", + "obs": "Obs.", + "firstSeen": "Première réception", + "lastSeen": "Dernière réception" + }, + "hops_one": "{{count}} saut", + "hops_other": "{{count}} sauts", + "hops_many": "{{count}} sauts", + "obs_one": "{{value}} obs.", + "obs_other": "{{value}} obs.", + "obs_many": "{{value}} obs.", + "first": "premier", + "last": "dernier", + "findPath": "Chercher un trajet", + "fromPlaceholder": "hash de départ", + "fromLabel": "Hash de départ", + "toPlaceholder": "hash d’arrivée", + "toLabel": "Hash d’arrivée", + "areas": "Zones", + "search": "Rechercher", + "clear": "Effacer", + "searching": "Recherche…", + "noCross": "Aucun trajet entre ces zones", + "noMatches": "Aucun trajet correspondant", + "empty": "Aucun trajet" + }, + "routeDetail": { + "title": "Détail du trajet", + "summary": "Résumé", + "hops": "Sauts", + "heard": "Reçu", + "route": "Trajet", + "timestamps": "Horodatages", + "firstSeen": "Première réception", + "lastSeen": "Dernière réception" + }, + "traces": { + "packets": "Paquets", + "packetCount_one": "{{count}} paquet", + "packetCount_other": "{{count}} paquets", + "packetCount_many": "{{count}} paquets", + "noPackets": "Aucun paquet", + "noPath": "aucun chemin", + "unknown": "Inconnu", + "analyze": "analyser →", + "first": "Premier", + "last": "Dernier", + "tagCount_one": "{{count}} étiquette", + "tagCount_other": "{{count}} étiquettes", + "tagCount_many": "{{count}} étiquettes", + "pkt_one": "{{count}} paq.", + "pkt_other": "{{count}} paq.", + "pkt_many": "{{count}} paq.", + "iata_one": "{{count}} iata", + "iata_other": "{{count}} iata", + "iata_many": "{{count}} iata", + "typeLabel": "Type de trace", + "typeHint": "Filtre des étiquettes entières : TRACE si un paquet de l'étiquette est multi-saut, sinon PING. Les filtres de portée et de temps portent sur la première réception de l'étiquette ; les comptes et les heures couvrent toujours toute l'étiquette.", + "all": "Tous", + "trace": "Trace", + "ping": "Ping", + "empty": "Aucune trace" + }, + "loadingPill": { + "nodes": { + "loading_one": "Chargement des nœuds… ({{count}})", + "loading_other": "Chargement des nœuds… ({{count}})", + "loading_many": "Chargement des nœuds… ({{count}})", + "partial_one": "Certains nœuds n’ont pas pu être chargés ({{count}} affiché)", + "partial_other": "Certains nœuds n’ont pas pu être chargés ({{count}} affichés)", + "partial_many": "Certains nœuds n’ont pas pu être chargés ({{count}} affichés)", + "failed": "Échec du chargement des nœuds" + }, + "routes": { + "loading_one": "Chargement des routes… ({{count}})", + "loading_other": "Chargement des routes… ({{count}})", + "loading_many": "Chargement des routes… ({{count}})", + "partial_one": "Certaines routes n’ont pas pu être chargées ({{count}} affichée)", + "partial_other": "Certaines routes n’ont pas pu être chargées ({{count}} affichées)", + "partial_many": "Certaines routes n’ont pas pu être chargées ({{count}} affichées)", + "failed": "Échec du chargement des routes" + }, + "packets": { + "loading_one": "Chargement des paquets… ({{count}})", + "loading_other": "Chargement des paquets… ({{count}})", + "loading_many": "Chargement des paquets… ({{count}})", + "partial_one": "Certains paquets n’ont pas pu être chargés ({{count}} affiché)", + "partial_other": "Certains paquets n’ont pas pu être chargés ({{count}} affichés)", + "partial_many": "Certains paquets n’ont pas pu être chargés ({{count}} affichés)", + "failed": "Échec du chargement des paquets" + }, + "messages": { + "loading_one": "Chargement des messages… ({{count}})", + "loading_other": "Chargement des messages… ({{count}})", + "loading_many": "Chargement des messages… ({{count}})", + "partial_one": "Certains messages n’ont pas pu être chargés ({{count}} affiché)", + "partial_other": "Certains messages n’ont pas pu être chargés ({{count}} affichés)", + "partial_many": "Certains messages n’ont pas pu être chargés ({{count}} affichés)", + "failed": "Échec du chargement des messages" + }, + "observers": { + "loading_one": "Chargement des observateurs… ({{count}})", + "loading_other": "Chargement des observateurs… ({{count}})", + "loading_many": "Chargement des observateurs… ({{count}})", + "partial_one": "Certains observateurs n’ont pas pu être chargés ({{count}} affiché)", + "partial_other": "Certains observateurs n’ont pas pu être chargés ({{count}} affichés)", + "partial_many": "Certains observateurs n’ont pas pu être chargés ({{count}} affichés)", + "failed": "Échec du chargement des observateurs" + } + }, + "talkers": { + "node": "Nœud", + "flood": "Flood", + "direct": "Direct", + "topAdvertisers": "Principaux annonceurs · {{range}}", + "floodDirect": "flood · direct", + "noAdvertisers": "Aucun annonceur", + "topTalkers": "Principaux émetteurs · {{range}}", + "byName": "par nom" + }, + "neighbourGraph": { + "pickRegion": "Choisissez une région", + "allTooBig": "Toutes les régions représentent plus de 5 000 nœuds — choisissez une région dans le sélecteur RÉGION ci-dessus, ou limitez-vous à une seule zone, pour afficher son réseau.", + "loadFailed": "Échec du chargement des nœuds", + "loadingMesh": "Chargement du réseau…", + "nodes_one": "{{count}} nœud", + "nodes_other": "{{count}} nœuds", + "nodes_many": "{{count}} nœuds", + "noNodes": "Aucun nœud dans cette région", + "neighborhoodOf": "Voisinage de", + "neighbors_one": "{{count}} voisin", + "neighbors_other": "{{count}} voisins", + "neighbors_many": "{{count}} voisins", + "clickEmpty": "Cliquez dans un espace vide pour revenir au réseau complet.", + "showing": "{{cap}} nœuds affichés sur {{total}}", + "narrow": "Limitez-vous à une seule zone pour voir les autres.", + "searchName": "Nom", + "other": "Autre", + "unknownType": "inconnu", + "edgeToday": "{{obs}} obs · vu aujourd’hui", + "edgeAgo": "{{obs}} obs · vu il y a {{days}} j" + }, + "nodeTypes": { + "companion": "Compagnon", + "repeater": "Répéteur", + "room_server": "Salon", + "sensor": "Capteur" + }, + "observerFilters": { + "label": "Filtres des observateurs", + "online": "En ligne", + "offline": "Hors ligne", + "name": "Nom", + "status": "État", + "type": "Type", + "broker": "Courtier", + "scope": "Portée" + }, + "charts": { + "uniquePackets": "Paquets uniques", + "busy": "Occupation" + }, + "packetList": { + "pathSearchInvalid": "Saisissez des hashs de saut complets de même longueur, séparés par des espaces, des virgules ou → (p. ex. 7f a4).", + "pathSearchHint": "Recherche dans le dernier trajet de relais des paquets chargés. Utilisez des hashs de saut complets, p. ex. 7f a4 (espaces, virgules ou →). Les paquets trace sont exclus.", + "selectedPacket": "Paquet sélectionné", + "invalidHash": "Hash de paquet invalide.", + "notFound": "Paquet introuvable.", + "loadFailed": "Échec du chargement du paquet sélectionné.", + "retry": "Réessayer", + "retryLabel": "Réessayer de charger le paquet sélectionné", + "outsideResults": "Le paquet sélectionné est hors des résultats chargés.", + "openAnalyzer": "Ouvrir l’analyseur", + "loadingSelected": "Chargement du paquet sélectionné…", + "dismissSelected": "Fermer le paquet sélectionné", + "dropped_one": "{{count}} paquet perdu — les données peuvent être incomplètes", + "dropped_many": "{{count}} paquets perdus — les données peuvent être incomplètes", + "dropped_other": "{{count}} paquets perdus — les données peuvent être incomplètes", + "dismiss": "ignorer", + "newPackets_one": "{{count}} nouveau paquet", + "newPackets_many": "{{count}} nouveaux paquets", + "newPackets_other": "{{count}} nouveaux paquets", + "scrollToTop": "· remonter en haut", + "live": "Paquets en direct", + "noMatching": "Aucun paquet correspondant chargé.", + "loadingPackets": "Chargement des paquets...", + "loadOlder": "Charger des paquets plus anciens" + }, + "packetTable": { + "hash": "Hash", + "obs": "Obs.", + "hops": "Sauts", + "summary": "Résumé / Src → Dst", + "summaryTitle": "Résumé et source / destination", + "area": "Zone", + "age": "Âge", + "unknown": "Inconnu" + }, + "filters": { + "types": "Types", + "routes": "Routes", + "observers": "Observateurs", + "scope": "Portée", + "packetFilters": "Filtres de paquets", + "field": { + "hash": "Hash", + "path": "Dernier trajet", + "payload": "Charge utile" + } + }, + "search": { + "placeholder": "Rechercher par {{field}}…" + }, + "errorBoundary": { + "message": "Une erreur est survenue lors de l’affichage de cette vue.", + "reload": "recharger", + "retry": "réessayer" + }, + "splash": { + "tagline": "Analyseur de réseau MeshCore" + }, + "units": { + "perDay": "{{value}}/j" + } + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ed4cab8..317b56b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,8 +1,9 @@ -export const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8080/api/v1"; -export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"; +import { runtimeEnv } from "./runtime-env"; + +export const API_BASE = runtimeEnv("VITE_API_BASE", import.meta.env.VITE_API_BASE) ?? "http://localhost:8080/api/v1"; +export const WS_URL = runtimeEnv("VITE_WS_URL", import.meta.env.VITE_WS_URL) ?? "ws://localhost:8080/ws"; export const LIVE_BUFFER_CAP = 500; -export const MAX_INFINITE_PAGES = 20; export const DEFAULT_PAGE_SIZE = 50; export const SCROLL_TOP_THRESHOLD_PX = 100; @@ -14,15 +15,20 @@ export const WS_PING_INTERVAL_MS = 30_000; export const WS_RECONNECT_BASE_MS = 1000; export const WS_RECONNECT_MAX_MS = 30_000; export const WS_RECONNECT_JITTER = 0.25; +// a link that held this long counts as healthy, so the next drop restarts the backoff from scratch +export const WS_STABLE_MS = 10_000; + +// window to assume when a 429 arrives without Retry-After, and a ceiling on what the header can ask +export const RATE_LIMIT_DEFAULT_MS = 10_000; +export const RATE_LIMIT_MAX_MS = 300_000; // 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", "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/). +// Per-deployment config: read via runtimeEnv, so the Docker image's /config.js (written at container +// start, see .build/) overrides the VITE_* build-time env. -// 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. +// Split a comma-separated env value into trimmed, non-empty entries. export function parseEnvList(raw: string | undefined): string[] { return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean); } @@ -33,8 +39,8 @@ export function filterEnabledTabs(tabs: readonly string[], disabledRaw: string | 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. +// Truthy env flag: "1"/"true"/"yes"/"on" (case-insensitive). Anything else, empty or unset included, +// 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()); } @@ -52,17 +58,19 @@ export function selectableThemes( return themes.filter((t) => !t.hidden); } -const enabledTabs = filterEnabledTabs(TABS, import.meta.env.VITE_DISABLED_TABS); +const enabledTabs = filterEnabledTabs(TABS, runtimeEnv("VITE_DISABLED_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()), + parseEnvList(runtimeEnv("VITE_ENABLED_THEMES", 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 APP_NAME = runtimeEnv("VITE_APP_NAME", import.meta.env.VITE_APP_NAME) || "BEACON"; export const GITHUB_URL = "https://github.com/MeshCore-Beacon"; +// Notice shown above the header on every page; unset = no banner. +export const BANNER = runtimeEnv("VITE_BANNER", import.meta.env.VITE_BANNER) ?? ""; + // 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); +export const SKIP_SPLASH = parseEnvBool(runtimeEnv("VITE_SKIP_SPLASH", import.meta.env.VITE_SKIP_SPLASH)); diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 3c1b0b6..c127045 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -1,5 +1,7 @@ // hex and time display helpers +import i18n from "../i18n"; + export function formatHex(hex: string): string { return hex.slice(0, 8).toUpperCase(); } @@ -16,6 +18,14 @@ export function formatAbsolute(epochMs: number, opts?: { ms?: boolean }): string return opts?.ms ? `${base}.${pad(d.getMilliseconds(), 3)}` : base; } +// The UTC counterpart to formatAbsolute, for charts/tables anchored to server-side hour/minute +// boundaries rather than the viewer's local time. +export function formatUtc(epochMs: number, opts?: { seconds?: boolean; timeOnly?: boolean }): string { + const iso = new Date(epochMs).toISOString(); + const date = iso.slice(0, 10), time = iso.slice(11, opts?.seconds ? 19 : 16); + return opts?.timeOnly ? time : `${date} ${time}`; +} + // signal quality and radio metric formatting export type SignalLevel = "good" | "mid" | "bad"; @@ -26,6 +36,12 @@ export const SIGNAL_LEVEL_CLASSES: Record = { bad: "text-danger", }; +export const SIGNAL_LEVEL_BORDER_CLASSES: Record = { + good: "border-l-green", + mid: "border-l-warn", + bad: "border-l-danger", +}; + export function snrLevel(snr: number | null | undefined): SignalLevel | null { if (snr == null) return null; if (snr >= 10) return "good"; @@ -47,23 +63,25 @@ export function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; + const unit = (u: "d" | "h" | "m", count: number) => i18n.t(`timestamp.unit.${u}`, { count }); + if (d > 0) return `${unit("d", d)} ${unit("h", h)} ${unit("m", m)}`; + if (h > 0) return `${unit("h", h)} ${unit("m", m)}`; + return unit("m", m); } // Signed device-clock drift for the node detail, e.g. "+42s ahead", "-1h 1m behind", "in sync". // formatUptime floors to whole minutes and is unsigned, so it can't render sub-minute drift. // +ve = device clock ahead of the server (matches clockDriftSeconds). -export function formatClockDrift(seconds: number): string { - if (seconds === 0) return "in sync"; - const dir = seconds > 0 ? "ahead" : "behind"; +export function formatClockDrift(seconds: number, labels = { inSync: "in sync", ahead: "ahead", behind: "behind" }): string { + if (seconds === 0) return labels.inSync; + const dir = seconds > 0 ? labels.ahead : labels.behind; const sign = seconds > 0 ? "+" : "-"; const s = Math.abs(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; - const mag = h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`; + const unit = (u: "h" | "m" | "s", count: number) => i18n.t(`timestamp.unit.${u}`, { count }); + const mag = h > 0 ? `${unit("h", h)} ${unit("m", m)}` : m > 0 ? `${unit("m", m)} ${unit("s", sec)}` : unit("s", sec); return `${sign}${mag} ${dir}`; } @@ -90,28 +108,48 @@ export function formatRatePerDay(count: number | null | undefined, windowMs: num const days = windowMs / 86_400_000; const rate = days > 0 ? count / days : 0; const shown = rate >= 10 ? formatCount(Math.round(rate)) : String(Math.round(rate * 10) / 10); - return `${shown}/d`; + return i18n.t("units.perDay", { value: shown }); } +export type TimeAgoUnit = "s" | "m" | "h" | "d"; + // clamp negative values from clock skew -export function timeAgoMs(epochMs: number): string { +export function timeAgoParts(epochMs: number): { count: number; unit: TimeAgoUnit } { const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return { count: seconds, unit: "s" }; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return { count: minutes, unit: "m" }; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; + if (hours < 24) return { count: hours, unit: "h" }; + return { count: Math.floor(hours / 24), unit: "d" }; } -// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"). -// Formats freq/SF/bandwidth like the observer panel ("915 MHz · SF11 · 250 kHz"); the compact -// string carries no coding rate, so there's no "CR 4/x" segment. +// Compact English "7d" matching Timestamp's English relative phrasing; kept for tests that assert +// against it, not used by app code (see timeAgoParts for the i18n path). +export function timeAgoMs(epochMs: number): string { + const { count, unit } = timeAgoParts(epochMs); + return `${count}${unit}`; +} + +// One radio config format for every panel ("915 MHz · SF11 · 250 kHz · CR 4/5"); unknown or zero parts drop out. +export function formatRadioParts(r: { freqMhz?: number | null; sf?: number | null; bwKhz?: number | null; cr?: number | null }): string | null { + const known = (v: number | null | undefined): v is number => v != null && v > 0; + const parts = [ + known(r.freqMhz) && `${r.freqMhz} MHz`, + known(r.sf) && `SF${r.sf}`, + known(r.bwKhz) && `${r.bwKhz} kHz`, + known(r.cr) && `CR 4/${r.cr}`, + ].filter(Boolean) as string[]; + return parts.length > 0 ? parts.join(" · ") : null; +} + +// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"); the +// compact string carries no coding rate, so there's no "CR 4/x" segment. export function formatRadio(radio: string | null | undefined): string | null { if (!radio) return null; const [freq, bw, sf] = radio.split(","); if (!freq || !bw || !sf) return radio; // unexpected shape — show it raw rather than hide it - const f = Number(freq), b = Number(bw); - if (Number.isNaN(f) || Number.isNaN(b)) return radio; // non-numeric freq/bw — show raw, not "NaN MHz" - return `${f} MHz · SF${sf} · ${b} kHz`; + const f = Number(freq), b = Number(bw), s = Number(sf); + if (Number.isNaN(f) || Number.isNaN(b) || Number.isNaN(s)) return radio; // non-numeric — show raw, not "NaN MHz" + return formatRadioParts({ freqMhz: f, sf: s, bwKhz: b }); } diff --git a/src/lib/runtime-env.ts b/src/lib/runtime-env.ts new file mode 100644 index 0000000..f27c9e3 --- /dev/null +++ b/src/lib/runtime-env.ts @@ -0,0 +1,14 @@ +declare global { + interface Window { + __BEACON_CONFIG__?: Record; + } +} + +// Container config (written to /config.js at start) wins; otherwise the build-time value, so +// `npm run dev` and .env.local keep working. Callers pass import.meta.env.VITE_X explicitly so +// Vite inlines only that key, not the whole env object. +export function runtimeEnv(key: string, buildValue: string | undefined): string | undefined { + const value = typeof window === "undefined" ? undefined : window.__BEACON_CONFIG__?.[key]; + if (typeof value === "string" && value !== "") return value; + return buildValue; +} diff --git a/src/main.tsx b/src/main.tsx index a93d054..2be8131 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import "./i18n"; import { App } from "./App"; import "./index.css"; diff --git a/src/types/api.ts b/src/types/api.ts index f50ac80..2dc6a42 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -2,9 +2,9 @@ import type { PathConfidence } from "./enums"; // response wrappers -export interface CursorPage { +export interface CursorPage { items: T[]; - nextCursor: number | null; + nextCursor: C | null; hasMore: boolean; } @@ -35,6 +35,7 @@ export interface PacketSummary { observationCount: number; latestObserver?: LatestObserver; scope?: string; // matched transport scope name, e.g. "#bc" + summary?: string; // packet-derived display text (advert name, TRACE tag, ACK checksum, …) } export interface ResolvedNode { @@ -156,6 +157,7 @@ export interface RouteHop { export interface KnownRoute { id: number; + pathKey?: string; // stable full-route identity; absent on older servers iata: string; hopCount: number; hops: RouteHop[]; @@ -164,6 +166,32 @@ export interface KnownRoute { observationCount: number; } +export interface RouteObservation { + id: number; + packetHash: string; + observerId: string; + observerName?: string; + heardAt: number; + payloadType: number; + payloadTypeName: string; + rssi?: number; + snr?: number; +} + +export interface RouteEvidence { + route: KnownRoute; + windowStart: number; + windowEnd: number; + generatedAt: number; + matchType: "saved_path_prefixes"; + matchAvailable: boolean; + hashSize?: number; + pathBytes?: string; + items: RouteObservation[]; + hasMore: boolean; + nextPageCursor?: string; +} + // the boundary hop in a cross-IATA route: the link from the last node in the source IATA to the // first node in the target IATA. export interface CrossIATAHop { @@ -211,8 +239,9 @@ export interface TracePacket { scope?: string; // matched transport scope name, when any firstHeardAt: number; // epoch ms lastHeardAt: number; // epoch ms - rawPath: RawHop[]; // one hop per trace path hash, index-aligned with resolvedRoute - resolvedRoute: ResolvedHop[]; // one hop per trace path hash; nodes empty when unresolved + // both null when the payload didn't parse; resolvedRoute also when the packet has no IATAs + rawPath: RawHop[] | null; // one hop per trace path hash, index-aligned with resolvedRoute + resolvedRoute: ResolvedHop[] | null; // one hop per trace path hash; nodes empty when unresolved } export interface TraceDetail { diff --git a/src/types/ws.ts b/src/types/ws.ts index 7701ccd..bd93355 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -35,6 +35,9 @@ export interface WsConfigured { type: "configured"; id: string; resolvePath: boolean; + // echoed by servers that support these opt-ins; we never set them + includeObserverKey?: boolean; + includeRepeats?: boolean; } export interface WsPacketObservation { @@ -51,10 +54,14 @@ export interface WsPacketObservation { isFirstObservation: boolean; observationCount: number; scope?: string; // matched transport scope name; omitted when none matched + summary?: string; + // later hearing over a new path, only sent to configure{includeRepeats}; observationCount is 0 + isRepeat?: boolean; }; observation: { observerId: string; observerName: string; + observerPublicKey?: string; // only with configure{includeObserverKey} iata: string; heardAt: number; rssi: number; @@ -100,10 +107,12 @@ export interface WsNodeUpdate { name: string; nodeType: number; nodeTypeName: string; + // Omitted leaves the prior verdict unchanged; null clears an unknown/reset position. + possiblyForeign?: boolean | null; iata: string; - // decimal degrees, same as REST /nodes (api/nodes.go serializes *float64 degrees to both) - lat?: number; - lng?: number; + // decimal degrees as in REST /nodes; omitted keeps the prior position, null clears it (explicit 0/0 advert) + lat?: number | null; + lng?: number | null; isObserver: boolean; iatas: NodeIATA[]; defaultScope?: string; diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx index 7c40724..01e675c 100644 --- a/tests/App.analyzerObservationCarryOver.test.tsx +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -32,13 +32,13 @@ vi.mock("../src/api/client", () => ({ })); const packet: PacketSummary = { - packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + packetHash: "aa11", payloadType: 1, payloadTypeName: "ADVERT", routeType: 1, routeTypeName: "FLOOD", firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 3, }; const detail = { - packetHash: "AA11", + packetHash: "aa11", header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 3, rawPayload: "", decrypted: false, @@ -70,7 +70,7 @@ vi.mock("../src/features/packets/usePackets", () => ({ vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ - data: hash === "AA11" ? detail : undefined, + data: hash === "aa11" ? detail : undefined, isLoading: false, isError: false, refetch: () => {}, @@ -129,10 +129,19 @@ describe("opening the analyzer from an expanded row", () => { it("keeps the observation selected in the expanded row", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "AA11" })); + fireEvent.click(await screen.findByRole("button", { name: "aa11" })); fireEvent.click(await screen.findByText("Observer Three")); const drawer = await screen.findByTestId("packet-analyzer-drawer"); - expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); + expect(within(drawer).getByRole("button", { name: "Select observation: Observer Three" })).toHaveAttribute("aria-pressed", "true"); }); }); + +it("replaces a shared observation when another report is selected in the row", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=aa11&observation=3"); + render(); + fireEvent.click(await screen.findByText("Observer Two")); + const drawer = await screen.findByTestId("packet-analyzer-drawer"); + expect(within(drawer).getByRole("button", { name: "Select observation: Observer Two" })).toHaveAttribute("aria-pressed", "true"); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("2"); +}); diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx new file mode 100644 index 0000000..4d8f6e9 --- /dev/null +++ b/tests/App.observerInvestigation.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useState, type ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useSearchParams } from "react-router-dom"; +import { App } from "../src/App"; +import { useRegionSelection } from "../src/hooks/useRegion"; +import type { PacketDetail } from "../src/types/api"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { + connect() {} disconnect() {} updateSubscription() {} +} })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getBrokers: async () => [], getScopes: async () => [] })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => { + const { setSelection } = useRegionSelection(); + return <>{children}; +} })); +vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObserver, onAnalyzePacket, onViewNode }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string, id: number) => void; onViewNode: (id: string) => void }) => { + const [filter, setFilter] = useState(""); const [params] = useSearchParams(); + return
setFilter(e.target.value)} />{params.toString()}
Routes
; +} })); +vi.mock("../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: () =>

Observer directory

})); +vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ data: [], isPending: false, isError: false, refetch: () => {} }) })); +vi.mock("../src/features/stats/MeshTab", () => ({ MeshTab: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { + const [value, setValue] = useState(""); + return <> setValue(e.target.value)} />; +} })); +vi.mock("../src/features/stats/ObserverTab", () => ({ ObserverTab: ({ selectedObserverId, range, actions }: { selectedObserverId: string; range: string; actions?: ReactNode }) => <>

Dashboard {selectedObserverId} {range}

{actions} })); +// Exercise the real analytics shell without loading charts unrelated to its observer action. +vi.mock("../src/features/stats/TrafficTab", () => ({ TrafficTab: () => null })); +vi.mock("../src/features/stats/SignalTab", () => ({ SignalTab: () => null })); +vi.mock("../src/features/stats/PathsTab", () => ({ PathsTab: () => null })); +vi.mock("../src/features/stats/ScopesTab", () => ({ ScopesTab: () => null })); +vi.mock("../src/features/stats/TalkersTab", () => ({ TalkersTab: () => null })); +vi.mock("../src/features/stats/ClockDriftTab", () => ({ ClockDriftTab: () => null })); +vi.mock("../src/features/stats/CompareObserversTab", () => ({ CompareObserversTab: () => null })); +vi.mock("../src/features/stats/NeighbourGraphTab", () => ({ NeighbourGraphTab: () => null })); +vi.mock("../src/features/observers/ObserverAdverts", () => ({ ObserverAdverts: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string, id: number) => void }) => })); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <>

Node {nodeId}

})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 1 } } as unknown as PacketDetail : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onClose, onViewObserver, onViewNode, onViewPath, selectedObservationId, onSelectObservation }: { detail: PacketDetail; onClose: () => void; onViewObserver: (id: string) => void; onViewNode: (id: string) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void }) => <>

Packet {detail?.packetHash} report {selectedObservationId}

})); +vi.mock("../src/features/map/PacketPathMap", () => ({ PacketPathMap: () =>
Path map
})); + +beforeEach(() => { + window.history.replaceState({}, "", "/?tab=Routes&route=full-route&routeIata=YOW"); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); +const click = (name: string) => { const button = screen.getByRole("button", { name, exact: true }); button.focus(); fireEvent.click(button); }; +const escape = () => fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); +const browserBack = async () => { await act(async () => { window.history.back(); }); await waitFor(() => expect(window.location.search).toContain("tab=Routes")); }; + +describe("observer investigation windows", () => { + const dashboard = async (id = "o1") => { + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(`Dashboard ${id}`); + const params = new URLSearchParams(window.location.search); + expect(params.get("tab")).toBe("Observers"); expect(params.get("observer")).toBe(id); + }; + it("opens the dashboard straight from the route panel", async () => { + render(); click("Route observer"); + await dashboard(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("moves a stacked packet into the side drawer beside the dashboard, keeping its report", async () => { + render(); click("Route packet"); click("Select report 8"); click("Packet observer"); + await dashboard(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa"); + const params = new URLSearchParams(window.location.search); + expect(params.get("hash")).toBe("aa"); expect(params.get("analyze")).toBe("1"); expect(params.get("observation")).toBe("8"); + }); + it("keeps the side drawer open when its observer is inspected", async () => { + window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1"); + render(); click("Packet observer"); + await dashboard(); + expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa"); + }); + it("closes stacked node windows when going to the dashboard", async () => { + render(); click("Route packet"); click("Packet node"); click("Node observer"); + await dashboard(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("opens an advert from the dashboard in the side drawer", async () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1"); + render(); click("Advert packet"); + expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet bb"); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("3"); + }); + it("keeps the node and packet stack when Escape is pressed", () => { + render(); click("Route packet"); click("Packet node"); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + click("Packet map"); escape(); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + }); + it("returns to the previous tab with browser Back", async () => { + render(); click("Route observer"); + await screen.findByRole("heading", { level: 1 }); + await browserBack(); expect(screen.getByTestId("route-origin")).toBeInTheDocument(); + }); + it("keeps the packet drawer and stacked windows across tabs, but not into Analytics", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1"); + render(); + expect(screen.getByRole("heading", { name: /Packet aa/ })).toBeInTheDocument(); + click("Packet node"); + click("Observer tab"); + expect(await screen.findByRole("heading", { level: 2, name: /Packet aa/ })).toBeInTheDocument(); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1"); + click("Analytics tab"); + await screen.findByRole("button", { name: "Leaderboard observer" }); + expect(screen.queryByRole("heading", { name: /Packet aa/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + click("Route tab"); + expect(await screen.findByRole("heading", { name: /Packet aa/ })).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("opens the Observers dashboard from the leaderboard", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=mesh&range=24h"); + render(); + await screen.findByRole("button", { name: "Leaderboard observer" }); + click("Leaderboard observer"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 24h"); + expect(window.location.search).toContain("tab=Observers"); + expect(window.location.search).toContain("observer=o1"); + expect(window.location.search).not.toContain("statsTab"); + }); + it("redirects legacy Analytics observer links to the Observers dashboard", async () => { + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=observer&observerId=o2&range=7d"); + render(); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 7d"); + expect(window.location.search).toContain("tab=Observers"); + expect(window.location.search).not.toContain("statsTab"); + }); + it("closes the stacked windows when the region changes", async () => { + render(); click("Route packet"); click("Packet node"); + click("Change region"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + it("clears comparison params from both the observer dashboard and analytics compare tabs on tab change", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=1700000000000&compareA=x&compareB=y&compareSince=1"); + render(); + click("Route tab"); + expect(window.location.search).toContain("tab=Routes"); + for (const key of ["observer", "compareWith", "compareUntil", "compareA", "compareB", "compareSince"]) { + expect(window.location.search).not.toContain(key); + } + }); + it("keeps analytics compare params when re-clicking the already-active Analytics tab", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=compare&compareA=a&compareB=b&compareSince=1&compareUntil=2"); + render(); + click("Analytics tab"); + for (const pair of ["compareA=a", "compareB=b", "compareSince=1", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); + it("keeps the observer dashboard's compare params when re-clicking the already-active Observer tab", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=2"); + render(); + click("Observer tab"); + for (const pair of ["observer=o1", "compareWith=o2", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); +}); + +describe("observer investigation on mobile", () => { + beforeEach(() => vi.stubGlobal("matchMedia", (query: string) => ({ matches: /max-width/.test(query), media: query, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {} }))); + it("closes an inspected observer back to the packet it came from", async () => { + render(); click("Route packet"); click("Select report 8"); click("Packet observer"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1"); + expect(screen.queryByRole("heading", { name: /^Packet / })).not.toBeInTheDocument(); + click("Back to observers"); + await waitFor(() => expect(new URLSearchParams(window.location.search).get("tab")).toBe("Routes")); + expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa"); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("8"); + }); + it("closes a dashboard opened from the list back to the list", async () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1"); + render(); + await screen.findByRole("heading", { level: 1 }); + click("Back to observers"); + await waitFor(() => expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument()); + expect(new URLSearchParams(window.location.search).get("tab")).toBe("Observers"); + }); +}); diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 83dc167..3583cc7 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act, within, waitFor } from "@testing-library/react"; import { App } from "../src/App"; import type { PacketSummary, PacketDetail } from "../src/types/api"; @@ -29,18 +29,20 @@ vi.mock("../src/api/client", () => ({ getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), getIatas: async () => [], getScopes: async () => [], - getChannels: async () => [], + getChannels: async () => ({ items: [], nextCursor: null, hasMore: false }), getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), })); +vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () =>

Observer dashboard

})); + const packet: PacketSummary = { - packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + packetHash: "aa11", payloadType: 1, payloadTypeName: "ADVERT", routeType: 1, routeTypeName: "FLOOD", firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 1, }; const detail = { - packetHash: "AA11", + packetHash: "aa11", header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 1, rawPayload: "", decrypted: false, @@ -70,7 +72,7 @@ vi.mock("../src/features/packets/usePackets", () => ({ vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ - data: hash === "AA11" ? detail : undefined, + data: hash === "aa11" ? detail : hash === "bb22" ? { ...detail, packetHash: "bb22" } : undefined, isLoading: false, isError: false, refetch: () => {}, @@ -136,8 +138,55 @@ afterEach(() => { }); describe("Packets deep links", () => { + it("keeps the packet drawer beside the observer dashboard and returns to the same packet, report and filters", async () => { + setMobile(false); + window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1&observation=1&iata=YOW&q=missing"); + render(); + const section = await screen.findByRole("region", { name: "Observations" }); + expect(within(section).getByText("No path entries").closest("details")).toHaveAttribute("open"); + fireEvent.click(within(section).getByRole("button", { name: "Inspect observer" })); + expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("1"); + expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + act(() => window.history.back()); + await waitFor(() => expect(new URLSearchParams(window.location.search).get("tab")).toBe("Packets")); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + const params = new URLSearchParams(window.location.search); + expect(params.get("hash")).toBe("aa11"); expect(params.get("observation")).toBe("1"); + expect(params.get("iata")).toBe("YOW"); expect(params.get("q")).toBe("missing"); + }); + it.each([false, true])("opens an unloaded selection explicitly and preserves region and filters (mobile=%s)", async (mobile) => { + setMobile(mobile); + window.history.pushState({}, "", "/?tab=Packets&hash=bb22&iata=YOW&q=missing"); + render(); + const notice = await screen.findByRole("region", { name: "Selected packet" }); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + fireEvent.click(within(notice).getByRole("button", { name: "Open analyzer" })); + expect(screen.getByTestId("packet-analyzer-drawer")).toHaveTextContent("BB22"); + expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument(); + const params = new URLSearchParams(window.location.search); + expect(params.get("analyze")).toBe("1"); + expect(params.get("hash")).toBe("bb22"); + expect(params.get("iata")).toBe("YOW"); + expect(params.get("q")).toBe("missing"); + fireEvent.click(screen.getByRole("button", { name: "Close analyzer" })); + expect(screen.getByRole("region", { name: "Selected packet" })).toBeInTheDocument(); + }); + + it("updates the selection notice on URL navigation", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=bb22"); + render(); + expect(await screen.findByRole("region", { name: "Selected packet" })).toBeInTheDocument(); + act(() => { + window.history.pushState({}, "", "/?tab=Packets&hash=aa11"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument(); + expect(screen.getByTestId("packet-expansion")).toBeInTheDocument(); + }); + it("restores the expanded row and the drawer from ?hash&analyze=1", async () => { - window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1"); render(); expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); @@ -145,7 +194,7 @@ describe("Packets deep links", () => { }); it("expands the row without the drawer from ?hash alone", async () => { - window.history.pushState({}, "", "/?tab=Packets&hash=AA11"); + window.history.pushState({}, "", "/?tab=Packets&hash=aa11"); render(); expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); @@ -156,7 +205,7 @@ describe("Packets deep links", () => { window.history.pushState({}, "", "/?tab=Packets&analyze=1"); render(); - expect(await screen.findByRole("button", { name: "AA11" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "aa11" })).toBeInTheDocument(); expect(screen.queryByTestId("packet-expansion")).not.toBeInTheDocument(); expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); }); @@ -167,7 +216,7 @@ describe("leaving the Packets tab", () => { // to drop ?analyze or the analyzer covers the tab the user just asked for. it("closes the analyzer on mobile", async () => { setMobile(true); - window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1"); render(); expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); @@ -178,7 +227,7 @@ describe("leaving the Packets tab", () => { it("keeps the analyzer open on desktop", async () => { setMobile(false); - window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1"); render(); expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); diff --git a/tests/App.pathLinkRestore.test.tsx b/tests/App.pathLinkRestore.test.tsx index 787a4fe..d00dd38 100644 --- a/tests/App.pathLinkRestore.test.tsx +++ b/tests/App.pathLinkRestore.test.tsx @@ -20,6 +20,13 @@ beforeEach(() => getPacketDetail.mockReset()); const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; describe("PathLinkRestore", () => { + it("never restores a different packet while the shared packet is still loading", async () => { + getPacketDetail.mockResolvedValue(detail); + const onRestore = vi.fn(); + render(, { wrapper }); + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha")); + expect(onRestore).toHaveBeenCalledTimes(1); + }); // Regression: PacketPathMapModal's Copy Link strips ?analyze, so a copied path link carries ?hash // without it — the popup can't rely on the analyzer drawer's fetch and needs its own. it("restores the path popup from ?hash&?path alone, with no ?analyze", async () => { diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx new file mode 100644 index 0000000..cfcd911 --- /dev/null +++ b/tests/App.quickObserver.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; +import { ModalOverlay } from "../src/components/ModalOverlay"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <> })); +vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () =>

Observer dashboard

})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzerOverlay: ({ detail, inactive, onClose, onViewObserver }: { detail?: { packetHash: string }; inactive?: boolean; onClose: () => void; onViewObserver: (id: string) => void }) =>

{detail?.packetHash}

})); + +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onViewObserver }: { detail?: { packetHash: string }; onViewObserver: (id: string) => void }) => <>

{detail?.packetHash}

})); + +beforeEach(() => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("inspecting an observer from a window", () => { + it("leaves the node window for the observer dashboard", async () => { + render(); + fireEvent.click(screen.getByText("Node observer")); + expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).get("observer")).toBe("observer"); + }); +}); diff --git a/tests/App.viewOnMap.test.tsx b/tests/App.viewOnMap.test.tsx new file mode 100644 index 0000000..12e90f3 --- /dev/null +++ b/tests/App.viewOnMap.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; + +// any on*/subscribe call the Packets tab makes gets a no-op unsubscribe +vi.mock("../src/api/ws-manager", () => ({ + WsManager: class { + constructor() { + return new Proxy(this, { get: (target, key) => (key in target ? target[key as keyof typeof target] : () => () => {}) }); + } + connect() {} + disconnect() {} + updateSubscription() {} + }, +})); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ + AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => ( + <> + + + {children} + + ), +})); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/map/MapView", () => ({ MapView: () =>

Map view

})); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ + NodeDetailPanel: ({ nodeId, onViewOnMap }: { nodeId: string; onViewOnMap?: (lat: number, lng: number) => void }) => ( +
+ {onViewOnMap && } +
+ ), +})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }), +})); +vi.mock("../src/features/packets/PacketTable", () => ({ PacketTable: () => null })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ + PacketAnalyzerDrawer: ({ onViewNode }: { onViewNode: (id: string) => void }) => , +})); + +const params = () => new URLSearchParams(window.location.search); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("View on map", () => { + it("frames the node on the Map tab and keeps its panel open there", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(params().get("tab")).toBe("Map"); + expect(params().get("lat")).toBe("45.42153"); + expect(params().get("lng")).toBe("-75.69719"); + expect(params().get("zoom")).toBe("14"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + }); + + it("is not offered while the map is already showing", async () => { + window.history.replaceState({}, "", "/?tab=Map&node=node-a"); + render(); + await screen.findByText("Map view"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("drops the one-off map framing when leaving the Map tab", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + await screen.findByText("Map view"); + fireEvent.click(screen.getByText("Nodes tab")); + expect(params().get("tab")).toBe("Nodes"); + expect(params().has("lat")).toBe(false); + expect(params().has("lng")).toBe(false); + expect(params().has("zoom")).toBe(false); + }); + + it("closes stacked investigation panels and selects that node on the map", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=abcd&analyze=1"); + render(); + fireEvent.click(screen.getByText("Hop node")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Node node-b" })).toBeInTheDocument(); + }); +}); diff --git a/tests/App.viewOnMapDisabled.test.tsx b/tests/App.viewOnMapDisabled.test.tsx new file mode 100644 index 0000000..6a4450a --- /dev/null +++ b/tests/App.viewOnMapDisabled.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; + +// any on*/subscribe call the Packets tab makes gets a no-op unsubscribe +vi.mock("../src/api/ws-manager", () => ({ + WsManager: class { + constructor() { + return new Proxy(this, { get: (target, key) => (key in target ? target[key as keyof typeof target] : () => () => {}) }); + } + connect() {} + disconnect() {} + updateSubscription() {} + }, +})); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] })); +vi.mock("../src/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ENABLED_TABS: actual.ENABLED_TABS.filter((tab) => tab !== "Map") }; +}); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ + AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => ( + <> + + + {children} + + ), +})); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/map/MapView", () => ({ MapView: () =>

Map view

})); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ + NodeDetailPanel: ({ nodeId, onViewOnMap }: { nodeId: string; onViewOnMap?: (lat: number, lng: number) => void }) => ( +
+ {onViewOnMap && } +
+ ), +})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }), +})); +vi.mock("../src/features/packets/PacketTable", () => ({ PacketTable: () => null })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ + PacketAnalyzerDrawer: ({ onViewNode }: { onViewNode: (id: string) => void }) => , +})); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("View on map with the Map tab disabled", () => { + it("is not offered", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + expect(await screen.findByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts new file mode 100644 index 0000000..12107fa --- /dev/null +++ b/tests/api/analytics-client.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getStatsObservations, getStatsScopes, getStatsSeries, getTopNodes, getSignalStats, getPathStats } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("sends path window bounds, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getPathStats(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/paths"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + +it("forwards region and cancellation to both aggregate endpoints, omitting an empty global filter", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getStatsScopes(["YVR", "YOW"], 5678, controller.signal); + const scopesUrl = new URL(fetcher.mock.calls[0]![0] as string); + expect(scopesUrl.searchParams.get("iatas")).toBe("YVR,YOW"); + expect(scopesUrl.searchParams.get("since")).toBe("5678"); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); + await getStatsScopes([], 5678); + expect(new URL(fetcher.mock.calls[1]![0] as string).searchParams.has("iatas")).toBe(false); + await getStatsObservations(["YOW"], 1234, controller.signal); + const url = new URL(fetcher.mock.calls[2]![0] as string); + expect(url.pathname).toContain("/stats/observations"); + expect(url.searchParams.get("since")).toBe("1234"); + expect(url.searchParams.get("iatas")).toBe("YOW"); +}); + +it("sends explicit signal window bounds, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getSignalStats(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/signal"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + +it("sends the series window, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getStatsSeries(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/series"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + +it("windows top nodes by since", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); + vi.stubGlobal("fetch", fetcher); + await getTopNodes(["YVR"], 1234, 10); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/top-nodes"); + expect(Object.fromEntries(url.searchParams)).toEqual({ iatas: "YVR", since: "1234", limit: "10" }); +}); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 207a36d..fac5983 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client"; import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; import type { KnownRoute, TraceTagSummary, TraceDetail } from "../../src/types/api"; +import { getRateLimitedUntil, noteRequestOk } from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS } from "../../src/lib/constants"; // Capture the URL the client fetches and hand back a canned CursorPage. function mockFetchOnce(body: unknown): () => string { @@ -21,6 +23,8 @@ function mockFetchOnce(body: unknown): () => string { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); + noteRequestOk(); }); describe("getPackets", () => { @@ -125,6 +129,14 @@ describe("getScopes", () => { expect(url).not.toContain("?"); // no query params on the authoritative list expect(scopes).toEqual(["#bc", "#west"]); }); + + it("asks for the selected region's scopes by IATA", async () => { + const getUrl = mockFetchOnce(["#yow"]); + + await getScopes(["YOW", "YYZ"]); + + expect(new URL(getUrl()).searchParams.get("iatas")).toBe("YOW,YYZ"); + }); }); describe("getKnownRoutesPage", () => { @@ -137,16 +149,17 @@ describe("getKnownRoutesPage", () => { lastSeen: 2, }; - it("hits /routes, forwards iata/hopCount/cursor/limit", async () => { + it("hits /routes, forwards iata/hopCount/cursor/cursorId/limit", async () => { const getUrl = mockFetchOnce([route]); - await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: 1234, limit: 50 }); + await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: { lastSeen: 1234, id: 7 }, limit: 50 }); const url = getUrl(); expect(url).toContain("/routes"); expect(url).toContain("iata=YYC"); expect(url).toContain("hopCount=1"); expect(url).toContain("cursor=1234"); + expect(url).toContain("cursorId=7"); expect(url).toContain("limit=50"); }); @@ -163,15 +176,15 @@ describe("getKnownRoutesPage", () => { expect(url).not.toContain("hopCount="); }); - it("wraps a full page: nextCursor is the last route's lastSeen", async () => { + it("wraps a full page: nextCursor is the last route's lastSeen and id", async () => { mockFetchOnce([{ ...route, lastSeen: 9 }]); - // a page that fills the limit means there may be more — cursor = last (oldest) lastSeen + // a page that fills the limit means there may be more; the id breaks lastSeen ties const page = await getKnownRoutesPage({ limit: 1 }); expect(page.items).toHaveLength(1); expect(page.hasMore).toBe(true); - expect(page.nextCursor).toBe(9); + expect(page.nextCursor).toEqual({ lastSeen: 9, id: 7 }); }); it("wraps a short page: nextCursor null, hasMore false", async () => { @@ -200,6 +213,14 @@ describe("searchKnownRoutes", () => { }); describe("getChannels", () => { + it.each([1234, "v1:1700000000000123:50"])("sends cursor %s using the compatible parameter", async (cursor) => { + const getUrl = mockFetchOnce({ items: [], hasMore: false }); + await getChannels({ cursor }); + const params = new URL(getUrl()).searchParams; + expect(params.get(typeof cursor === "string" ? "pageCursor" : "cursor")).toBe(String(cursor)); + expect(params.has(typeof cursor === "string" ? "cursor" : "pageCursor")).toBe(false); + }); + const channel: ChannelSummary = { id: 1, name: "Public", @@ -210,7 +231,8 @@ describe("getChannels", () => { }; it("sends a single-IATA region as the singular iata param the server honors", async () => { - const getUrl = mockFetchOnce({ items: [channel] }); + const page = { items: [channel], nextCursor: 900, hasMore: true }; + const getUrl = mockFetchOnce(page); const channels = await getChannels({ iatas: ["YYZ"] }); @@ -218,7 +240,7 @@ describe("getChannels", () => { expect(url.pathname).toContain("/channels"); expect(url.searchParams.get("iata")).toBe("YYZ"); expect(url.searchParams.has("iatas")).toBe(false); - expect(channels).toEqual([channel]); + expect(channels).toEqual(page); }); it("keeps the comma-joined iatas param for multi-IATA regions", async () => { @@ -231,6 +253,15 @@ describe("getChannels", () => { expect(url.searchParams.has("iata")).toBe(false); }); + it.each([true, false])("sends keyKnown=%s and omits it when unset", async (keyKnown) => { + const getUrl = mockFetchOnce({ items: [] }); + await getChannels({ keyKnown }); + expect(new URL(getUrl()).searchParams.get("keyKnown")).toBe(String(keyKnown)); + const getUnset = mockFetchOnce({ items: [] }); + await getChannels(); + expect(new URL(getUnset()).searchParams.has("keyKnown")).toBe(false); + }); + it("omits both iata params for all regions", async () => { const getUrl = mockFetchOnce({ items: [] }); @@ -372,12 +403,12 @@ describe("getObserversPage", () => { describe("stats endpoints", () => { it("joins the region's IATAs into the iatas param", async () => { - const getUrl = mockFetchOnce({ totalPackets: 0 }); + const getUrl = mockFetchOnce({ hours: [] }); - await getStatsOverview(["YOW", "YYZ"]); + await getStatsSeries(0, 1000, ["YOW", "YYZ"]); const url = new URL(getUrl()); - expect(url.pathname).toContain("/stats/overview"); + expect(url.pathname).toContain("/stats/series"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); @@ -426,6 +457,16 @@ describe("stats endpoints", () => { expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); + it("sends an empty region as region= so the server returns zeros instead of every IATA", async () => { + const getUrl = mockFetchOnce([]); + + await getStatsNodeTypes({ region: "empty-region" }); + + const url = new URL(getUrl()); + expect(url.searchParams.get("region")).toBe("empty-region"); + expect(url.searchParams.has("iatas")).toBe(false); + }); + it("hits /stats/clock-drift with iatas/limit", async () => { const getUrl = mockFetchOnce([]); @@ -449,6 +490,8 @@ describe("getIataBorder", () => { return { ok: status >= 200 && status < 300, status, + statusText: "status", + headers: new Headers(), json: async () => { if (status === 204) throw new Error("no body to parse"); return body; @@ -485,4 +528,121 @@ describe("getIataBorder", () => { mockStatus(200, feature); await expect(getIataBorder("YOW")).resolves.toEqual(feature); }); + + it("surfaces a 429 with the same ApiError shape as request()", async () => { + mockStatus(429, { error: { code: "rate_limited", message: "slow down" } }); + const err = await getIataBorder("YOW").catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited" }); + }); +}); + +describe("error responses", () => { + function mockError(status: number, body: unknown, headers: Record = {}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status, + statusText: "status", + headers: new Headers(headers), + json: async () => body, + }) as unknown as Response), + ); + } + const limited = { error: { code: "rate_limited", message: "slow down" } }; + + it("a 429 throws an ApiError carrying status, code and retryAfterMs from the header", async () => { + mockError(429, limited, { "Retry-After": "12" }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited", retryAfterMs: 12_000 }); + }); + + it("a 429 without Retry-After leaves retryAfterMs undefined", async () => { + mockError(429, limited); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 429, retryAfterMs: undefined }); + }); + + it("a 429 marks the app rate-limited for Retry-After", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited, { "Retry-After": "12" }); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_012_000); + }); + + it("a 429 without the header uses the default window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("the next successful request clears the rate-limited state", async () => { + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).not.toBeNull(); + + mockFetchOnce([]); + await getScopes(); + expect(getRateLimitedUntil()).toBeNull(); + }); + + it("a plain 500 is not treated as rate limiting", async () => { + mockError(500, { error: { code: "internal", message: "boom" } }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 500, code: "internal" }); + expect(getRateLimitedUntil()).toBeNull(); + }); +}); + +describe("getObserverActivity", () => { + it("requests the observer's heard-activity series for a range and bucket interval", async () => { + const getUrl = mockFetchOnce({ range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] }); + + await getObserverActivity("obs-1", "24h", "15m"); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/observers/obs-1/activity"); + expect(url.searchParams.get("range")).toBe("24h"); + expect(url.searchParams.get("interval")).toBe("15m"); + }); +}); + +describe("isNotFound", () => { + it("is true only for a 404 from the API", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + headers: new Headers(), + json: async () => ({ error: { code: "not_found", message: "no such observer" } }), + })), + ); + + const err = await getObserverActivity("obs-1", "24h", "15m").catch((e: unknown) => e); + + expect(isNotFound(err)).toBe(true); + expect(isNotFound(new Error("network down"))).toBe(false); + }); +}); + +describe("a region with no IATAs", () => { + it("returns empty results without asking the server, which would answer for every IATA", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const empty = { items: [], nextCursor: null, hasMore: false }; + + await expect(getPackets([])).resolves.toEqual(empty); + await expect(getNodesPage([])).resolves.toEqual(empty); + await expect(getObserversPage([])).resolves.toEqual(empty); + await expect(getChannels({ iatas: [] })).resolves.toEqual(empty); + await expect(getChannelMessagesPage(1, { iatas: [] })).resolves.toEqual(empty); + await expect(getTraces([])).resolves.toEqual([]); + await expect(getScopes([])).resolves.toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts new file mode 100644 index 0000000..539c7eb --- /dev/null +++ b/tests/api/observer-comparison.test.ts @@ -0,0 +1,35 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getObserverComparison, getObserverActivity, getObserver } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("sends the complete comparison scope, including an epoch-zero start, and consumes cancellation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ totalPackets: 0 }) }); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getObserverComparison(["YVR", "YYJ"], { observerA: "a", observerB: "b", since: 0, until: 1000 }, controller.signal); + const [url, options] = fetcher.mock.calls[0]; + expect(new URL(url).pathname).toContain("/stats/observer-comparison"); + expect(Object.fromEntries(new URL(url).searchParams)).toEqual({ observerA: "a", observerB: "b", since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(options.signal).toBe(controller.signal); + controller.abort(); + expect(options.signal.aborted).toBe(true); +}); + +it("sends the shared activity anchor without altering the selected range", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ points: [] }) }); + vi.stubGlobal("fetch", fetcher); + await getObserverActivity("a", "168h", "1h", 123456789); + expect(Object.fromEntries(new URL(fetcher.mock.calls[0][0]).searchParams)).toEqual({ range: "168h", interval: "1h", until: "123456789" }); +}); + +it.each([ { stats: { noise_floor: -117 } }, btoa('{"stats":{"noise_floor":-117}}') ])("reads both object and legacy byte-encoded status metadata", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + expect((await getObserver("a")).statusMetadata).toEqual({ stats: { noise_floor: -117 } }); +}); + +it.each(["not base64!", btoa('null'), btoa('[]')])("ignores malformed metadata without losing the observer", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + const observer = await getObserver("a"); + expect(observer.id).toBe("a"); expect(observer.statusMetadata).toBeUndefined(); +}); diff --git a/tests/api/rate-limit.test.ts b/tests/api/rate-limit.test.ts new file mode 100644 index 0000000..52160fe --- /dev/null +++ b/tests/api/rate-limit.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseRetryAfter, + shouldRetryQuery, + noteRateLimited, + noteRequestOk, + getRateLimitedUntil, + isRateLimited, + subscribeRateLimit, +} from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../../src/lib/constants"; + +afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); +}); + +describe("parseRetryAfter", () => { + it("reads delta-seconds as milliseconds", () => { + expect(parseRetryAfter("7")).toBe(7_000); + }); + + it("reads an HTTP-date relative to now", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T12:00:00Z")); + expect(parseRetryAfter("Wed, 02 Sep 2026 12:00:30 GMT")).toBe(30_000); + }); + + it("returns undefined for a missing or junk header", () => { + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter("soon")).toBeUndefined(); + }); + + it("clamps absurd values to the ceiling", () => { + expect(parseRetryAfter("9999")).toBe(RATE_LIMIT_MAX_MS); + }); +}); + +describe("shouldRetryQuery", () => { + it("never retries a 429", () => { + expect(shouldRetryQuery(0, { status: 429 })).toBe(false); + }); + + it("never retries a 404", () => { + expect(shouldRetryQuery(0, { status: 404 })).toBe(false); + }); + + it("retries a 5xx twice then stops", () => { + expect(shouldRetryQuery(0, { status: 500 })).toBe(true); + expect(shouldRetryQuery(1, { status: 500 })).toBe(true); + expect(shouldRetryQuery(2, { status: 500 })).toBe(false); + }); + + it("retries a network error", () => { + expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); + }); +}); + +describe("rate-limit store", () => { + it("starts un-limited", () => { + expect(getRateLimitedUntil()).toBeNull(); + expect(isRateLimited()).toBe(false); + }); + + it("noteRateLimited opens the window and notifies subscribers", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRateLimited(5_000); + + expect(getRateLimitedUntil()).toBe(1_005_000); + expect(isRateLimited()).toBe(true); + expect(cb).toHaveBeenCalledOnce(); + unsub(); + }); + + it("uses the default window when no Retry-After was given", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + noteRateLimited(); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("is no longer limited once the window elapses", () => { + vi.useFakeTimers(); + noteRateLimited(5_000); + vi.advanceTimersByTime(5_000); + expect(isRateLimited()).toBe(false); + }); + + it("a longer window extends, a shorter one does not shrink", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + noteRateLimited(5_000); + noteRateLimited(10_000); + expect(getRateLimitedUntil()).toBe(10_000); + noteRateLimited(2_000); + expect(getRateLimitedUntil()).toBe(10_000); + }); + + it("noteRequestOk clears and notifies, and stays quiet when nothing was set", () => { + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRequestOk(); + expect(cb).not.toHaveBeenCalled(); + + noteRateLimited(5_000); + noteRequestOk(); + expect(getRateLimitedUntil()).toBeNull(); + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + }); +}); diff --git a/tests/api/route-evidence.test.ts b/tests/api/route-evidence.test.ts new file mode 100644 index 0000000..10de581 --- /dev/null +++ b/tests/api/route-evidence.test.ts @@ -0,0 +1,16 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getRouteEvidence } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); +it("encodes route identity and preserves the opaque cursor without numeric truncation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [], hasMore: false }) }); + vi.stubGlobal("fetch", fetcher); + const cursor = "v1:YOW:abcdef:1:2:1234567890000001:9007199254740993"; + const signal = new AbortController().signal; + await getRouteEvidence("Y/W", "abc/def", { pageCursor: cursor, limit: 50 }, signal); + const url = new URL(fetcher.mock.calls[0][0]); + expect(url.pathname).toContain("/routes/Y%2FW/abc%2Fdef/observations"); + expect(url.searchParams.get("pageCursor")).toBe(cursor); + expect(url.searchParams.has("range")).toBe(false); + expect(fetcher.mock.calls[0][1].signal).toBe(signal); +}); diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index 913b62c..9c1586c 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -183,6 +183,21 @@ describe("WsManager", () => { ).not.toThrow(); }); + it("drops the subscription without resubscribing for a region with no IATAs", () => { + 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" }); + ws.simulateMessage({ v: 1, type: "subscribed", id: "sub-1", subscriptionId: "s-1" }); + + mgr.updateSubscription({ iatas: [] }); + + const types = ws.sent.slice(1).map((m) => JSON.parse(m).type); + expect(types).toEqual(["unsubscribe"]); + }); + it("updates subscription without reconnecting", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); @@ -305,6 +320,24 @@ describe("WsManager", () => { expect(unsubs2[1].subscriptionId).toBe("s-yow"); }); + it("drops a pending subscribe's late ack after switching to a region with no IATAs", () => { + 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: 1, connectionId: "c1" }); + mgr.updateSubscription({ iatas: [] }); + + const sent = () => ws.sent.map((s) => JSON.parse(s)); + const [sub] = sent().filter((m) => m.type === "subscribe"); + ws.simulateMessage({ v: 1, type: "subscribed", id: sub.id, subscriptionId: "s-yow" }); + + const unsubs = sent().filter((m) => m.type === "unsubscribe"); + expect(unsubs).toHaveLength(1); + expect(unsubs[0].subscriptionId).toBe("s-yow"); + }); + it("ignores close events from a torn-down socket (no reconnect treadmill)", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); @@ -345,6 +378,48 @@ describe("WsManager", () => { expect(MockWebSocket.instances.length).toBeGreaterThan(1); }); + it("pings a fresh connection after a long outage instead of dropping it", () => { + 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: 1, connectionId: "c1" }); + + // the socket dies and stays down for longer than the liveness window + ws1.simulateClose(1006); + vi.setSystemTime(Date.now() + 120_000); + vi.advanceTimersByTime(1500); + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" }); + + ws2.sent = []; + vi.advanceTimersByTime(30_000); + expect(MockWebSocket.instances).toHaveLength(2); + expect(ws2.sent.map((s) => JSON.parse(s)).filter((m) => m.type === "ping")).toHaveLength(1); + }); + + it("still reports the pre-outage time as the lag start after a reconnect", () => { + const handler = vi.fn(); + const mgr = new WsManager("ws://test/ws"); + mgr.onLagged(handler); + mgr.connect({ iatas: ["YOW"] }); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" }); + ws1.simulateMessage({ v: 1, type: "pong", id: "p" }); + const lastHeard = Date.now(); + + ws1.simulateClose(1006); + vi.setSystemTime(Date.now() + 120_000); + vi.advanceTimersByTime(1500); + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" }); + + expect(handler.mock.calls[0]![0].since).toBe(lastHeard); + }); + it("clears the stale subscriptionId across reconnects", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); @@ -404,3 +479,72 @@ describe("WsManager", () => { expect(handler).not.toHaveBeenCalled(); }); }); + +describe("WsManager reconnect backoff", () => { + // Math.random = 0.5 zeroes the jitter so delays are exact + function connectOnce() { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + return mgr; + } + + it("keeps escalating when the server accepts then closes at once (no 1 s treadmill)", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + + const ws3 = MockWebSocket.instances[2]!; + ws3.simulateOpen(); + ws3.simulateClose(1006); + vi.advanceTimersByTime(3999); + expect(MockWebSocket.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(4); + }); + + it("resets the backoff after a link that held for WS_STABLE_MS", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + vi.advanceTimersByTime(10_000); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(3); + }); + + it("jumps to and stays at the max backoff on a 1013 shed close", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1013); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + }); +}); diff --git a/tests/browser/header-layout.js b/tests/browser/header-layout.js new file mode 100644 index 0000000..436f9ce --- /dev/null +++ b/tests/browser/header-layout.js @@ -0,0 +1,30 @@ +// Paste into a real browser console at 320, 390, 768 and 1280 CSS pixels, +// with both header pickers closed/open and a long region selection. +// jsdom does not perform layout, so this check intentionally uses browser geometry. +(function checkHeaderLayout() { + const header = document.querySelector("header"); + if (!header) throw new Error("Header is missing"); + const viewport = document.documentElement.clientWidth; + const bounds = header.getBoundingClientRect(); + const controls = header.querySelectorAll("button, a, span, div"); + const panels = new Set(); + const failures = []; + for (const element of controls) { + if (!element.getClientRects().length) continue; + let floating = null; + for (let parent = element; parent && parent !== header; parent = parent.parentElement) { + if (["absolute", "fixed"].includes(getComputedStyle(parent).position)) floating = parent; + } + if (floating) { panels.add(floating); continue; } + const rect = element.getBoundingClientRect(); + if (rect.left < -0.5 || rect.right > viewport + 0.5 || rect.top < bounds.top - 0.5 || rect.bottom > bounds.bottom + 0.5) { + failures.push(element.getAttribute("aria-label") || element.textContent.trim()); + } + } + for (const panel of panels) { + const rect = panel.getBoundingClientRect(); + if (rect.left < -0.5 || rect.right > viewport + 0.5) failures.push("Open picker outside viewport"); + } + if (failures.length) throw new Error(`Header overflows at ${viewport}px: ${failures.join(", ")}`); + return { viewport, headerHeight: bounds.height, openPickers: panels.size, fits: true }; +})(); diff --git a/tests/browser/region-input-zoom.js b/tests/browser/region-input-zoom.js new file mode 100644 index 0000000..1079307 --- /dev/null +++ b/tests/browser/region-input-zoom.js @@ -0,0 +1,13 @@ +// Open REGION, then run in a real browser at phone, tablet and desktop widths. +// jsdom does not load Tailwind's generated CSS; inspect the rendered input instead. +// Also check on iOS Safari: opening, filtering and closing must leave zoom unchanged. +(function checkRegionInputZoom() { + const input = document.querySelector('header input[placeholder="Search region or area code…"]'); + if (!input) throw new Error("Open the region picker first"); + const fontSize = Number.parseFloat(getComputedStyle(input).fontSize); + if (fontSize < 16 || !Number.isFinite(fontSize)) { + throw new Error(`Region filter is ${fontSize}px; small focused inputs can trigger Safari zoom`); + } + if (document.activeElement !== input) throw new Error("Region filter did not receive focus"); + return { viewport: document.documentElement.clientWidth, fontSize, focused: true }; +})(); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index 435e682..ae2c86d 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -1,12 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "../../src/components/AppShell"; import { RegionProvider } from "../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../src/hooks/region-selection"; import { getIatas, getRegions, getRegion } from "../../src/api/client"; import type { WsManager } from "../../src/api/ws-manager"; +import { noteRateLimited, noteRequestOk } from "../../src/api/rate-limit"; import pkg from "../../package.json"; +import i18n from "../../src/i18n"; vi.mock("../../src/api/client", () => ({ getIatas: vi.fn(), @@ -20,12 +22,12 @@ const wsManager = { getLastEventTimestamp: () => Date.now(), } as unknown as WsManager; -function renderShell() { +function renderShell(onTabChange: (tab: string) => void = () => {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - {}} wsManager={wsManager}> +
@@ -40,6 +42,24 @@ beforeEach(() => { }); describe("AppShell", () => { + it("switches navigation to French while preserving tab identifiers and the active view", async () => { + vi.mocked(getIatas).mockResolvedValue([]); + const onTabChange = vi.fn(); + renderShell(onTabChange); + + fireEvent.click(screen.getByRole("button", { name: "Language: English" })); + fireEvent.click(screen.getByRole("button", { name: "Français", exact: true })); + await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false")); + expect(screen.getAllByRole("tab", { name: "Paquets" })[0]).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("EN DIRECT")).toBeInTheDocument(); + expect(document.documentElement.lang).toBe("fr"); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + expect(onTabChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getAllByRole("tab", { name: "Carte" })[0]!); + expect(onTabChange).toHaveBeenCalledWith("Map"); + }); + it("footer shows the package.json version", () => { vi.mocked(getIatas).mockResolvedValue([]); renderShell(); @@ -49,7 +69,8 @@ describe("AppShell", () => { it("region picker shows an error state when the IATA list fails to load", async () => { vi.mocked(getIatas).mockRejectedValue(new Error("boom")); renderShell(); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + expect(screen.getByText("Region")).toHaveClass("uppercase"); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); await waitFor(() => expect(screen.getByText("Failed to load")).toBeInTheDocument()); expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); @@ -73,11 +94,11 @@ const REGIONS = [ // Opens the picker and returns the filter input, once both region and IATA lists have landed. // The trigger is focused first because a real browser click focuses the button; jsdom's does not. async function openPicker() { - const trigger = screen.getByRole("button", { name: /REGION/ }); + const trigger = screen.getByRole("button", { name: /Region/ }); trigger.focus(); fireEvent.click(trigger); await waitFor(() => expect(screen.getByText("Western Canada")).toBeInTheDocument()); - return screen.getByPlaceholderText(/Filter/); + return screen.getByPlaceholderText(/Search region or area code/); } describe("region picker filter", () => { @@ -87,6 +108,22 @@ describe("region picker filter", () => { vi.mocked(getRegion).mockImplementation(async (id: number) => REGIONS.find((r) => r.id === id)!); }); + it("searches the translated all-regions label and retains raw IATA values", async () => { + renderShell(); + fireEvent.click(screen.getByRole("button", { name: "Language: English" })); + fireEvent.click(screen.getByRole("button", { name: "Français", exact: true })); + fireEvent.click(await screen.findByRole("button", { name: /Région/ })); + await screen.findByText("Western Canada"); + const input = screen.getByRole("textbox", { name: "Rechercher une région ou un code de zone…" }); + fireEvent.change(input, { target: { value: "toutes" } }); + expect(screen.getByText("Toutes les régions")).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "yvr" } }); + expect(screen.queryByText("Toutes les régions")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Vancouver International/ })); + expect(screen.getByRole("button", { name: /Région\s*YVR/ })).toBeInTheDocument(); + }); + it("focuses the filter input when the picker opens", async () => { renderShell(); const input = await openPicker(); @@ -99,7 +136,7 @@ describe("region picker filter", () => { expect(input).toHaveFocus(); fireEvent.keyDown(input, { key: "Escape" }); // empty query, so this closes - expect(screen.getByRole("button", { name: /REGION/ })).toHaveFocus(); + expect(screen.getByRole("button", { name: /Region/ })).toHaveFocus(); }); it("narrows the IATA list by code, ignoring surrounding whitespace", async () => { @@ -123,7 +160,7 @@ describe("region picker filter", () => { // no region name or member code contains "toronto" expect(screen.queryByText("Regions")).not.toBeInTheDocument(); // nothing above it, so the header must not draw a divider - expect(screen.getByText("IATA")).not.toHaveClass("border-t"); + expect(screen.getByText("Areas")).not.toHaveClass("border-t"); }); it("surfaces a region whose member IATA matches, tagged with the matching code", async () => { @@ -135,7 +172,7 @@ describe("region picker filter", () => { expect(screen.getByText("· YVR")).toBeInTheDocument(); expect(screen.queryByText("Eastern Canada")).not.toBeInTheDocument(); // something is above it now, so the header does draw a divider - expect(screen.getByText("IATA")).toHaveClass("border-t"); + expect(screen.getByText("Areas")).toHaveClass("border-t"); }); it("tags a code match but not a name match, for the same query", async () => { @@ -156,7 +193,7 @@ describe("region picker filter", () => { expect(screen.getByText("Western Canada")).toBeInTheDocument(); expect(screen.queryByText("· YVR")).not.toBeInTheDocument(); // nothing in the IATA group matches, so its header goes too - expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + expect(screen.queryByText("Areas")).not.toBeInTheDocument(); }); it("keeps an IATA with no display name matchable by code", async () => { @@ -190,7 +227,7 @@ describe("region picker filter", () => { expect(screen.getByText("No matches")).toBeInTheDocument(); expect(screen.queryByText("Regions")).not.toBeInTheDocument(); - expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + expect(screen.queryByText("Areas")).not.toBeInTheDocument(); expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); }); @@ -200,10 +237,10 @@ describe("region picker filter", () => { fireEvent.change(input, { target: { value: "yvr" } }); fireEvent.keyDown(input, { key: "Escape" }); - expect(screen.getByPlaceholderText(/Filter/)).toHaveValue(""); + expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue(""); expect(screen.getByText("All Regions")).toBeInTheDocument(); - fireEvent.keyDown(screen.getByPlaceholderText(/Filter/), { key: "Escape" }); + fireEvent.keyDown(screen.getByPlaceholderText(/Search region or area code/), { key: "Escape" }); expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); }); @@ -212,10 +249,80 @@ describe("region picker filter", () => { const input = await openPicker(); fireEvent.change(input, { target: { value: "yvr" } }); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); - await waitFor(() => expect(screen.getByPlaceholderText(/Filter/)).toHaveValue("")); + await waitFor(() => expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue("")); expect(screen.getByText("Eastern Canada")).toBeInTheDocument(); }); }); + +describe("rate limit badge", () => { + beforeEach(() => { + vi.mocked(getIatas).mockResolvedValue([]); + vi.useFakeTimers(); + }); + + afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); + }); + + it("is hidden by default", () => { + renderShell(); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("shows a countdown after a 429 and hides once Retry-After elapses", () => { + renderShell(); + + act(() => noteRateLimited(5_000)); + expect(screen.getByText(/RATE LIMITED 5s/)).toHaveAttribute("role", "status"); + + act(() => vi.advanceTimersByTime(2_000)); + expect(screen.getByText(/RATE LIMITED 3s/)).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(3_000)); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("clears on the next successful request", () => { + renderShell(); + + act(() => noteRateLimited(30_000)); + expect(screen.getByText(/RATE LIMITED/)).toBeInTheDocument(); + + act(() => noteRequestOk()); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); +}); + +describe("AppShell region picker alignment", () => { + it("opens rightward only below md so the desktop row's controls stay visible", async () => { + vi.mocked(getIatas).mockResolvedValue([]); + renderShell(); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); + const panel = screen.getByPlaceholderText("Search region or area code…").closest(".absolute")!; + expect(panel.className).toContain("left-0"); + expect(panel.className).toContain("md:right-0"); + }); +}); + +describe("AppShell on a phone", () => { + afterEach(() => vi.restoreAllMocks()); + it("keeps the header to one row and folds theme, language and GitHub into a settings menu", async () => { + const media = window.matchMedia("(max-width: 767px)"); + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ ...media, media: query, matches: query === "(max-width: 767px)" })); + vi.mocked(getIatas).mockResolvedValue([]); + renderShell(); + expect(screen.getByRole("button", { name: /Region/ })).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "LIVE" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Language:/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "GitHub" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("link", { name: "GitHub" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Français" })); + await waitFor(() => expect(document.documentElement.lang).toBe("fr")); + await act(() => i18n.changeLanguage("en")); + }); +}); diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index 3ae3aed..be2c039 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -1,8 +1,21 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { BottomNav } from "../../src/components/BottomNav"; +import i18n from "../../src/i18n"; describe("BottomNav", () => { + it("translates an open More sheet without changing the selected tab identifier", async () => { + const onTabChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("More")); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("menu", { name: "Autres onglets" })).toBeInTheDocument(); + expect(screen.getByText("Plus").closest("button")).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Analyses" })); + expect(onTabChange).toHaveBeenCalledWith("Analytics"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + it("marks the active primary tab with aria-selected", () => { render( {}} />); expect(screen.getByRole("tab", { name: "Map" })).toHaveAttribute("aria-selected", "true"); diff --git a/tests/components/CopyButton.test.tsx b/tests/components/CopyButton.test.tsx index 3d5fb4a..13a1c5e 100644 --- a/tests/components/CopyButton.test.tsx +++ b/tests/components/CopyButton.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { CopyButton } from "../../src/components/CopyButton"; +import i18n from "../../src/i18n"; const writeText = vi.fn(); @@ -26,12 +27,13 @@ describe("CopyButton", () => { expect(writeText).toHaveBeenCalledWith(key); }); - it("swaps to 'Copied' after clicking, then reverts", () => { + it("swaps to 'Copied' after clicking, then reverts", async () => { vi.useFakeTimers(); try { render(); const button = screen.getByRole("button"); fireEvent.click(button); + await act(async () => {}); expect(button).toHaveTextContent("Copied"); act(() => { vi.advanceTimersByTime(1500); @@ -46,4 +48,34 @@ describe("CopyButton", () => { render(); expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument(); }); + + it("defaults to French labels in French", async () => { + await i18n.changeLanguage("fr"); + render(); + const button = screen.getByRole("button", { name: "Copier" }); + fireEvent.click(button); + await act(async () => {}); + expect(button).toHaveTextContent("Copié"); + }); + + it("doesn't claim success when the clipboard API is missing (plain http)", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, configurable: true }); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + await act(async () => {}); + expect(button).not.toHaveTextContent("Copied"); + expect(button).toHaveTextContent("Copy failed"); + }); + + it("shows 'Copied' only once the write resolves, and a failure when it rejects", async () => { + let reject!: (e: Error) => void; + writeText.mockReturnValueOnce(new Promise((_, r) => (reject = r))); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).not.toHaveTextContent("Copied"); + await act(async () => reject(new Error("denied"))); + expect(button).toHaveTextContent("Copy failed"); + }); }); diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx index a7fe975..84f319e 100644 --- a/tests/components/CopyLinkButton.test.tsx +++ b/tests/components/CopyLinkButton.test.tsx @@ -18,9 +18,9 @@ afterEach(() => { }); describe("CopyLinkButton", () => { - it("shows the default 'Copy Link' label", () => { + it("shows the default 'Copy link' label", () => { render(); - expect(screen.getByRole("button")).toHaveTextContent("Copy Link"); + expect(screen.getByRole("button")).toHaveTextContent("Copy link"); }); it("copies a URL carrying the given params on click", () => { @@ -32,17 +32,18 @@ describe("CopyLinkButton", () => { expect(copied.searchParams.get("node")).toBe("abc123"); }); - it("swaps to 'Copied' after clicking, then reverts", () => { + it("swaps to 'Copied' after clicking, then reverts", async () => { vi.useFakeTimers(); try { render(); const button = screen.getByRole("button"); fireEvent.click(button); + await act(async () => {}); expect(button).toHaveTextContent("Copied"); act(() => { vi.advanceTimersByTime(1500); }); - expect(button).toHaveTextContent("Copy Link"); + expect(button).toHaveTextContent("Copy link"); } finally { vi.useRealTimers(); } @@ -72,4 +73,33 @@ describe("CopyLinkButton", () => { expect(copied.searchParams.get("keep")).toBe("1"); expect(copied.searchParams.get("tab")).toBe("Map"); }); + + it("doesn't claim success when the clipboard API is missing (plain http)", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, configurable: true }); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + await act(async () => {}); + expect(button).not.toHaveTextContent("Copied"); + expect(button).toHaveTextContent("Copy failed"); + }); + + it("shows 'Copied' only once the write resolves, and a failure when it rejects", async () => { + let reject!: (e: Error) => void; + writeText.mockReturnValueOnce(new Promise((_, r) => (reject = r))); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).not.toHaveTextContent("Copied"); + await act(async () => reject(new Error("denied"))); + expect(button).toHaveTextContent("Copy failed"); + }); +}); + +it("retains only an explicit allowlist when building a focused investigation link", () => { + window.history.replaceState({}, "", "/?regions=ontario&futureFilter=wrong&observer=old&tab=Routes"); + render(); + fireEvent.click(screen.getByRole("button")); + const copied = new URL(writeText.mock.calls[0][0]); + expect(Object.fromEntries(copied.searchParams)).toEqual({ regions: "ontario", tab: "Packets", hash: "abc", path: "all" }); }); diff --git a/tests/components/DataTable.test.tsx b/tests/components/DataTable.test.tsx index 02594c3..382b258 100644 --- a/tests/components/DataTable.test.tsx +++ b/tests/components/DataTable.test.tsx @@ -2,6 +2,31 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { render, fireEvent, screen } from "@testing-library/react"; import { DataTable, type Column } from "../../src/components/DataTable"; +it("keeps a stable column's default sort and focused button when its label changes", () => { + const data = [{ id: "a" }, { id: "b" }]; + const translatedColumns = (header: string): Column[] => [{ id: "identity", header, cell: (r) => r.id, sortValue: (r) => r.id }]; + const view = (header: string) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ id: "identity", direction: "desc" }} />; + const { container, rerender } = render(view("ID")); + const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent); + expect(order()).toEqual(["b", "a"]); + const button = screen.getByRole("button", { name: /^ID\s*▼$/ }); + button.focus(); + rerender(view("Identifiant")); + expect(order()).toEqual(["b", "a"]); + expect(screen.getByRole("button", { name: /^Identifiant\s*▼$/ })).toBe(button); + expect(button).toHaveFocus(); + fireEvent.click(button); + expect(order()).toEqual(["a", "b"]); +}); + +it("retains header-based default and user sorting for callers without column IDs", () => { + const { container } = render( r.id, sortValue: (r) => r.id }]} rows={rows} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ header: "ID", direction: "desc" }} />); + const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent); + expect(order()).toEqual(["b", "a"]); + fireEvent.click(screen.getByRole("button", { name: /^ID\s*▼$/ })); + expect(order()).toEqual(["a", "b"]); +}); + interface Row { id: string; } diff --git a/tests/components/DetailPanel.test.tsx b/tests/components/DetailPanel.test.tsx index dfd6fd2..1b88a03 100644 --- a/tests/components/DetailPanel.test.tsx +++ b/tests/components/DetailPanel.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { DetailPanel } from "../../src/components/DetailPanel"; +import i18n from "../../src/i18n"; function renderPanel(props: { collapsible?: boolean; onClose?: () => void } = {}) { const onClose = props.onClose ?? vi.fn(); @@ -40,4 +41,14 @@ describe("DetailPanel", () => { fireEvent.click(screen.getByRole("button", { name: "Close detail panel" })); expect(onClose).toHaveBeenCalledTimes(1); }); + + it("uses French defaults for its controls and states", async () => { + await i18n.changeLanguage("fr"); + const { rerender } = render(

); + expect(screen.getByRole("button", { name: "Fermer le panneau de détails" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Réduire le panneau de détails" })).toBeTruthy(); + expect(screen.getByText("Chargement…")).toBeTruthy(); + rerender(

); + expect(screen.getByText("Introuvable")).toBeTruthy(); + }); }); diff --git a/tests/components/Dropdown.test.tsx b/tests/components/Dropdown.test.tsx index 48456c3..0b8d6d4 100644 --- a/tests/components/Dropdown.test.tsx +++ b/tests/components/Dropdown.test.tsx @@ -45,3 +45,18 @@ describe("Dropdown", () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe("Dropdown align", () => { + it("left-below-md anchors left on phones and flips to right at md", () => { + render( + }> + {() =>

option
} + , + ); + fireEvent.click(screen.getByText("trigger")); + const panel = screen.getByTestId("dropdown-panel").parentElement!; + expect(panel.className).toContain("left-0"); + expect(panel.className).toContain("md:left-auto"); + expect(panel.className).toContain("md:right-0"); + }); +}); diff --git a/tests/components/ErrorBoundary.test.tsx b/tests/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..80d8a6b --- /dev/null +++ b/tests/components/ErrorBoundary.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "../../src/components/ErrorBoundary"; +import { CloseButton } from "../../src/components/CloseButton"; +import i18n from "../../src/i18n"; + +function Boom({ message }: { message: string }): never { + throw new Error(message); +} + +describe("ErrorBoundary", () => { + it("shows the English fallback", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + render(); + expect(screen.getByText("Something went wrong rendering this view.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "try again" })).toBeInTheDocument(); + }); + + it("translates the fallback into French", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await i18n.changeLanguage("fr"); + render(); + expect(screen.getByText("Une erreur est survenue lors de l’affichage de cette vue.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "recharger" })).toBeInTheDocument(); + }); +}); + +describe("ErrorBoundary recovery", () => { + it("offers a reload for Safari's failed module import", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await i18n.changeLanguage("en"); + render(); + expect(screen.getByRole("button", { name: "reload" })).toBeInTheDocument(); + }); + + it("clears the error when its reset key changes", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await i18n.changeLanguage("en"); + const { rerender } = render(); + rerender(

packets

); + expect(screen.getByText("packets")).toBeInTheDocument(); + }); +}); + +describe("CloseButton", () => { + it("defaults its label per language", async () => { + const { unmount } = render( {}} />); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + unmount(); + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByRole("button", { name: "Fermer" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/FilterBar.mobile.test.tsx b/tests/components/FilterBar.mobile.test.tsx index 0157e82..5919033 100644 --- a/tests/components/FilterBar.mobile.test.tsx +++ b/tests/components/FilterBar.mobile.test.tsx @@ -39,6 +39,16 @@ const baseProps = { }; describe("FilterBar (mobile)", () => { + it.each([true, false])("offers latest-path search on mobile=%s", (mobile) => { + setMobile(mobile); + const onSearchFieldChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Hash/ })); + fireEvent.click(screen.getByRole("button", { name: "Latest path", exact: true })); + expect(onSearchFieldChange).toHaveBeenCalledWith("path"); + expect(screen.queryByRole("button", { name: "Latest path", exact: true })).not.toBeInTheDocument(); + }); + it("hides the inline dropdowns behind a Filters button until opened", () => { setMobile(true); render(); diff --git a/tests/components/FilterBar.test.tsx b/tests/components/FilterBar.test.tsx new file mode 100644 index 0000000..583d143 --- /dev/null +++ b/tests/components/FilterBar.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FilterBar } from "../../src/components/FilterBar"; +import i18n from "../../src/i18n"; + +const noop = () => {}; +const props = { + typeOptions: [{ value: "ADVERT", label: "ADVERT" }], + routeOptions: [{ value: "0", label: "Flood" }], + observerOptions: [{ value: "o1", label: "YVR" }], + scopeOptions: [{ value: "s1", label: "#east" }], + activeTypes: ["ADVERT"], + activeRoutes: [] as string[], + activeObservers: [] as string[], + activeScopes: [] as string[], + onTypesChange: noop, + onRoutesChange: noop, + onObserversChange: noop, + onScopesChange: noop, + search: "", + onSearchChange: noop, + searchField: "hash" as const, + onSearchFieldChange: noop, + onClear: noop, +}; + +describe("FilterBar in French", () => { + it("translates the toolbar, search field, and dropdown controls", async () => { + await i18n.changeLanguage("fr"); + render(); + expect(screen.getByRole("toolbar", { name: "Filtres de paquets" })).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Rechercher par hash…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Effacer" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Observateurs/ })); + expect(screen.getByPlaceholderText("Filtrer…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Tous" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Aucun" })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("Filtrer…"), { target: { value: "zzz" } }); + expect(screen.getByText("Aucun résultat")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^Hash/ })); + expect(screen.getByRole("button", { name: "Dernier trajet" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Charge utile" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Portée/ })).toBeInTheDocument(); + }); + + it("keeps the English placeholder wording", () => { + render(); + expect(screen.getByPlaceholderText("Search by latest path...")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/FilterSheet.test.tsx b/tests/components/FilterSheet.test.tsx index 88227fd..5c97085 100644 --- a/tests/components/FilterSheet.test.tsx +++ b/tests/components/FilterSheet.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { FilterSheet, FiltersButton } from "../../src/components/FilterSheet"; +import i18n from "../../src/i18n"; describe("FiltersButton", () => { it("shows the active count only when there are active filters", () => { @@ -72,3 +73,16 @@ describe("FilterSheet", () => { expect(dialog).toHaveAttribute("aria-modal", "true"); }); }); + +describe("FilterSheet in French", () => { + it("translates the trigger, title and actions", async () => { + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByRole("button", { name: "Filtres" })).toBeInTheDocument(); + render( {}} onClear={() => {}}>
); + expect(screen.getByRole("dialog", { name: "Filtres" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fermer les filtres" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Tout effacer" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Terminé" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/InfoTip.test.tsx b/tests/components/InfoTip.test.tsx new file mode 100644 index 0000000..acd9088 --- /dev/null +++ b/tests/components/InfoTip.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { InfoTip } from "../../src/components/InfoTip"; + +it("keeps the note out of the layout until hovered or focused", () => { + render(

Heading

); + const button = screen.getByRole("button", { name: /First note\. Second note\./ }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + fireEvent.mouseEnter(button.parentElement!); + expect(screen.getByRole("tooltip")).toHaveTextContent("First note.Second note."); + fireEvent.mouseLeave(button.parentElement!); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + fireEvent.focus(button); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); +}); + +it("does not toggle a details section when clicked inside its summary", () => { + render(
More

Body

); + fireEvent.click(screen.getByRole("button", { name: /Note\./ })); + expect(screen.getByRole("group")).not.toHaveAttribute("open"); +}); diff --git a/tests/components/InstanceBanner.test.tsx b/tests/components/InstanceBanner.test.tsx new file mode 100644 index 0000000..d64956e --- /dev/null +++ b/tests/components/InstanceBanner.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InstanceBanner } from "../../src/components/InstanceBanner"; + +describe("InstanceBanner", () => { + it("renders nothing without text", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it("shows the text and links any URL in it", () => { + render(); + const banner = screen.getByRole("note"); + expect(banner).toHaveTextContent("This is the development instance of Beacon, please use live (https://live.meshcore.ca)"); + const link = screen.getByRole("link", { name: "https://live.meshcore.ca" }); + expect(link).toHaveAttribute("href", "https://live.meshcore.ca"); + }); + it("turns [label](url) into a labelled link", () => { + render(); + expect(screen.getByRole("note")).toHaveTextContent("This is the Beacon development instance and may be unstable. For everyday use, visit live.meshcore.ca."); + expect(screen.getByRole("link", { name: "live.meshcore.ca" })).toHaveAttribute("href", "https://live.meshcore.ca"); + }); +}); diff --git a/tests/components/LanguagePicker.test.tsx b/tests/components/LanguagePicker.test.tsx new file mode 100644 index 0000000..2fbb753 --- /dev/null +++ b/tests/components/LanguagePicker.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { LanguagePicker } from "../../src/components/LanguagePicker"; + +describe("LanguagePicker", () => { + it("opens the language choices, selects French and returns focus to the trigger", async () => { + render(); + const trigger = screen.getByRole("button", { name: "Language: English" }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("group", { name: "Language" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "English", exact: true })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Français", exact: true })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveFocus()); + expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + }); + + it("closes with Escape from an option without changing language or losing focus", () => { + render(); + const trigger = screen.getByRole("button", { name: "Language: English" }); + fireEvent.click(trigger); + const french = screen.getByRole("button", { name: "Français", exact: true }); + french.focus(); + fireEvent.keyDown(french, { key: "Escape" }); + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + }); + + it("closes on an outside click without changing the choice", () => { + render(); + const trigger = screen.getByRole("button", { name: "Language: English" }); + fireEvent.click(trigger); + fireEvent.mouseDown(document.body); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/LoadingPill.test.tsx b/tests/components/LoadingPill.test.tsx index 2591f04..ede3130 100644 --- a/tests/components/LoadingPill.test.tsx +++ b/tests/components/LoadingPill.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { LoadingPill } from "../../src/components/LoadingPill"; +import i18n from "../../src/i18n"; describe("LoadingPill", () => { it("shows a loading label with a live region", () => { @@ -22,4 +23,16 @@ describe("LoadingPill", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); + + it("agrees in French with each noun's gender and the shown count", async () => { + await i18n.changeLanguage("fr"); + const { rerender } = render(); + expect(screen.getByRole("status")).toHaveTextContent("Chargement des nœuds… (42)"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("Certaines routes n’ont pas pu être chargées (7 affichées)"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("Certains paquets n’ont pas pu être chargés (1 affiché)"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("Échec du chargement des observateurs"); + }); }); diff --git a/tests/components/ModalOverlay.test.tsx b/tests/components/ModalOverlay.test.tsx index e140cf6..4a0653a 100644 --- a/tests/components/ModalOverlay.test.tsx +++ b/tests/components/ModalOverlay.test.tsx @@ -12,6 +12,24 @@ function renderOverlay(onClose: () => void) { } describe("ModalOverlay", () => { + it("handles Escape in the active dialog without reaching underlying window handlers", () => { + const close = vi.fn(); const parent = vi.fn(); + window.addEventListener("keydown", parent); + try { + const dialog = renderOverlay(close); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(close).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + } finally { window.removeEventListener("keydown", parent); } + }); + it("does not dismiss an inactive layer", () => { + const close = vi.fn(); + render(

Covered content

); + const dialog = screen.getByRole("dialog", { hidden: true }); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(close).not.toHaveBeenCalled(); + expect(dialog).toHaveAttribute("aria-hidden", "true"); + }); it("closes when a click starts and ends on the backdrop", () => { const onClose = vi.fn(); const backdrop = renderOverlay(onClose); diff --git a/tests/components/MultiSelectDropdown.test.tsx b/tests/components/MultiSelectDropdown.test.tsx new file mode 100644 index 0000000..72e6415 --- /dev/null +++ b/tests/components/MultiSelectDropdown.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MultiSelectDropdown } from "../../src/components/MultiSelectDropdown"; + +describe("MultiSelectDropdown", () => { + const onWindowKey = vi.fn(); + afterEach(() => window.removeEventListener("keydown", onWindowKey)); + + it("closes on Escape without letting an enclosing sheet close too", () => { + window.addEventListener("keydown", onWindowKey); + render( {}} />); + const trigger = screen.getByRole("button", { name: /Types/ }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(onWindowKey).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/components/SearchBar.compact.test.tsx b/tests/components/SearchBar.compact.test.tsx new file mode 100644 index 0000000..2d56d21 --- /dev/null +++ b/tests/components/SearchBar.compact.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { SearchBar } from "../../src/components/SearchBar"; + +describe("compact observer search", () => { + it("omits the field menu and coalesces typing before querying", () => { + vi.useFakeTimers(); + try { + const change = vi.fn(); + render( {}} hideField inputLabel="Search observers" />); + const input = screen.getByRole("textbox", { name: "Search observers" }); + fireEvent.change(input, { target: { value: "Ro" } }); + fireEvent.change(input, { target: { value: "Roof" } }); + expect(change).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(300)); + expect(change).toHaveBeenCalledExactlyOnceWith("Roof"); + expect(screen.queryByRole("button", { name: /Name/ })).not.toBeInTheDocument(); + } finally { vi.useRealTimers(); } + }); +}); diff --git a/tests/components/SplashScreen.test.tsx b/tests/components/SplashScreen.test.tsx new file mode 100644 index 0000000..276cf02 --- /dev/null +++ b/tests/components/SplashScreen.test.tsx @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SplashScreen } from "../../src/components/SplashScreen"; +import i18n from "../../src/i18n"; + +describe("SplashScreen", () => { + it("translates the tagline but keeps the brand", async () => { + sessionStorage.clear(); + await i18n.changeLanguage("fr"); + render(); + expect(screen.getByText("Analyseur de réseau MeshCore")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/Timestamp.test.tsx b/tests/components/Timestamp.test.tsx index 1c4fd5f..2bb3964 100644 --- a/tests/components/Timestamp.test.tsx +++ b/tests/components/Timestamp.test.tsx @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, fireEvent } from "@testing-library/react"; import { Timestamp } from "../../src/components/Timestamp"; +import i18n from "../../src/i18n"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); describe("Timestamp", () => { it("shows relative text, revealing the absolute time on hover (instant tooltip)", () => { @@ -25,4 +31,58 @@ describe("Timestamp", () => { render(); expect(screen.getByText(/\.123$/)).toBeInTheDocument(); }); + + it.each([ + [59_999, "59s", "59 s"], + [60_000, "1m", "1 min"], + [3_600_000, "1h", "1 h"], + [86_400_000, "1d", "1 j"], + [7 * 86_400_000, "7d", "7 j"], + [-60_000, "0s", "0 s"], + ])("translates the phrase while preserving duration %s", async (age, enDuration, frDuration) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); + render(); + const label = screen.getByText(`${enDuration} ago`); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText(`il y a ${frDuration}`)).toBe(label); + await act(() => i18n.changeLanguage("en")); + expect(screen.getByText(`${enDuration} ago`)).toBe(label); + }); + + it("updates an open relative tooltip without changing the absolute value or milliseconds", async () => { + vi.useFakeTimers(); + const value = new Date(2026, 8, 25, 12, 34, 56, 123).getTime(); + vi.setSystemTime(value + 5 * 60_000); + render(); + const label = screen.getByText("2026-09-25 12:34:56.123"); + expect(label).toHaveClass("timestamp-label"); + fireEvent.mouseEnter(label); + expect(screen.getByRole("tooltip")).toHaveTextContent("5m ago"); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5 min"); + expect(screen.getByText("2026-09-25 12:34:56.123")).toBe(label); + expect(label).toHaveClass("timestamp-label"); + }); + + it("keeps one shared timer for many translated timestamps and releases it on unmount", async () => { + // Count polling intervals without counting React's asynchronous scheduling. + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); + const value = Date.now() - 60_000; + const { unmount } = render(<>{Array.from({ length: 100 }, (_, id) => )}); + expect(screen.getAllByText("1m ago")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getAllByText("il y a 1 min")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + act(() => vi.advanceTimersByTime(60_000)); + expect(screen.getAllByText("il y a 2 min")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/tests/deploy/docker-entrypoint.test.ts b/tests/deploy/docker-entrypoint.test.ts new file mode 100644 index 0000000..2b8d877 --- /dev/null +++ b/tests/deploy/docker-entrypoint.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = join(process.cwd(), ".build/docker-entrypoint.sh"); + +let dir: string | undefined; +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +// Runs the entrypoint (exec'ing `true` instead of caddy) and evaluates the config.js it writes. +function runEntrypoint(env: Record): Record { + dir ??= mkdtempSync(join(tmpdir(), "beacon-entrypoint-")); + const out = join(dir, "config.js"); + execFileSync("sh", [SCRIPT, "true"], { env: { PATH: process.env.PATH, BEACON_CONFIG_OUT: out, ...env } }); + const window: { __BEACON_CONFIG__?: Record } = {}; + runInNewContext(readFileSync(out, "utf8"), { window }); + return window.__BEACON_CONFIG__ ?? {}; +} + +describe("docker-entrypoint.sh", () => { + it("writes every key, empty when unset", () => { + const cfg = runEntrypoint({ VITE_API_BASE: "https://api.example.com/api/v1" }); + expect(cfg.VITE_API_BASE).toBe("https://api.example.com/api/v1"); + expect(cfg.VITE_BANNER).toBe(""); + expect(Object.keys(cfg).sort()).toEqual([ + "VITE_API_BASE", "VITE_APP_NAME", "VITE_BANNER", "VITE_DISABLED_TABS", "VITE_ENABLED_THEMES", + "VITE_MAP_CENTER", "VITE_MAP_ZOOM", "VITE_SKIP_SPLASH", "VITE_WS_URL", + ]); + }); + + it("round-trips values with JS and sed metacharacters", () => { + const banner = 'a `tick` "quote" \'single\' ${x} & | \\ back\\slash café\nline2\ttab'; + expect(runEntrypoint({ VITE_BANNER: banner, VITE_APP_NAME: "R&D | Mesh" })).toMatchObject({ + VITE_BANNER: banner, + VITE_APP_NAME: "R&D | Mesh", + }); + }); + + it("rewrites the file on every start so a restart picks up new values", () => { + expect(runEntrypoint({ VITE_BANNER: "first" }).VITE_BANNER).toBe("first"); + expect(runEntrypoint({ VITE_BANNER: "second" }).VITE_BANNER).toBe("second"); + }); +}); diff --git a/tests/features/channels/ChannelFilters.i18n.test.tsx b/tests/features/channels/ChannelFilters.i18n.test.tsx new file mode 100644 index 0000000..b46f23e --- /dev/null +++ b/tests/features/channels/ChannelFilters.i18n.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ChannelSidebar } from "../../../src/features/channels/ChannelSidebar"; +import { ChannelFilterBar } from "../../../src/features/channels/ChannelFilterBar"; +import type { ChannelSummary } from "../../../src/features/channels/types"; +import i18n from "../../../src/i18n"; + +const channels: ChannelSummary[] = [ + { id: 1, name: "Public", channelHash: "11", lastSeen: 2, isHashtag: false, keyKnown: true }, + { id: 2, name: "#ops", channelHash: "22", lastSeen: 1, isHashtag: true, keyKnown: false }, +]; + +function mount() { + render(<> + {}} searchField="name" onSearchFieldChange={() => {}} + keyFilter="" onKeyChange={() => {}} hashtagFilter="" onHashtagChange={() => {}} /> + {}} /> + ); +} + +describe("channel list chrome", () => { + it("renders in English", () => { + mount(); + expect(screen.getByRole("toolbar", { name: "Channel filters" })).toBeInTheDocument(); + expect(screen.getByText("key")).toBeInTheDocument(); + expect(screen.getByText("no key")).toBeInTheDocument(); + }); + + it("renders in French", async () => { + await i18n.changeLanguage("fr"); + mount(); + expect(screen.getByRole("toolbar", { name: "Filtres des canaux" })).toBeInTheDocument(); + expect(screen.getByText("clé")).toBeInTheDocument(); + expect(screen.getByText("sans clé")).toBeInTheDocument(); + expect(screen.getByText("mot-dièse")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx new file mode 100644 index 0000000..bda0a6b --- /dev/null +++ b/tests/features/channels/ChannelList.test.tsx @@ -0,0 +1,242 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, type InfiniteData } from "@tanstack/react-query"; +import { ChannelList } from "../../../src/features/channels/ChannelList"; +import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit"; +import { LIVE_BUFFER_CAP } from "../../../src/lib/constants"; +import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import type { CursorPage } from "../../../src/types/api"; +import type { WsManager } from "../../../src/api/ws-manager"; + +let region = { iatas: ["YYZ"], regionKey: "YYZ" }; +let onMessage: (data: ChannelMessage) => void; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/hooks/useWsHandlers", () => ({ + useWsChannelMessageHandler: (_manager: unknown, handler: typeof onMessage) => { onMessage = handler; }, +})); +vi.mock("../../../src/features/channels/MessagePanel", () => ({ + MessagePanel: ({ channel }: { channel: ChannelSummary | null }) =>
{channel?.id ?? "none"}
, +})); + +const channel = (id: number, name = `Channel ${id}`, keyKnown = true): ChannelSummary => ({ + id, name, channelHash: id.toString(16).padStart(2, "0"), lastSeen: 10000 - id, isHashtag: false, keyKnown, +}); +const other = (id: number, name = `Other ${id}`) => channel(id, name, false); +const page = (items: ChannelSummary[], nextCursor: number | null = null): CursorPage => ({ items, nextCursor, hasMore: nextCursor !== null }); +const response = (data: CursorPage) => new Response(JSON.stringify(data), { status: 200 }); +const fetchMock = vi.fn(); + +// Each list (keyKnown=true / false) answers from its own queue; an unqueued request gets an empty page. +type Reply = CursorPage | Error | Promise; +let queues: Record; +const keyed = (...replies: Reply[]) => queues.true!.push(...replies); +const others = (...replies: Reply[]) => queues.false!.push(...replies); +const calls = (kind: "true" | "false") => fetchMock.mock.calls.map(([url]) => new URL(String(url))).filter((url) => url.searchParams.get("keyKnown") === kind); +const lastCall = (kind: "true" | "false") => calls(kind).at(-1)!; + +function show(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + const app = () => {}} />; + return { ...render(app()), client, app }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} +function pending() { + let resolve!: (value: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + region = { iatas: ["YYZ"], regionKey: "YYZ" }; + queues = { true: [], false: [] }; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const reply = queues[new URL(String(input)).searchParams.get("keyKnown") ?? ""]?.shift() ?? page([]); + if (reply instanceof Error) throw reply; + if (reply instanceof Promise) return reply; + return response(reply); + }); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + noteRequestOk(); +}); + +describe("channel directory", () => { + it("lists every keyed channel up front and leaves the rest behind a button", async () => { + keyed(page([channel(1, "Public"), channel(2, "First")], 5000), page([channel(3, "Last")])); + show(); + await screen.findByText("#Last"); + expect(screen.getByText("Public")).toBeInTheDocument(); + expect(calls("true")).toHaveLength(2); + expect(calls("true")[0]!.searchParams.get("limit")).toBe("200"); + expect(calls("true")[1]!.searchParams.get("cursor")).toBe("5000"); + expect(calls("true")[0]!.searchParams.get("iata")).toBe("YYZ"); + expect(calls("false")).toHaveLength(0); + expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument(); + }); + + it("loads other channels on demand below the keyed ones, then pages them with the precise cursor", async () => { + const precise = "v1:1700000000000123:50"; + keyed(page([channel(1)])); + others({ ...page([other(60)], 5000), nextPageCursor: precise }, page([other(61, "Older other")])); + show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + await screen.findByText("#Other 60"); + const names = screen.getAllByRole("button", { name: /^#/ }).map((b) => b.textContent); + expect(names.findIndex((n) => n?.includes("Channel 1"))).toBeLessThan(names.findIndex((n) => n?.includes("Other 60"))); + fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); + await screen.findByText("#Older other"); + expect(lastCall("false").searchParams.get("pageCursor")).toBe(precise); + expect(lastCall("false").searchParams.get("limit")).toBe("50"); + expect(screen.queryByRole("button", { name: /Load (more|other) channels/ })).not.toBeInTheDocument(); + }); + + it("stops paging keyed channels when the server ignores keyKnown", async () => { + keyed(page([channel(1, "Keyed"), other(2, "Mixed in")], 5000)); + show(); + await screen.findByText("#Keyed"); + await waitFor(() => expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument()); + expect(calls("true")).toHaveLength(1); + expect(screen.queryByText("#Mixed in")).not.toBeInTheDocument(); + }); + + it("loads other channels automatically for the Unknown key filter", async () => { + keyed(page([channel(1)])); + others(page([other(60)])); + show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByRole("button", { name: /^Key/ })); + fireEvent.click(screen.getByRole("option", { name: "Unknown" })); + await screen.findByText("#Other 60"); + expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument(); + }); + + it("can retry an initial failure", async () => { + keyed(new Error("temporary failure"), page([channel(1)])); + show(); + fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); + await screen.findByText("#Channel 1"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(calls("true")).toHaveLength(2); + expect(calls("true")[1]!.searchParams.has("cursor")).toBe(false); + }); + + it("gates a pending page of other channels and offers retry after failure", async () => { + const slow = pending(); + keyed(page([channel(1)])); + others(page([other(60)], 5000), slow.promise, page([other(61)])); + show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + fireEvent.click(await screen.findByRole("button", { name: "Load more channels" })); + const loading = await screen.findByRole("button", { name: "Loading channels..." }); + expect(loading).toBeDisabled(); + fireEvent.click(loading); + expect(calls("false")).toHaveLength(2); + await act(async () => slow.reject(new Error("temporary failure"))); + fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); + await screen.findByText("#Other 61"); + expect(lastCall("false").searchParams.get("cursor")).toBe("5000"); + }); + + it("updates a known channel in place and refreshes only the keyed list for an unseen live channel", async () => { + keyed(page([channel(1)]), page([channel(1), channel(99, "New live channel")])); + others(page([other(60)], 5000)); + const { client } = show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Other 60/ })); + const event = { id: 1, packetHash: "fixture", channelHash: other(60).channelHash, senderName: "fixture", content: "fixture", sentAt: 20000 }; + act(() => onMessage(event)); + const cached = client.getQueryData>>(["channels", "YYZ", "other"])!; + expect(cached.pages[0]!.items[0]!.lastSeen).toBe(20000); + expect(cached.pages[0]!.nextCursor).toBe(5000); + expect(fetchMock).toHaveBeenCalledTimes(2); + act(() => onMessage({ ...event, channelHash: "ff" })); + await screen.findByText("#New live channel"); + expect(calls("true")).toHaveLength(2); + expect(calls("false")).toHaveLength(1); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("60"); + }); + + it("coalesces unseen live channels during a keyed fetch into one refresh", async () => { + const slow = pending(); + keyed(page([channel(1)], 5000), slow.promise, page([channel(99, "New live channel")])); + show(); + await screen.findByText("#Channel 1"); + const event = { id: 1, packetHash: "fixture", channelHash: "ff", senderName: "fixture", content: "fixture", sentAt: 20000 }; + act(() => { onMessage(event); onMessage({ ...event, channelHash: "fe" }); }); + expect(calls("true")).toHaveLength(2); + await act(async () => slow.resolve(response(page([channel(2)])))); + await screen.findByText("#New live channel"); + expect(calls("true")).toHaveLength(3); + expect(lastCall("true").searchParams.has("cursor")).toBe(false); + }); + + it("keeps the open channel when it drops out of the list, then resets everything on region change", async () => { + keyed(page([channel(1)])); + others(page([other(60)])); + const view = show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Other 60/ })); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "absent" } }); + await screen.findByText("No matching channels loaded."); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("60"); + keyed(page([channel(101, "Other region")])); + region = { iatas: ["YOW"], regionKey: "YOW" }; + view.rerender(view.app()); + await screen.findByText("#Other region"); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("none"); + expect(screen.getByPlaceholderText("Search by name...")).toHaveValue(""); + expect(lastCall("true").searchParams.get("iata")).toBe("YOW"); + expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument(); + expect(calls("false")).toHaveLength(1); + }); +}); + +describe("channel message overflow recovery", () => { + it("recovers a rate-limit-stuck overflow once the rate limit clears", async () => { + keyed(page([channel(1)])); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + const fetch = deferred>>(); + void client.fetchQuery({ queryKey: key, queryFn: () => fetch.promise }); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(1)); + act(() => { + for (let i = 0; i <= LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + noteRateLimited(60_000); + await act(async () => fetch.resolve({ pages: [{ items: [], nextCursor: null, hasMore: false }], pageParams: [undefined] })); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(0)); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => noteRequestOk()); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); + + it("invalidates an idle overflow directly from the WS handler once a further message arrives", async () => { + keyed(page([channel(1)])); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + // A registered-but-unfetched query: getQueryData(key) stays undefined, so messages keep hitting the queue/overflow path. + client.getQueryCache().build(client, { queryKey: key }); + expect(client.isFetching({ queryKey: key, exact: true })).toBe(0); + act(() => { + for (let i = 0; i < LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => onMessage({ id: LIVE_BUFFER_CAP, packetHash: `h${LIVE_BUFFER_CAP}`, channelHash: "01", senderName: "s", content: "c", sentAt: 30000 })); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx new file mode 100644 index 0000000..d95bf88 --- /dev/null +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MessagePanel } from "../../../src/features/channels/MessagePanel"; +import { ChannelList } from "../../../src/features/channels/ChannelList"; +import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { getChannelMessagesPage, getChannels } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ + getChannelMessagesPage: vi.fn(), + getChannels: vi.fn(), + getScopes: vi.fn(async () => ["#can", "#yow"]), +})); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }) })); +let onMessage: (data: ChannelMessage) => void; +vi.mock("../../../src/hooks/useWsHandlers", () => ({ + useWsChannelMessageHandler: (_m: unknown, fn: typeof onMessage) => { + onMessage = fn; + }, +})); +const channel: ChannelSummary = { + id: 1, + name: "#test", + channelHash: "11", + keyKnown: true, + isHashtag: true, + lastSeen: 1, +}; +const base = { channelHash: "11", senderName: "Fixture", content: "test message", sentAt: 1 }; +const messages = [ + { ...base, id: 4, packetHash: "matched", scope: "#yow", scopeStatus: "matched" as const }, + { ...base, id: 3, packetHash: "unscoped", scope: null, scopeStatus: "unscoped" as const }, + { ...base, id: 2, packetHash: "unknown", scope: null, scopeStatus: "unknown" as const }, + { ...base, id: 1, packetHash: "legacy" }, +]; +const page = (items: ChannelMessage[], nextCursor: number | null = null) => ({ + items, + nextCursor, + hasMore: nextCursor !== null, +}); +function show(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { client, ...render({node}) }; +} +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages)); + vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +describe("channel scope evidence", () => { + it("chips matched and unresolved scopes, leaves the unscoped default and legacy rows unlabelled, and offers packet inspection", async () => { + const inspect = vi.fn(); + show(); + expect(await screen.findByText("#yow")).toBeInTheDocument(); + expect(screen.getByText("Unknown scope")).toBeInTheDocument(); + expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); + expect(screen.queryByText("Scope unavailable")).not.toBeInTheDocument(); + const view = screen.getAllByRole("button", { name: "Inspect packet from Fixture" })[0]!; + expect(view).toHaveTextContent("View packet"); + expect(view).not.toHaveClass("underline"); + fireEvent.click(view); + expect(inspect).toHaveBeenCalledTimes(1); + }); + it("sizes the scope chip like the heard-count badge", async () => { + show(); + const chip = await screen.findByText("#yow"); + expect(chip).toHaveClass("border", "px-2", "py-0.5", "font-semibold"); + }); + it("switches French labels without refetching or changing protocol names", async () => { + show(); + await screen.findByText("#yow"); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Portée inconnue")).toBeInTheDocument(); + expect(screen.getByText("#yow")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("isolates scope history and live messages, preserving page cursors and channel identity", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, p) => + page(p?.scope === "#yow" ? [messages[0]!] : messages, p?.scope === "#yow" ? 4 : null), + ); + const { client } = show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); + await screen.findByText("Unknown scope"); + fireEvent.click(screen.getByRole("button", { name: /Scope.*All scopes/ })); + fireEvent.click(screen.getByRole("option", { name: "#yow", exact: true })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow" })), + ); + await waitFor(() => expect(screen.queryByText("Unknown scope")).not.toBeInTheDocument()); + await waitFor(() => expect(client.getQueryData(["channel-messages", 1, "YOW", "#yow"])).toBeDefined()); + const event = { + ...base, + id: 5, + channelId: 1, + packetHash: "live-yow", + content: "in scope", + scope: "#yow", + scopeStatus: "matched" as const, + }; + act(() => { + onMessage({ ...event, packetHash: "other", scope: "#can", content: "wrong scope" }); + onMessage({ ...event, channelId: 2, packetHash: "collision", content: "wrong channel" }); + onMessage(event); + onMessage(event); + }); + expect(await screen.findByText("in scope")).toBeInTheDocument(); + expect(screen.queryByText("wrong scope")).not.toBeInTheDocument(); + expect(screen.queryByText("wrong channel")).not.toBeInTheDocument(); + expect(screen.getAllByText("in scope")).toHaveLength(1); + const cache = client.getQueryData<{ pages: ReturnType[]; pageParams: unknown[] }>([ + "channel-messages", + 1, + "YOW", + "#yow", + ])!; + expect(cache.pages[0]!.nextCursor).toBe(4); + expect(cache.pageParams).toEqual([undefined]); + fireEvent.click(screen.getByRole("button", { name: "Load older messages" })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow", cursor: 4 })), + ); + }); + it("explains an empty filtered history", async () => { + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show( {}} />); + expect(await screen.findByText("No messages match #yow.")).toBeInTheDocument(); + }); + it("keeps messages received during history loading without an extra request", async () => { + let resolveHistory!: (value: ReturnType) => void; + const live = { ...messages[0]!, id: 5, channelId: 1, packetHash: "during-read", content: "arrived during history" }; + vi.mocked(getChannelMessagesPage) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + ) + .mockResolvedValue(page([live])); + show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); + await waitFor(() => expect(getChannelMessagesPage).toHaveBeenCalledTimes(1)); + act(() => { + onMessage(live); + onMessage(live); + }); + await act(async () => resolveHistory(page([]))); + expect(await screen.findByText("arrived during history")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("explains missing decryption keys without inventing a message scope", async () => { + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show(); + expect( + await screen.findByText("Messages can't be decrypted without the channel key."), + ).toBeInTheDocument(); + expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); + }); + + it("preserves the newest/live page after more than twenty older pages", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { + const index = params?.cursor ?? 0; + return page( + [ + { + ...base, + id: 1000 - index, + packetHash: `history-${index}`, + content: `History ${index}`, + sentAt: 1000 - index, + }, + ], + index + 1, + ); + }); + show(); + await screen.findByText("History 0"); + for (let index = 1; index <= 21; index++) { + fireEvent.click(screen.getByRole("button", { name: /Load older/ })); + await screen.findByText(`History ${index}`); + } + expect(screen.getByText("History 0")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index ca7c44c..65e3481 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -3,8 +3,9 @@ import { render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MessagePanel } from "../../../src/features/channels/MessagePanel"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import i18n from "../../../src/i18n"; -// live WS messages have no id — mirror that runtime shape in the page data +// Older live WS messages have no id — preserve that compatible runtime shape. const restMsg: ChannelMessage = { id: 1, packetHash: "ph-rest", @@ -39,7 +40,9 @@ const multiLineMsg: ChannelMessage = { sentAt: 4000, }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: undefined, regionKey: "*" }) })); vi.mock("../../../src/api/client", () => ({ + getScopes: vi.fn(async () => []), getChannelMessagesPage: vi.fn(() => Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB, multiLineMsg], nextCursor: null, hasMore: false }), ), @@ -97,3 +100,24 @@ describe("MessagePanel multi-line messages", () => { expect(body.className).toContain("break-words"); }); }); + +describe("MessagePanel heard badge", () => { + const mount = () => render( + + + , + ); + + it("labels the repeat count", async () => { + mount(); + expect(await screen.findByTitle("Heard 3 times")).toHaveTextContent("×3"); + expect(screen.getByTitle("Heard 1 time")).toBeInTheDocument(); + }); + + it("labels the repeat count in French", async () => { + await i18n.changeLanguage("fr"); + mount(); + expect(await screen.findByTitle("Entendu 3 fois")).toBeInTheDocument(); + expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/map/MapSettingsPanel.test.tsx b/tests/features/map/MapSettingsPanel.test.tsx new file mode 100644 index 0000000..96610f3 --- /dev/null +++ b/tests/features/map/MapSettingsPanel.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MapSettingsPanel } from "../../../src/features/map/MapSettingsPanel"; +import { PacketFlowButton } from "../../../src/features/map/PacketFlowButton"; +import i18n from "../../../src/i18n"; + +function mount() { + render(<> + {}} typeFilter="" onTypeChange={() => {}} + clustered onClusteredChange={() => {}} neighborLines="selected" onNeighborLinesChange={() => {}} + borders onBordersChange={() => {}} buildShareParams={() => ({})} /> + {}} /> + ); +} + +describe("map settings", () => { + it("renders in English", () => { + mount(); + expect(screen.getByText("Map Settings")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Repeater" })).toBeInTheDocument(); + expect(screen.getByText("fainter = heard longer ago")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Play live map packet flow" })).toHaveTextContent("Live Map"); + }); + + it("renders in French", async () => { + await i18n.changeLanguage("fr"); + mount(); + expect(screen.getByText("Paramètres de la carte")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Répéteur" })).toBeInTheDocument(); + expect(screen.getByText("Contour de zone")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Lancer le flux de paquets en direct sur la carte" })).toHaveTextContent("Carte en direct"); + }); +}); diff --git a/tests/features/map/MapStyleSwitcher.test.tsx b/tests/features/map/MapStyleSwitcher.test.tsx new file mode 100644 index 0000000..b273ea1 --- /dev/null +++ b/tests/features/map/MapStyleSwitcher.test.tsx @@ -0,0 +1,17 @@ +import { expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MapStyleSwitcher } from "../../../src/features/map/MapStyleSwitcher"; +import i18n from "../../../src/i18n"; + +it("labels the map styles in English", () => { + render(); + expect(screen.getByRole("group", { name: "Map style" })).toBeInTheDocument(); + for (const name of ["Dark", "Liberty", "Light"]) expect(screen.getByText(name)).toBeInTheDocument(); +}); + +it("labels the map styles in French", async () => { + await i18n.changeLanguage("fr"); + render(); + expect(screen.getByRole("group", { name: "Style de carte" })).toBeInTheDocument(); + for (const name of ["Sombre", "Liberty", "Clair"]) expect(screen.getByText(name)).toBeInTheDocument(); +}); diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index 17595f8..67c080d 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -34,18 +34,18 @@ const detail = { } as unknown as PacketDetail; describe("PacketPathMapModal", () => { - it("lists All paths plus a row per observer and starts on All", () => { + it("lists All paths plus a row per observer and starts on All", async () => { render( {}} />); expect(screen.getByText("All paths")).toBeInTheDocument(); expect(screen.getByText("Alpha")).toBeInTheDocument(); expect(screen.getByText("Bravo")).toBeInTheDocument(); - expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); }); - it("isolates a path when its row is clicked", () => { + it("isolates a path when its row is clicked", async () => { render( {}} />); fireEvent.click(screen.getByText("Bravo")); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); it("shows each observer's propagation", () => { @@ -61,14 +61,18 @@ describe("PacketPathMapModal", () => { expect(onClose).toHaveBeenCalledOnce(); }); - it("pre-selects the observer from initialSelectedKey", () => { + it("pre-selects the observer from initialSelectedKey", async () => { render( {}} initialSelectedKey="obs-bravo" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); - it("falls back to All when initialSelectedKey isn't a known path", () => { + it("does not silently substitute all paths for an unavailable selected path", async () => { render( {}} initialSelectedKey="nope" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("nope"); + expect(screen.getByRole("status")).toHaveTextContent("This path can't be mapped"); + fireEvent.click(screen.getByText("All paths")); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("renders a copy-link button", () => { diff --git a/tests/features/map/map-locale.test.ts b/tests/features/map/map-locale.test.ts new file mode 100644 index 0000000..c64a0c3 --- /dev/null +++ b/tests/features/map/map-locale.test.ts @@ -0,0 +1,16 @@ +import { expect, it, vi } from "vitest"; +import i18n from "../../../src/i18n"; + +vi.mock("maplibre-gl", () => ({})); +vi.mock("../../../src/features/map/maplibre-worker", () => ({})); + +const { mapLocale } = await import("../../../src/features/map/useMapLibre"); + +it("keeps MapLibre's own English control tooltips", () => { + expect(mapLocale()).toMatchObject({ "NavigationControl.ZoomIn": "Zoom in", "AttributionControl.ToggleAttribution": "Toggle attribution" }); +}); + +it("translates the MapLibre control tooltips to French", async () => { + await i18n.changeLanguage("fr"); + expect(mapLocale()).toMatchObject({ "NavigationControl.ZoomIn": "Zoom avant", "NavigationControl.ZoomOut": "Zoom arrière" }); +}); diff --git a/tests/features/map/map-url.test.ts b/tests/features/map/map-url.test.ts index 485bca2..ff7ee43 100644 --- a/tests/features/map/map-url.test.ts +++ b/tests/features/map/map-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseMapView, buildMapParams, type MapViewSnapshot } from "../../../src/features/map/map-url"; +import { parseMapView, parseNodeType, 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 { @@ -142,3 +142,12 @@ describe("buildMapParams", () => { expect(parseMapView(toParams(built)).nodeType).toBeUndefined(); }); }); + +describe("parseNodeType", () => { + it("accepts a known type name and rejects stale or empty values", () => { + expect(parseNodeType("repeater")).toBe("repeater"); + expect(parseNodeType("room")).toBeUndefined(); // an older saved value no type matches + expect(parseNodeType("")).toBeUndefined(); + expect(parseNodeType(null)).toBeUndefined(); + }); +}); diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 79a7de4..23fb28d 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -64,10 +64,15 @@ describe("nodesToFeatureCollection", () => { expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]); }); - it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => { - const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]); - expect(fc.features).toHaveLength(1); - expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]); + it("omits location resets and unusable coordinates", () => { + const positions = [[0, 0], [-0, 0], [NaN, 10], [10, Infinity], [91, 20], [-91, 20], [10, 181], [10, -181]]; + expect(nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng }))).features).toEqual([]); + }); + + it("keeps valid equator, prime-meridian and boundary locations", () => { + const positions = [[0, -75], [45, 0], [90, 180], [-90, -180]]; + const fc = nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng }))); + expect(fc.features.map((f) => f.geometry.coordinates)).toEqual([[-75, 0], [0, 45], [180, 90], [-180, -90]]); }); it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => { @@ -114,6 +119,17 @@ describe("buildNeighborEdges", () => { const fc = buildNeighborEdges([selfRef, b], "on", null); expect(fc.features).toHaveLength(1); // a<->b only, not a<->a }); + + it("omits both directions of neighbour edges to reset or invalid locations", () => { + const valid = node({ id: "a", neighborIds: ["reset", "invalid"] }); + const reset = node({ id: "reset", lat: 0, lng: 0, neighborIds: ["a"] }); + const invalid = node({ id: "invalid", lat: 91, lng: 10, neighborIds: ["a"] }); + for (const mode of ["on", "selected"] as const) { + expect(buildNeighborEdges([valid, reset, invalid], mode, "a").features).toEqual([]); + } + expect(neighborFocusIds([valid, reset, invalid], "a")).toBeNull(); + expect(neighborFocusIds([valid, reset, invalid], "reset")).toBeNull(); + }); }); describe("neighborFocusIds", () => { @@ -200,6 +216,12 @@ describe("buildFocusedNeighborEdges", () => { ], NOW); expect(fc.features.map((f) => f.properties.obs)).toEqual([5]); }); + + it("omits reset and invalid locations from focused edges", () => { + const candidates = [nb({ id: "reset", lat: 0, lng: 0 }), nb({ id: "invalid", lat: 10, lng: Infinity }), nb({ id: "valid", lat: 0, lng: 15 })]; + expect(buildFocusedNeighborEdges(sel, candidates, NOW).features.map((f) => f.geometry.coordinates)).toEqual([[[-75, 45], [15, 0]]]); + expect(buildFocusedNeighborEdges(node({ lat: 0, lng: 0 }), [nb({})], NOW).features).toEqual([]); + }); }); describe("filterByNodeType", () => { diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index e390657..89d8d30 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -27,14 +27,21 @@ describe("packetChain", () => { }); describe("resolvedPathNodes", () => { - it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { + it("suppresses an animation when a gap would invent a link", () => { 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 }]); + expect(resolvedPathNodes(path)).toEqual([]); + expect(resolvedPathNodes([hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)])).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([]); }); + + it("rejects reset/invalid and ambiguous locations while accepting valid zero axes", () => { + const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 10, 91).nodes, ...hop("valid", 0, 45).nodes] }; + expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([]); + expect(resolvedPathNodes([hop("valid", 0, 45)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); + }); }); describe("posAtHop", () => { diff --git a/tests/features/map/packet-path-evidence.test.ts b/tests/features/map/packet-path-evidence.test.ts new file mode 100644 index 0000000..4f2bf83 --- /dev/null +++ b/tests/features/map/packet-path-evidence.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { buildPacketPaths, packetPathsToFeatures } from "../../../src/features/map/packet-path"; +import { resolvedPathNodes } from "../../../src/features/map/packet-flow"; +import type { PacketDetail, ResolvedHop } from "../../../src/types/api"; + +const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, publicKey: id, latitude: 45, longitude: -75 + Number(id) }] }); +function detail(hops: ResolvedHop[]): PacketDetail { + return { header: { payloadType: 2 }, observations: [{ id: 1, observerId: "observer", resolvedPath: hops }] } as unknown as PacketDetail; +} +it("does not choose the located candidate in an ambiguous relay", () => { + const ambiguous: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "unknown", publicKey: "a" }, ...hop("1").nodes] }; + const paths = buildPacketPaths(detail([hop("0"), ambiguous, hop("2"), hop("3")])); + expect(paths[0].points.map(p => p.id)).toEqual(["0", "2", "3"]); + expect(packetPathsToFeatures(paths, null).lines.features.map(f => f.geometry.coordinates)).toEqual([[[-73,45],[-72,45]]]); +}); +it("keeps isolated known locations without drawing a link over an unknown hop", () => { + const paths = buildPacketPaths(detail([hop("0"), { confidence: "none", nodes: [] }, hop("2")])); + const data = packetPathsToFeatures(paths, null); + expect(data.lines.features).toHaveLength(0); + expect(data.points.features).toHaveLength(2); + expect(resolvedPathNodes([hop("0"), { confidence: "none", nodes: [] }, hop("2")])).toEqual([]); +}); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 1c78385..ec0a7af 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -21,6 +21,17 @@ function detail(observations: Observation[], over: Partial = {}): } describe("buildPacketPaths", () => { + it("excludes location resets and invalid candidates from observation and trace paths", () => { + const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 181, 10).nodes, ...hop("valid", 0, 45).nodes] }; + const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)]; + const ordinary = buildPacketPaths(detail([obs(1, hops)])); + const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial)); + expect(ordinary).toEqual([]); expect(trace).toEqual([]); + const valid = buildPacketPaths(detail([obs(1, [hop("start", 0, 45), hop("end", -75, 0)])])); + expect(valid[0].points.map(p => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); + expect(buildPacketPaths(detail([obs(1, [hop("unknown", 0, 0), hop("one", -75, 45)])]))).toEqual([]); + }); + it("keys each path by observerId and carries propagation, fastest first", () => { const d = detail([ obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }), @@ -133,19 +144,18 @@ describe("buildPacketPaths", () => { expect(buildPacketPaths(d)).toEqual([]); }); - it("uses the first located candidate for an ambiguous relay hop", () => { + it("does not turn the only located candidate into a resolved identity", () => { const multi: ResolvedHop = { confidence: "ambiguous", nodes: [ { id: "unlocated", publicKey: "p0" }, // no coords — skipped - { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used + { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // location does not disambiguate identity ], }; const d = detail([ obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }), ]); - const [path] = buildPacketPaths(d); - expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]); + expect(buildPacketPaths(d)).toEqual([]); }); it("omits observations that resolve to fewer than 2 located hops", () => { @@ -203,3 +213,9 @@ describe("packetPathsToFeatures", () => { expect(points.features[1]!.properties.title).toBe("Repeater North"); }); }); + +it("resumes a confirmed segment after returning to the same node across a gap", () => { + const paths = buildPacketPaths(detail([obs(1, [hop("a", -79, 43), hop("missing"), hop("a", -79, 43), hop("b", -75, 45)])])); + expect(paths[0].points.map(point => point.id)).toEqual(["a", "b"]); + expect(paths[0].points[1].breakBefore).toBeUndefined(); +}); diff --git a/tests/features/map/useMapBordersData.refresh.test.tsx b/tests/features/map/useMapBordersData.refresh.test.tsx new file mode 100644 index 0000000..7eca6f0 --- /dev/null +++ b/tests/features/map/useMapBordersData.refresh.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useMapBordersData } from "../../../src/features/map/useMapBordersData"; +import type { IataBorder } from "../../../src/api/client"; + +const state = vi.hoisted(() => ({ data: null as IataBorder | null, updated: 0 })); +vi.mock("@tanstack/react-query", () => ({ useQueries: () => [{ data: state.data, dataUpdatedAt: state.updated }] })); + +const border = (lng: number): IataBorder => ({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[lng, 0], [lng + 1, 0], [lng + 1, 1], [lng, 0]]] } }); + +describe("border refresh", () => { + it("replaces an already-loaded polygon when its query refreshes", () => { + state.data = border(0); state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.data = border(5); state.updated = 2; + rerender(); + expect(result.current).not.toBe(previous); + expect(result.current.features[0]?.geometry).toEqual(border(5).geometry); + }); + it("adds a configured border after an earlier empty response", () => { + state.data = null; state.updated = 3; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + expect(result.current.features).toHaveLength(0); + state.data = border(10); state.updated = 4; + rerender(); + expect(result.current.features).toHaveLength(1); + }); + it("keeps the same reference when a refetch returns the same data object", () => { + const same = border(0); + state.data = same; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); + it("keeps the same reference when a null-border query refetches", () => { + state.data = null; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); + it("returns no borders once toggled off, even with cached data", () => { + state.data = border(0); state.updated = 1; + const { result, rerender } = renderHook(({ on }) => useMapBordersData(["YKF"], on), { initialProps: { on: true } }); + expect(result.current.features).toHaveLength(1); + rerender({ on: false }); + expect(result.current.features).toHaveLength(0); + }); +}); diff --git a/tests/features/map/useMapLibre.test.tsx b/tests/features/map/useMapLibre.test.tsx new file mode 100644 index 0000000..75b1468 --- /dev/null +++ b/tests/features/map/useMapLibre.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +type Handler = (e?: unknown) => void; + +const maps = vi.hoisted(() => [] as FakeMap[]); + +interface FakeMap { + fire: (type: string, e?: unknown) => void; + fitBounds: ReturnType; + flyTo: ReturnType; + setStyle: ReturnType; +} + +vi.mock("../../../src/features/map/maplibre-worker", () => ({})); +vi.mock("maplibre-gl", () => { + class Map { + handlers: Record = {}; + fitBounds = vi.fn(); + flyTo = vi.fn(); + setStyle = vi.fn(); + constructor() { + maps.push(this as unknown as FakeMap); + } + on(type: string, fn: Handler) { + (this.handlers[type] ??= []).push(fn); + } + fire(type: string, e?: unknown) { + for (const fn of this.handlers[type] ?? []) fn(e); + } + addControl() {} + getContainer() { + return document.createElement("div"); + } + setMissingStyleImageResolver() {} + getSource() { + return {}; + } + getLayer() { + return {}; + } + setTerrain() {} + remove() {} + } + class LngLatBounds { + extend() { + return this; + } + } + class Control {} + return { Map, LngLatBounds, NavigationControl: Control, ScaleControl: Control, AttributionControl: Control }; +}); + +const { useMapLibre } = await import("../../../src/features/map/useMapLibre"); +const { resolveMapStyle } = await import("../../../src/features/map/types"); + +const camera = { center: [-75.7, 45.4] as [number, number], zoom: 9 }; + +function mount(props: { styleId?: string; fitPoints: [number, number][] | null; fitScope?: string; onStyleError?: (id: string) => void }) { + const hook = renderHook( + (p: typeof props) => { + const r = useMapLibre(p.styleId ?? "dark", p.fitPoints, p.onStyleError, camera, p.fitScope); + // the map attaches to the container ref, so hand it a node before the init effect runs + if (!r.containerRef.current) r.containerRef.current = document.createElement("div"); + return r; + }, + { initialProps: props }, + ); + const map = maps[maps.length - 1]!; + act(() => map.fire("load")); + return { ...hook, map }; +} + +beforeEach(() => { + maps.length = 0; +}); + +describe("useMapLibre deep-link fit", () => { + it("keeps the deep-link camera over the first fit of the linked region", () => { + const { map, rerender } = mount({ fitPoints: null, fitScope: "ottawa" }); + rerender({ fitPoints: [[-75.7, 45.3]], fitScope: "ottawa" }); + expect(map.fitBounds).not.toHaveBeenCalled(); + }); + + it("frames a newly selected region when the linked one had no airport coords", () => { + const { map, rerender } = mount({ fitPoints: null, fitScope: "nowhere" }); + rerender({ fitPoints: [[-79.6, 43.7]], fitScope: "toronto" }); + expect(map.fitBounds).toHaveBeenCalledTimes(1); + }); +}); + +describe("useMapLibre basemap swap errors", () => { + function swap(onStyleError: (id: string) => void) { + const { map, rerender } = mount({ fitPoints: null, onStyleError }); + rerender({ styleId: "liberty", fitPoints: null, onStyleError }); + expect(map.setStyle).toHaveBeenCalled(); + return map; + } + + it("ignores a sprite failure while the new style is still loading", () => { + const onStyleError = vi.fn(); + const map = swap(onStyleError); + act(() => + map.fire("error", { error: Object.assign(new Error("sprite"), { status: 404, url: "https://tiles.example/sprite.json" }) }), + ); + expect(onStyleError).not.toHaveBeenCalled(); + }); + + it("reverts when the style document itself fails to load", () => { + const onStyleError = vi.fn(); + const map = swap(onStyleError); + act(() => + map.fire("error", { error: Object.assign(new Error("offline"), { status: 0, url: resolveMapStyle("liberty").url }) }), + ); + expect(onStyleError).toHaveBeenCalledWith("dark"); + }); +}); diff --git a/tests/features/map/useMapNodes.layer-order.test.tsx b/tests/features/map/useMapNodes.layer-order.test.tsx new file mode 100644 index 0000000..0f9e1b7 --- /dev/null +++ b/tests/features/map/useMapNodes.layer-order.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import { + NODES_CLUSTER_LAYER_ID, + NODES_POINT_LAYER_ID, + PACKET_FLOW_TRAIL_LAYER_ID, + PACKET_FLOW_DOT_HALO_LAYER_ID, + PACKET_FLOW_DOT_LAYER_ID, +} from "../../../src/features/map/types"; + +vi.mock("@nazka/map-gl-js-spiderfy", () => ({ + default: class { + applyTo = vi.fn(); + unspiderfyAll = vi.fn(); + }, +})); + +import { useMapNodes } from "../../../src/features/map/useMapNodes"; + +// map stub that keeps a real layer stack so addLayer's beforeId placement can be asserted +function stackMap(layers: string[]): MapLibreMap { + const sources = new Set(); + const fns = new Map(); + const canvas = { style: {} as Record }; + const impl: Record = { + getCanvas: () => canvas, + getLayer: (id: string) => (layers.includes(id) ? { id } : undefined), + addLayer: (spec: { id: string }, beforeId?: string) => { + const at = beforeId ? layers.indexOf(beforeId) : -1; + if (at >= 0) layers.splice(at, 0, spec.id); + else layers.push(spec.id); + }, + removeLayer: (id: string) => layers.splice(layers.indexOf(id), 1), + getSource: (id: string) => (sources.has(id) ? { setData: vi.fn() } : undefined), + addSource: (id: string) => sources.add(id), + removeSource: (id: string) => sources.delete(id), + getStyle: () => ({ layers: layers.map((id) => ({ id })) }), + }; + return new Proxy({} as MapLibreMap, { + get(_, key) { + if (typeof key === "string" && key in impl) return impl[key]; + if (key === "then") return undefined; + if (!fns.has(key)) fns.set(key, vi.fn()); + return fns.get(key); + }, + }); +} + +describe("useMapNodes layer order", () => { + it("keeps node layers beneath the packet-flow layers after a clustering toggle", () => { + const layers: string[] = []; + const mapRef = { current: stackMap(layers) }; + const geojson = { type: "FeatureCollection" as const, features: [] }; + const { rerender } = renderHook( + ({ clustered }) => + useMapNodes(mapRef, { current: null }, true, geojson, true, "dark", clustered, () => {}, null, true, null), + { initialProps: { clustered: true } }, + ); + // packet flow builds its layers after the nodes, on top + layers.push(PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID); + + rerender({ clustered: false }); + + const trail = layers.indexOf(PACKET_FLOW_TRAIL_LAYER_ID); + expect(layers.indexOf(NODES_CLUSTER_LAYER_ID)).toBeLessThan(trail); + expect(layers.indexOf(NODES_POINT_LAYER_ID)).toBeLessThan(trail); + expect(layers.indexOf(NODES_CLUSTER_LAYER_ID)).toBeLessThan(layers.indexOf(NODES_POINT_LAYER_ID)); + }); +}); diff --git a/tests/features/map/useMapNodes.spiderfy.test.tsx b/tests/features/map/useMapNodes.spiderfy.test.tsx new file mode 100644 index 0000000..d1889bc --- /dev/null +++ b/tests/features/map/useMapNodes.spiderfy.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { Map as MapLibreMap } from "maplibre-gl"; + +const spiders = vi.hoisted(() => [] as { applyTo: ReturnType; unspiderfyAll: ReturnType }[]); + +vi.mock("@nazka/map-gl-js-spiderfy", () => ({ + default: class { + // unspiderfyAll also unbinds the cluster click, so a reset must re-apply on a fresh instance + applyTo = vi.fn(); + unspiderfyAll = vi.fn(); + constructor() { + spiders.push(this); + } + }, +})); + +import { useMapNodes } from "../../../src/features/map/useMapNodes"; + +function stubMap(): MapLibreMap { + const fns = new Map(); + const canvas = { style: {} as Record }; + return new Proxy({} as MapLibreMap, { + get(_, key) { + if (key === "getCanvas") return () => canvas; + if (key === "getSource") return () => ({ setData: vi.fn() }); + if (key === "then") return undefined; + if (!fns.has(key)) fns.set(key, vi.fn()); + return fns.get(key); + }, + }); +} + +describe("useMapNodes spiderfy", () => { + it("re-applies spiderfy to clusters when the dataset resets", () => { + const mapRef = { current: stubMap() }; + const iconRef = { current: null }; + const geojson = { type: "FeatureCollection" as const, features: [] }; + const { rerender } = renderHook( + ({ resetKey }) => + useMapNodes(mapRef, iconRef, true, geojson, true, "dark", true, () => {}, null, false, null, resetKey), + { initialProps: { resetKey: "YOW:all" } }, + ); + const before = spiders.length; + + rerender({ resetKey: "YYZ:all" }); + + const latest = spiders[spiders.length - 1]!; + expect(spiders.length).toBe(before + 1); + expect(latest.applyTo).toHaveBeenCalled(); + expect(latest.unspiderfyAll).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 22992a2..6c9424a 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; +import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; @@ -58,6 +59,18 @@ beforeEach(() => { }); describe("NodeDetailPanel neighbors", () => { + it("shows the server's possibly-foreign indication", async () => { + mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true }); + renderPanel(); + expect(await screen.findByText("Possibly foreign")).toBeInTheDocument(); + }); + + it.each([false, undefined])("does not label unflagged nodes as foreign (%s)", async (possiblyForeign) => { + mockGetNode.mockResolvedValue({ ...node, possiblyForeign }); + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText("Possibly foreign")).not.toBeInTheDocument(); + }); it("lists each neighbor's name in a Neighbors section", async () => { mockGetNodeNeighbors.mockResolvedValue([neighbor("n-1", "Neighbor A"), neighbor("n-2", "Neighbor B")]); @@ -87,6 +100,18 @@ describe("NodeDetailPanel neighbors", () => { }); }); +describe("NodeDetailPanel location", () => { + it("hides Lat/Lng but keeps the source for a 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0, locationSource: "advert" }); + + renderPanel(); + + expect(await screen.findByText("Source")).toBeInTheDocument(); + expect(screen.queryByText("Lat")).not.toBeInTheDocument(); + expect(screen.queryByText("Lng")).not.toBeInTheDocument(); + }); +}); + describe("NodeDetailPanel clock drift", () => { it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); @@ -113,3 +138,92 @@ describe("NodeDetailPanel clock drift", () => { expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); }); }); + +describe("NodeDetailPanel View on map", () => { + function renderWithMap(onViewOnMap?: (lat: number, lng: number) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + } + + it("sends the node's coordinates", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(45.42153, -75.69719); + }); + + it("is not offered for a node without a location", async () => { + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is not offered when the caller has no map to open", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + renderWithMap(); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("shows the French label", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + await act(() => i18n.changeLanguage("fr")); + renderWithMap(vi.fn()); + expect(await screen.findByRole("button", { name: "Voir sur la carte" })).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); + }); + + it("is not offered for an explicit 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0 }); + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is still offered when only one axis is zero", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 10 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(0, 10); + }); +}); + +describe("NodeDetailPanel recent packets", () => { + it("labels the expiring packet list as recent packets", async () => { + renderPanel(); + expect(await screen.findByText("Recent packets")).toBeInTheDocument(); + expect(await screen.findByText("No recent packets")).toBeInTheDocument(); + expect(screen.queryByText("Observations")).not.toBeInTheDocument(); + }); +}); + +describe("NodeDetailPanel in French", () => { + it("translates section titles, labels, yes/no and empty states", async () => { + mockGetNode.mockResolvedValue({ ...node, knownNeighborCount: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 }); + await act(() => i18n.changeLanguage("fr")); + renderPanel(); + + expect(await screen.findByText("Capacités")).toBeInTheDocument(); + expect(screen.getByText("Détail du nœud")).toBeInTheDocument(); + expect(screen.getByText("Chemins multi-octets")).toBeInTheDocument(); + expect(screen.getAllByText("non")).toHaveLength(2); + expect(screen.getByText("+20 s en avance")).toBeInTheDocument(); + expect(screen.getByText("Voisins (2)")).toBeInTheDocument(); + expect(await screen.findByText("Aucun voisin connu")).toBeInTheDocument(); + expect(screen.getByText("Aucun paquet récent")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copier la clé publique" })).toBeInTheDocument(); + }); + + it("translates the possibly-foreign badge", async () => { + mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true }); + await act(() => i18n.changeLanguage("fr")); + renderPanel(); + expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/nodes/NodeTable.test.tsx b/tests/features/nodes/NodeTable.test.tsx new file mode 100644 index 0000000..e7df3e4 --- /dev/null +++ b/tests/features/nodes/NodeTable.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, within, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NodeTable } from "../../../src/features/nodes/NodeTable"; +import { getNodesPage } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { WsManager } from "../../../src/api/ws-manager"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn() })); + +const fakeWsManager = { onNodeUpdate: () => () => {} } as unknown as WsManager; + +const node = (over: Partial): NodeSummary => ({ + id: "node-a", + publicKey: "aabbccdd", + nodeType: 2, + nodeTypeName: "REPEATER", + name: "Node A", + lat: null, + lng: null, + iatas: [], + knownNeighborCount: 0, + ...over, +}); + +function mount(items: NodeSummary[]) { + vi.mocked(getNodesPage).mockResolvedValue({ items, nextCursor: null, hasMore: false }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + vi.mocked(getNodesPage).mockReset(); +}); + +describe("NodeTable location column", () => { + it("shows a dash for an explicit 0/0 advert reset, not the coordinates", async () => { + mount([node({ id: "node-zero", name: "Zeroed node", lat: 0, lng: 0 })]); + const nameCell = await screen.findByText("Zeroed node"); + const row = nameCell.closest("tr")!; + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 1]).toHaveTextContent("—"); + expect(screen.queryByText("0.00, 0.00")).not.toBeInTheDocument(); + }); + + it("still shows coordinates when only one axis is zero", async () => { + mount([node({ id: "node-partial", name: "Partial node", lat: 0, lng: 10 })]); + await screen.findByText("Partial node"); + expect(screen.getByText("0.00, 10.00")).toBeInTheDocument(); + }); +}); + +describe("NodeTable IATA badge tooltip", () => { + it("shows a translated last-heard label, in English and French", async () => { + const lastHeard = Date.now() - 7 * 86_400_000; + mount([node({ id: "node-iata", name: "IATA node", iatas: [{ iata: "YOW", lastHeard }] })]); + const badge = await screen.findByText("YOW"); + const trigger = badge.parentElement!; + + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("last heard 7d ago"); + fireEvent.mouseLeave(trigger); + + await i18n.changeLanguage("fr"); + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("dernier contact il y a 7 j"); + }); +}); + +describe("NodeTable in French", () => { + it("translates column headers and filter labels", async () => { + await i18n.changeLanguage("fr"); + mount([node({ id: "node-fr", name: "Nœud FR" })]); + await screen.findByText("Nœud FR"); + for (const header of ["Nom", "Zones", "Voisins", "Position"]) { + expect(screen.getByRole("columnheader", { name: new RegExp(header) })).toBeInTheDocument(); + } + expect(screen.getByRole("toolbar", { name: "Filtres des nœuds" })).toHaveTextContent("Chemins multi-octets"); + expect(screen.getByRole("toolbar")).toHaveTextContent("Indifférent"); + }); + + it("shows the French empty state", async () => { + await i18n.changeLanguage("fr"); + mount([]); + expect(await screen.findByText("Aucun nœud")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/nodes/node-updates.test.ts b/tests/features/nodes/node-updates.test.ts index e7b5722..bc42afb 100644 --- a/tests/features/nodes/node-updates.test.ts +++ b/tests/features/nodes/node-updates.test.ts @@ -24,6 +24,20 @@ function update(overrides: Partial): WsNodeUpdate["data"] } describe("patchNodeSummary", () => { + it("applies foreign status changes without moving coordinates and clears explicit null", () => { + const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })]; + const local = patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: false }))!; + expect(local[0]!.possiblyForeign).toBe(false); + expect(local).not.toBe(list); + const unknown = patchNodeSummary(local, update({ nodeId: "a", name: "Keep", lat: 0, lng: 0, possiblyForeign: null }))!; + expect(unknown[0]!.possiblyForeign).toBeUndefined(); + expect(list[0]!.possiblyForeign).toBe(true); + }); + + it("retains the flag and list identity when an advert omits the position/classification", () => { + const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })]; + expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: undefined, lng: undefined }))).toBe(list); + }); it("returns the list unchanged (same ref) when it is undefined", () => { expect(patchNodeSummary(undefined, update({}))).toBeUndefined(); }); @@ -54,6 +68,19 @@ describe("patchNodeSummary", () => { expect(out[0]!.lng).toBe(20); }); + it("clears the position when the update sends explicit null", () => { + const list = [node({ id: "a", lat: 10, lng: 20 })]; + const out = patchNodeSummary(list, update({ nodeId: "a", lat: null, lng: null }))!; + expect(out).not.toBe(list); + expect(out[0]!.lat).toBeNull(); + expect(out[0]!.lng).toBeNull(); + }); + + it("returns the same ref when a null clear hits a node that is already unlocated", () => { + const list = [node({ id: "a", name: "Keep", lat: null, lng: null })]; + expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: null, lng: null }))).toBe(list); + }); + it("returns the same list ref when the update changes nothing (no needless repaint)", () => { const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20 })]; // re-advert with identical name + omitted coords: every field resolves back to the prev value @@ -84,12 +111,12 @@ describe("upsertNodePages", () => { const old = pages([node({ id: "a" })], [node({ id: "b" })]); const out = upsertNodePages( old, - update({ nodeId: "new1", name: "Fresh", nodeTypeName: "companion", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [] }), + update({ nodeId: "new1", name: "Fresh", nodeTypeName: "repeater", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [], possiblyForeign: true }), )!; expect(out).not.toBe(old); expect(out.pages[0]).toBe(old.pages[0]); // earlier pages keep their refs expect(out.pages[1]!.items.map((n) => n.id)).toEqual(["b", "new1"]); - expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25 }); + expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25, possiblyForeign: true }); }); it("keeps the same ref for a re-advert that changes nothing", () => { @@ -100,4 +127,10 @@ describe("upsertNodePages", () => { it("passes undefined caches through", () => { expect(upsertNodePages(undefined, update({}))).toBeUndefined(); }); + + it("inserts a fresh node with an explicit null position", () => { + const old = pages([node({ id: "a" })]); + const out = upsertNodePages(old, update({ nodeId: "new1", lat: null, lng: null }))!; + expect(out.pages[0]!.items[1]).toMatchObject({ id: "new1", lat: null, lng: null }); + }); }); diff --git a/tests/features/observers/ObserverAdverts.test.tsx b/tests/features/observers/ObserverAdverts.test.tsx new file mode 100644 index 0000000..e0a124c --- /dev/null +++ b/tests/features/observers/ObserverAdverts.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ObserverAdverts } from "../../../src/features/observers/ObserverAdverts"; +import { getObserverAdverts } from "../../../src/api/client"; +import type { AdvertObservation } from "../../../src/features/observers/types"; + +vi.mock("../../../src/api/client", () => ({ getObserverAdverts: vi.fn() })); +const mockGetObserverAdverts = vi.mocked(getObserverAdverts); + +function advert(id: number, nodeName: string): AdvertObservation { + return { id, packetHash: `hash-${id}`, payloadType: 4, payloadTypeName: "ADVERT", iata: "YVR", heardAt: 1000, snr: 7.5, rssi: -90, hopCount: 0, nodeName, nodePublicKey: "1122334455" }; +} + +function renderAdverts(onAnalyzePacket = vi.fn()) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + return { onAnalyzePacket }; +} + +beforeEach(() => mockGetObserverAdverts.mockReset()); + +describe("ObserverAdverts", () => { + it("lists adverts heard by the observer", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [advert(1, "Node Alpha"), advert(2, "Node Beta")], nextCursor: null, hasMore: false }); + renderAdverts(); + expect(screen.getByText("Adverts heard")).toBeInTheDocument(); + expect(await screen.findByText("Node Alpha")).toBeInTheDocument(); + expect(screen.getByText("Node Beta")).toBeInTheDocument(); + expect(mockGetObserverAdverts).toHaveBeenCalledWith("obs-1", { limit: 50 }); + }); + + it("analyzes the packet when an advert is clicked", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [advert(1, "Node Alpha")], nextCursor: null, hasMore: false }); + const { onAnalyzePacket } = renderAdverts(); + fireEvent.click(await screen.findByRole("button", { name: /Node Alpha/ })); + expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1", 1); + }); + + it("says when no adverts were heard", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [], nextCursor: null, hasMore: false }); + renderAdverts(); + expect(await screen.findByText("No adverts heard")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx new file mode 100644 index 0000000..39a4ef8 --- /dev/null +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -0,0 +1,98 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverComparison } from "../../../src/features/observers/ObserverComparison"; +import { getObserverActivity, getObserverComparison } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity } from "../../../src/features/stats/types"; + +const A = "11111111-1111-1111-1111-111111111111"; +const B = "22222222-2222-2222-2222-222222222222"; +const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; +const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; +const activity: ObserverActivity = { range: "168h", interval: "1h", windowStart: until - 604_800_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); +vi.mock("../../../src/api/client", async original => ({ + ...await original(), + getObserver: vi.fn(async (id: string) => ({ ...observer, id, displayName: "Roof B" })), + getObserversPage: vi.fn(async () => ({ items: [{ ...observer, id: B, displayName: "Roof B" }], hasMore: false })), + getObserverActivity: vi.fn(), getObserverComparison: vi.fn(), +})); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } }); + vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 }); +}); +function view(id = B, data = activity, anchor: number | null = until, range: "24h" | "7d" | "30d" = "7d") { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const props = { observerA: observer, activityA: data, range, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + return { ...render(, { wrapper }), props }; +} +it("aligns activity and retained overlap to the primary observer's effective window", async () => { + view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByText("19")).toBeInTheDocument(); + expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); + expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); + expect(screen.getByText("Heard by both")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Only packets the server still keeps/ })).toBeInTheDocument(); + expect(screen.getByTestId("chart").textContent).toContain('"name":"A"'); + expect(screen.getByTestId("chart").textContent).toContain('"name":"B"'); +}); +it("does not compare mismatched or old-server windows", async () => { + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 }); + view(); + expect(await screen.findByText(/Comparison not available on this server/)).toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); + expect(screen.queryByTestId("chart")).not.toBeInTheDocument(); +}); +it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing partner: %s", async id => { + view(id); + expect((await screen.findAllByText(id ? /Choose a different observer/ : /Choose an observer to compare/)).length).toBeGreaterThan(0); + expect(getObserverActivity).not.toHaveBeenCalled(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); +it("rejects a bad anchor and offers a fresh common window", async () => { + const { props } = view(B, activity, null); + expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(props.onRefresh).toHaveBeenCalled(); + expect(getObserverActivity).not.toHaveBeenCalled(); +}); +it("clears the previous partner's totals while the next request is pending", async () => { + const { props, rerender } = view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + vi.mocked(getObserverActivity).mockImplementation(() => new Promise(() => {})); + rerender(); + await waitFor(() => expect(screen.queryByText("23")).not.toBeInTheDocument()); + expect(screen.queryByText("9 distinct flood packets")).not.toBeInTheDocument(); +}); + +it("refreshes the current window without requesting the obsolete window after re-anchoring", async () => { + const { props } = view(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1)); + const activityCalls = vi.mocked(getObserverActivity).mock.calls.length; + props.onRefresh.mockReturnValue(until + 3_600_000); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + + props.onRefresh.mockReturnValue(until); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1)); + expect(getObserverComparison).toHaveBeenCalledTimes(2); +}); + +it("skips the raw-packet overlap at 30d but keeps the aligned charts", async () => { + const month: ObserverActivity = { ...activity, range: "720h", interval: "6h", windowStart: Math.floor(until / 21_600_000) * 21_600_000 - 2_592_000_000, windowEnd: Math.floor(until / 21_600_000) * 21_600_000 }; + vi.mocked(getObserverActivity).mockResolvedValue({ ...month, summary: { ...month.summary!, recordedPackets: 23 } }); + view(B, month, until, "30d"); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByTestId("chart")).toBeInTheDocument(); + expect(screen.getByText(/Packet overlap is available for 24h and 7d/)).toBeInTheDocument(); + expect(screen.queryByText("Heard by both")).not.toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx deleted file mode 100644 index 7d332ae..0000000 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { ObserverDetailPanel } from "../../../src/features/observers/ObserverDetailPanel"; -import { getObserver, getObserverAdverts } from "../../../src/api/client"; -import type { Observer, AdvertObservation } from "../../../src/features/observers/types"; - -vi.mock("../../../src/api/client", () => ({ - getObserver: vi.fn(), - getObserverAdverts: vi.fn(), -})); - -const mockGetObserver = vi.mocked(getObserver); -const mockGetObserverAdverts = vi.mocked(getObserverAdverts); - -const observer: Observer = { - id: "obs-1", - iata: "YVR", - status: "online", - publicKey: "aabbccddeeff", - firstSeen: 1, - lastSeen: 2, - observationCount: 10, - brokers: [], -}; - -function advert(id: number, nodeName: string): AdvertObservation { - return { - id, - packetHash: `hash-${id}`, - payloadType: 4, - payloadTypeName: "ADVERT", - iata: "YVR", - heardAt: 1000, - snr: 7.5, - rssi: -90, - hopCount: 0, - nodeName, - nodePublicKey: "1122334455", - }; -} - -function renderPanel(onAnalyzePacket = vi.fn()) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - render(, { wrapper }); - return { onAnalyzePacket }; -} - -beforeEach(() => { - mockGetObserver.mockReset(); - mockGetObserverAdverts.mockReset(); - mockGetObserver.mockResolvedValue(observer); - mockGetObserverAdverts.mockResolvedValue({ items: [], nextCursor: null, hasMore: false }); -}); - -describe("ObserverDetailPanel status", () => { - it("renders offline when lastStatusAt is 10 minutes old even if the API status says online", async () => { - mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 10 * 60_000 }); - - renderPanel(); - - expect(await screen.findByText("offline")).toBeInTheDocument(); - expect(screen.queryByText("online")).not.toBeInTheDocument(); - }); - - it("renders online when lastStatusAt is fresh", async () => { - mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 60_000 }); - - renderPanel(); - - expect(await screen.findByText("online")).toBeInTheDocument(); - }); -}); - -describe("ObserverDetailPanel adverts", () => { - it("lists adverts heard by the observer", async () => { - mockGetObserverAdverts.mockResolvedValue({ - items: [advert(1, "Node Alpha"), advert(2, "Node Beta")], - nextCursor: null, - hasMore: false, - }); - - renderPanel(); - - expect(await screen.findByText("Adverts heard")).toBeInTheDocument(); - expect(await screen.findByText("Node Alpha")).toBeInTheDocument(); - expect(screen.getByText("Node Beta")).toBeInTheDocument(); - expect(mockGetObserverAdverts).toHaveBeenCalledWith("obs-1", { limit: 50 }); - }); - - it("analyzes the packet when an advert row is clicked", async () => { - mockGetObserverAdverts.mockResolvedValue({ - items: [advert(1, "Node Alpha")], - nextCursor: null, - hasMore: false, - }); - - const { onAnalyzePacket } = renderPanel(); - - fireEvent.click(await screen.findByText("Node Alpha")); - expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1"); - }); -}); diff --git a/tests/features/observers/ObserverFilterBar.test.tsx b/tests/features/observers/ObserverFilterBar.test.tsx new file mode 100644 index 0000000..c5585ec --- /dev/null +++ b/tests/features/observers/ObserverFilterBar.test.tsx @@ -0,0 +1,40 @@ +import { expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ObserverFilterBar } from "../../../src/features/observers/ObserverFilterBar"; +import i18n from "../../../src/i18n"; + +function renderBar() { + return render( + , + ); +} + +it("labels the observer filters in English", () => { + renderBar(); + expect(screen.getByRole("toolbar", { name: "Observer filters" })).toBeInTheDocument(); + for (const label of ["Status", "Type", "Broker", "Scope"]) expect(screen.getByText(label)).toBeInTheDocument(); +}); + +it("labels the observer filters in French", async () => { + await i18n.changeLanguage("fr"); + renderBar(); + expect(screen.getByRole("toolbar", { name: "Filtres des observateurs" })).toBeInTheDocument(); + for (const label of ["État", "Courtier", "Portée"]) expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/par nom/)).toBeInTheDocument(); +}); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx new file mode 100644 index 0000000..43550de --- /dev/null +++ b/tests/features/observers/ObserverPage.test.tsx @@ -0,0 +1,170 @@ +import { describe, it, expect, vi } from "vitest"; +import "../../../src/i18n"; +import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; +import type { WsManager } from "../../../src/api/ws-manager"; +import type { ObserverSummary } from "../../../src/features/observers/types"; +import { ObserverPage } from "../../../src/features/observers/ObserverPage"; + +const directory: ObserverSummary[] = [ + { id: "observer-a", displayName: "Roof A", iata: "YOW", status: "online", observerType: "meshcore-ha" }, + { id: "observer-b", displayName: "Basement B", iata: "YOW", status: "offline", observerType: "RemoteTerm" }, +]; +vi.mock("../../../src/features/observers/useObserverDirectory", () => ({ + useObserverDirectory: () => ({ data: directory, isPending: false, isError: false, refetch: vi.fn() }), +})); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/api/client", () => ({ getBrokers: () => Promise.resolve([]) })); +vi.mock("../../../src/features/observers/ObserverSidebar", () => ({ + ObserverSidebar: ({ observers, onSelect }: { observers: ObserverSummary[]; onSelect: (id: string) => void }) => ( +
    + {observers.map((o) =>
  • )} +
+ ), +})); +vi.mock("../../../src/features/stats/ObserverTab", () => ({ + ObserverTab: ({ + selectedObserverId, + range, + comparison, + actions, + }: { + selectedObserverId: string; + range: string; + comparison?: { until: number | null; onRefresh: () => void }; + actions?: React.ReactNode; + }) => ( + <> +
{actions}
+

+ Dashboard {selectedObserverId} {range} +

+ {comparison && ( + <> + {String(comparison.until)} + + + )} + + ), +})); +function Location() { + const l = useLocation(); + const go = useNavigate(); + return ( + <> + {l.search} + + + + ); +} +function view(url = "?tab=Observers&iata=YOW") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + , + ); +} +const list = () => within(screen.getByRole("list", { name: "Observer list" })); + +describe("Observer destination", () => { + it("prompts for an observer next to the list until one is picked", () => { + view(); + expect(screen.getByText("Choose an observer")).toBeInTheDocument(); + expect(list().getAllByRole("button").map((b) => b.textContent)).toEqual(["Roof A", "Basement B"]); + expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument(); + }); + it("narrows the list with the filter bar", async () => { + view(); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "base" } }); + await waitFor(() => expect(list().getAllByRole("button").map((b) => b.textContent)).toEqual(["Basement B"])); + }); + it("opens a canonical dashboard beside the list and keeps the filters across Back", async () => { + view(); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "roof" } }); + await waitFor(() => expect(list().getAllByRole("button")).toHaveLength(1)); + fireEvent.click(list().getByText("Roof A")); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); + expect(list().getAllByRole("button")).toHaveLength(1); + expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); + expect(screen.getByRole("status").textContent).toContain("iata=YOW"); + fireEvent.click(screen.getByText("Browser back")); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search by name...")).toHaveValue("roof"); + fireEvent.click(screen.getByText("Browser forward")); + expect(await screen.findByRole("heading")).toHaveTextContent("observer-a"); + }); + it("restores a deep link with its range in the filter bar", async () => { + view("?tab=Observers&observer=observer-b&range=30d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); + const toolbar = screen.getByRole("toolbar", { name: "Observer filters" }); + const options = within(within(toolbar).getByRole("group", { name: "Time range" })).getAllByRole("button"); + expect(options.map(option => option.textContent)).toEqual(["24h", "7d", "30d"]); + expect(options[2]).toHaveAttribute("aria-pressed", "true"); + }); + it("puts observer actions in the dashboard header", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + await screen.findByRole("heading"); + const header = screen.getByTestId("header-actions"); + expect(within(header).getByRole("button", { name: "Compare with…" })).toBeInTheDocument(); + expect(within(header).getByRole("button", { name: "Copy observer link" })).toHaveTextContent("Copy link"); + expect(screen.getAllByRole("button", { name: "Compare with…" })).toHaveLength(1); + }); + it("closes the mobile overlay from its header, back to the list", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + await screen.findByRole("heading"); + const header = screen.getByText("Observer detail").parentElement!; + expect(header).toHaveClass("md:hidden"); + fireEvent.click(within(header).getByRole("button", { name: "Back to observers" })); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByRole("status").textContent).not.toContain("observer="); + }); + it("minimizes the mobile overlay over the list and expands for the next pick", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + const body = () => screen.getByRole("heading").closest(".overflow-auto")!; + await screen.findByRole("heading"); + expect(list().getAllByRole("button")).toHaveLength(2); + fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" })); + expect(body()).toHaveClass("hidden"); + fireEvent.click(list().getByText("Basement B")); + expect(await screen.findByRole("heading")).toHaveTextContent("observer-b"); + expect(body()).not.toHaveClass("hidden"); + }); +}); + +it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 8, 29, 12, 30)); + try { + view("?tab=Observers&observer=observer-a&range=24h"); + await screen.findByRole("heading"); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 15, 59, 59)); + fireEvent.click(screen.getByRole("button", { name: "Compare with…" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 15))); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 16, 0, 1)); + fireEvent.click(screen.getByRole("button", { name: "Refresh comparison" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 16))); + fireEvent.click(screen.getByRole("button", { name: "Close comparison" })); + expect(screen.queryByTestId("comparison-until")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).not.toHaveTextContent("compareUntil"); + } finally { + clock.mockRestore(); + } +}); + +it("rejects duplicate, malformed and future comparison anchors", async () => { + const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + viewResult.unmount(); + const future = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + future.unmount(); + const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000; + view(`?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=${hour}&compareUntil=${hour}`); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); +}); diff --git a/tests/features/observers/ObserverPicker.test.tsx b/tests/features/observers/ObserverPicker.test.tsx new file mode 100644 index 0000000..3592f3f --- /dev/null +++ b/tests/features/observers/ObserverPicker.test.tsx @@ -0,0 +1,54 @@ +import { it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverPicker } from "../../../src/features/observers/ObserverPicker"; +import { getObserversPage } from "../../../src/api/client"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/api/client", () => ({ getObserversPage: vi.fn(async () => ({ items: [], hasMore: false })) })); + +beforeEach(() => { vi.mocked(getObserversPage).mockClear(); }); + +function view(id: string, name: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +it("shows the placeholder name, not the dropdown's default All label, before a partner is chosen", () => { + view("", "Choose an observer to compare…"); + const trigger = screen.getByRole("button", { name: /Choose an observer to compare…/ }); + expect(trigger).not.toHaveTextContent("All"); +}); + +it("keeps a trailing space visible while typing and trims only the query sent to the server", async () => { + vi.useFakeTimers(); + try { + view("", "Choose an observer"); + fireEvent.click(screen.getByRole("button", { name: /Choose an observer/ })); + const input = screen.getByRole("searchbox"); + fireEvent.change(input, { target: { value: "Node " } }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(input).toHaveValue("Node "); + expect(getObserversPage).toHaveBeenCalledWith(["YOW"], { name: "Node", limit: 50 }); + } finally { + vi.useRealTimers(); + } +}); + +it("opens one searchable list and selects a result", async () => { + vi.mocked(getObserversPage).mockResolvedValue({ items: [{ id: "o1", displayName: "Rooftop", iata: "YOW", status: "online" }], hasMore: false, nextCursor: null } as never); + const onSelect = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Partner: Choose" })); + expect(screen.getByRole("searchbox")).toHaveFocus(); + fireEvent.click(await screen.findByRole("option", { name: /Rooftop.*YOW/ })); + expect(onSelect).toHaveBeenCalledWith("o1"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); +}); diff --git a/tests/features/observers/ObserverSidebar.test.tsx b/tests/features/observers/ObserverSidebar.test.tsx new file mode 100644 index 0000000..5aec84e --- /dev/null +++ b/tests/features/observers/ObserverSidebar.test.tsx @@ -0,0 +1,69 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverSidebar } from "../../../src/features/observers/ObserverSidebar"; +import { getTopObservers } from "../../../src/api/client"; +import type { ObserverSummary } from "../../../src/features/observers/types"; + +const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getTopObservers: vi.fn() })); + +const observers: ObserverSummary[] = [ + { id: "a", displayName: "Alpha", iata: "YOW", status: "online" }, + { id: "b", displayName: "Bravo", iata: "YOW", status: "offline" }, + { id: "c", displayName: "Charlie", iata: "YOW", status: "online" }, +]; + +beforeEach(() => { + vi.mocked(getTopObservers).mockReset().mockResolvedValue([ + { observerId: "c", displayName: "Charlie", iata: "YOW", observationCount: 900 }, + { observerId: "a", displayName: "Alpha", iata: "YOW", observationCount: 100 }, + ] as never); +}); + +function view({ list = observers, filtered = false, onSelect = vi.fn() } = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} range="7d" selectedId="a" onSelect={onSelect} /> + , + ); + return onSelect; +} +const names = () => screen.getAllByRole("option").map((o) => o.textContent); + +it("lists every observer busiest first, with idle ones last", async () => { + view(); + await screen.findByText("900"); + expect(names()).toEqual([expect.stringMatching(/^Charlie.*900/), expect.stringMatching(/^Alpha.*100/), expect.stringMatching(/^Bravo/)]); + expect(screen.getByRole("option", { name: /Alpha/ })).toHaveAttribute("aria-selected", "true"); + expect(getTopObservers).toHaveBeenCalledWith(["YOW"], expect.any(Number), 200); +}); + +it("sorts by name on request", async () => { + view(); + await screen.findByText("900"); + fireEvent.click(screen.getByRole("button", { name: "Name" })); + expect(names().map((n) => n?.match(/^[A-Z][a-z]+/)?.[0])).toEqual(["Alpha", "Bravo", "Charlie"]); +}); + +it("selects an observer on click", async () => { + const onSelect = view(); + fireEvent.click(await screen.findByRole("option", { name: /Charlie/ })); + expect(onSelect).toHaveBeenCalledWith("c"); +}); + +it("says when the filters match nothing", () => { + view({ list: [], filtered: true }); + expect(screen.getByText("No matches")).toBeInTheDocument(); +}); + +it("does not load activity counts for every IATA while the region is unresolved", async () => { + region.isResolved = false; + view(); + await Promise.resolve(); + expect(getTopObservers).not.toHaveBeenCalled(); + region.isResolved = true; +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx new file mode 100644 index 0000000..1d11f7e --- /dev/null +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { ObserverSummary } from "../../../src/features/observers/ObserverSummary"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types"; + +const observer = { id: "observer", displayName: "Roof", iata: "YKF", brokers: [], lastStatusAt: Date.now() - 600_000 } as unknown as Observer; + +describe("compact observer header", () => { + it("uses an accessible stale icon and omits the traffic text badge", () => { + render(); + expect(screen.getByRole("img", { name: "Status is stale" })).toHaveClass("text-warn"); + expect(screen.queryByText("Status is stale")).not.toBeInTheDocument(); + expect(screen.queryByText(/No recent recorded packets|Packet freshness unavailable/)).not.toBeInTheDocument(); + }); + it("distinguishes recent and absent status without inventing telemetry", () => { + const { rerender } = render(); + expect(screen.getByRole("img", { name: "Recent status" })).toHaveClass("text-green"); + rerender(); + expect(screen.getByRole("img", { name: "No status report" })).toHaveClass("text-text-muted"); + }); +}); + +describe("observer summary card notes", () => { + it("keeps the status icon and IATA chip on the name row", () => { + render(); + const row = screen.getByRole("heading", { level: 1 }).parentElement!; + expect(screen.getByRole("img", { name: "Status is stale" }).parentElement).toBe(row); + expect(screen.getByText("YKF").parentElement).toBe(row); + }); + + it("labels the last complete hour plainly, without a UTC window", () => { + const activity = { summary: { + recordedPackets: 5, lastCompleteHour: 3, lastCompleteHourStart: 0, lastCompleteHourEnd: 3_600_000, latestRecordedAt: null, + } } as unknown as ObserverActivity; + render(); + const tile = screen.getByText("Packets last hour").closest("li")!; + expect(tile).toHaveTextContent("3"); + expect(tile).toHaveClass("text-center"); + expect(screen.queryByText(/UTC/)).not.toBeInTheDocument(); + }); + + it("labels the noise tile as the last reading and sets the unit apart from the number", () => { + const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint]; + render(); + const tile = screen.getByText("Noise floor").closest("li")!; + expect(screen.queryByText(/Latest telemetry interval|Latest status/)).not.toBeInTheDocument(); + expect(within(tile).getByText("-91")).not.toHaveClass("text-sm"); + expect(within(tile).getByText("dBm")).toHaveClass("text-sm"); + }); +}); + +describe("observer identity panel", () => { + const full = { + ...observer, + hardwareModel: "Heltec V3", + firmwareVersion: "v1.16.0-observer-8fa3870", + softwareVersion: "meshcore/v1.16.0-observer-8fa3870", + radioFreqMhz: 910.525, radioSf: 7, radioBwKhz: 62.5, radioCr: 5, + publicKey: "04ebc137a3e94d78c376ee356293343f1751f3d9e023960af02c14c63b839a29", + firstSeen: Date.now() - 86_400_000, + brokers: [{ name: "mqtt1", lastSeenAt: Date.now() - 60_000, lastPacketAt: Date.now() - 60_000 }], + } as unknown as Observer; + + it("puts device details beside the name instead of a separate card", () => { + render(); + const panel = screen.getByRole("region", { name: "Roof" }); + expect(panel).toContainElement(screen.getByRole("heading", { level: 1, name: "Roof" })); + expect(panel).toHaveTextContent("Heltec V3"); + expect(panel).toHaveTextContent("v1.16.0-observer-8fa3870"); + expect(panel).toHaveTextContent("910.525 MHz"); + expect(panel).not.toHaveTextContent("meshcore/v1.16.0"); + expect(panel).toHaveTextContent("mqtt1"); + expect(panel).toHaveTextContent("1d ago"); + }); + + it("shortens the public key but copies all of it", () => { + const writeText = vi.fn(); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + expect(screen.getByText("04ebc137…b839a29")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy public key" })); + expect(writeText).toHaveBeenCalledWith(full.publicKey); + }); + + it("stacks observer actions in their own column of the header panel", () => { + render(} />); + const panel = screen.getByRole("region", { name: "Roof" }); + const column = within(panel).getByRole("button", { name: "Compare with…" }).parentElement!; + expect(column).toContainElement(within(panel).getByRole("button", { name: "Copy link" })); + expect(column).not.toContainElement(screen.getByRole("heading", { level: 1 })); + expect(column).toHaveClass("sm:flex-col"); + }); + + it("labels each detail", () => { + render(); + const panel = screen.getByRole("region", { name: "Roof" }); + const term = (label: string) => within(panel).getByText(label, { selector: "dt" }).nextElementSibling!; + expect(term("Device")).toHaveTextContent("Heltec V3"); + expect(term("Public key")).toHaveTextContent("04ebc137…b839a29"); + expect(term("Brokers")).toHaveTextContent("mqtt1"); + expect(term("First seen")).toHaveTextContent("1d ago"); + }); + + it("keeps a client version that adds information", () => { + render(); + expect(screen.getByRole("region", { name: "Roof" })).toHaveTextContent("meshcore-mqtt 2.1"); + }); +}); diff --git a/tests/features/observers/observer-filter.test.ts b/tests/features/observers/observer-filter.test.ts new file mode 100644 index 0000000..82db2a4 --- /dev/null +++ b/tests/features/observers/observer-filter.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { filterObservers } from "../../../src/features/observers/observer-filter"; +import type { ObserverSummary } from "../../../src/features/observers/types"; + +const now = Date.UTC(2026, 8, 30, 12); +const observers: ObserverSummary[] = [ + { id: "aaaa1111", displayName: "Roof Alpha", iata: "YOW", status: "online", observerType: "meshcore-ha", scopes: ["#yow"] }, + { id: "bbbb2222", displayName: "Bravo", iata: "YUL", status: "offline", observerType: "RemoteTerm" }, + // fetched as online, but its last status report is stale + { id: "cccc3333", iata: "YOW", status: "online", lastStatusAt: now - 10 * 60_000 }, +]; +const ids = (list: ObserverSummary[]) => list.map((o) => o.id); +const none = { search: "", status: "", type: "", scope: "" }; + +describe("filterObservers", () => { + it("keeps everything with no filters", () => { + expect(ids(filterObservers(observers, none, now))).toEqual(["aaaa1111", "bbbb2222", "cccc3333"]); + }); + it("searches the display name, falling back to the short id", () => { + expect(ids(filterObservers(observers, { ...none, search: "roof" }, now))).toEqual(["aaaa1111"]); + expect(ids(filterObservers(observers, { ...none, search: "CCCC" }, now))).toEqual(["cccc3333"]); + }); + it("filters status by recency, not just the fetched value", () => { + expect(ids(filterObservers(observers, { ...none, status: "offline" }, now))).toEqual(["bbbb2222", "cccc3333"]); + }); + it("filters by type and scope", () => { + expect(ids(filterObservers(observers, { ...none, type: "RemoteTerm" }, now))).toEqual(["bbbb2222"]); + expect(ids(filterObservers(observers, { ...none, scope: "#yow" }, now))).toEqual(["aaaa1111"]); + }); +}); diff --git a/tests/features/observers/observer-id.test.ts b/tests/features/observers/observer-id.test.ts new file mode 100644 index 0000000..32cb672 --- /dev/null +++ b/tests/features/observers/observer-id.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { OBSERVER_UUID } from "../../../src/features/observers/observer-id"; + +describe("OBSERVER_UUID", () => { + it("matches a well-formed UUID, case-insensitively", () => { + expect(OBSERVER_UUID.test("3fa85f64-5717-4562-b3fc-2c963f66afa6")).toBe(true); + expect(OBSERVER_UUID.test("3FA85F64-5717-4562-B3FC-2C963F66AFA6")).toBe(true); + }); + + it("rejects the nil UUID and malformed strings", () => { + expect(OBSERVER_UUID.test("00000000-0000-0000-0000-000000000000")).toBe(false); + expect(OBSERVER_UUID.test("not-a-uuid")).toBe(false); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts new file mode 100644 index 0000000..93588fb --- /dev/null +++ b/tests/features/observers/observer-navigation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; + +describe("observerDestination", () => { + it("normalizes legacy analytics links without losing region or range", () => { + const p = observerDestination( + new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"), + "abc", + ); + expect(p.get("tab")).toBe("Observers"); + expect(p.get("observer")).toBe("abc"); + expect(p.get("range")).toBe("30d"); + expect(p.get("iata")).toBe("YOW"); + expect(p.has("observerId")).toBe(false); + expect(p.has("statsTab")).toBe(false); + }); + + it("keeps the open packet analyzer, drops other tabs' selectors and defaults the range to 7d", () => { + const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&observation=5&path=p&node=n&route=r"), "b"); + expect(Object.fromEntries(p)).toEqual({ hash: "abc", analyze: "1", observation: "5", path: "p", tab: "Observers", observer: "b", range: "7d" }); + }); + + it("drops the analytics compare params so they don't ride along into the dashboard URL", () => { + const p = observerDestination(new URLSearchParams("compareA=x&compareB=y&compareSince=1"), "b"); + expect(p.has("compareA")).toBe(false); + expect(p.has("compareB")).toBe(false); + expect(p.has("compareSince")).toBe(false); + }); + + it("drops the analytics compare end but keeps it for an open observer comparison", () => { + expect(observerDestination(new URLSearchParams("compareA=x&compareB=y&compareSince=1&compareUntil=2"), "b").has("compareUntil")).toBe(false); + const comparing = observerDestination(new URLSearchParams("observer=a&compareWith=c&compareUntil=2"), "b"); + expect(comparing.get("compareWith")).toBe("c"); + expect(comparing.get("compareUntil")).toBe("2"); + }); +}); + +describe("observerRange", () => { + it("accepts the ranges the server can serve", () => { + expect(observerRange("24h")).toBe("24h"); + expect(observerRange("7d")).toBe("7d"); + expect(observerRange("30d")).toBe("30d"); + }); + + it("falls back to 7d for unknown or retired values", () => { + expect(observerRange("invalid")).toBe("7d"); + expect(observerRange("3d")).toBe("7d"); + expect(observerRange(null)).toBe("7d"); + }); +}); diff --git a/tests/features/observers/observer-stats.test.ts b/tests/features/observers/observer-stats.test.ts new file mode 100644 index 0000000..1f79341 --- /dev/null +++ b/tests/features/observers/observer-stats.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { getStats, observerNoiseFloor } from "../../../src/features/observers/observer-stats"; + +describe("getStats", () => { + it("returns null when metadata is missing", () => { + expect(getStats(undefined)).toBeNull(); + }); + + it("returns null when stats is not an object", () => { + expect(getStats({ stats: "nope" })).toBeNull(); + }); + + it("returns the stats object when present", () => { + expect(getStats({ stats: { noise_floor: -95, queue_len: 3 } })).toEqual({ noise_floor: -95, queue_len: 3 }); + }); +}); + +describe("observerNoiseFloor", () => { + it("returns null when statusMetadata is missing", () => { + expect(observerNoiseFloor({ statusMetadata: undefined })).toBeNull(); + }); + + it("returns null when noise_floor is not a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: NaN } } })).toBeNull(); + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: "cold" } } })).toBeNull(); + }); + + it("returns the noise floor when it is a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: -102.5 } } })).toBe(-102.5); + }); +}); diff --git a/tests/features/packets/ObservationCard.test.tsx b/tests/features/packets/ObservationCard.test.tsx new file mode 100644 index 0000000..305b7ef --- /dev/null +++ b/tests/features/packets/ObservationCard.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ObservationCard } from "../../../src/features/packets/ObservationCard"; +import type { Observation } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +const obs = (over: Partial = {}): Observation => ({ + id: 1, observerId: "o1", observerName: "Observer 1", iata: "YVR", + heardAt: 1700000000, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const tokens = (el: Element) => (el.getAttribute("class") ?? "").split(/\s+/); + +describe("ObservationCard", () => { + // jsdom can't measure layout, so these assert the class tokens that encode the behavior. + it("lets a long observer name truncate instead of overflowing the card", () => { + render(); + + const name = screen.getByText("a-very-long-observer-station-name"); + expect(tokens(name)).toEqual(expect.arrayContaining(["flex-1", "min-w-0", "truncate"])); + }); + + it("shows the observer's radio settings", () => { + render(); + expect(screen.getByText("910.525 MHz")).toBeInTheDocument(); + expect(screen.getByText("SF7")).toBeInTheDocument(); + expect(screen.getByText("62.5 kHz")).toBeInTheDocument(); + expect(screen.getByText("CR 4/5")).toBeInTheDocument(); + }); + + it("lays the four stats out as equal grid columns that can shrink", () => { + render(); + + const stats = screen.getByText("SNR").parentElement!.parentElement!; + expect(tokens(stats)).toEqual(expect.arrayContaining(["grid", "grid-cols-4"])); + for (const label of ["SNR", "RSSI", "Prop", "Hops"]) { + expect(tokens(screen.getByText(label).parentElement!)).toContain("min-w-0"); + } + }); + + // The left edge mirrors the SNR coloring the desktop table applies to its SNR column. + it.each([ + [12, "border-l-green"], + [7, "border-l-warn"], + [2, "border-l-danger"], + ])("colors the left edge by signal level (snr %s → %s)", (snr, cls) => { + const { container } = render(); + expect(tokens(container.firstElementChild!)).toContain(cls); + }); + + it("keeps a neutral edge when SNR is unknown", () => { + const { container } = render(); + expect(tokens(container.firstElementChild!)).toContain("border-l-primary"); + }); + + it("lets selection override the signal edge", () => { + const { container } = render(); + const cls = tokens(container.firstElementChild!); + expect(cls).toEqual(expect.arrayContaining(["border-l-primary", "bg-primary/6"])); + expect(cls).not.toContain("border-l-green"); + }); + + // Guards the analyzer-drawer usage: content and click-through must survive the layout change. + it("still renders IATA, timestamp, path row and fires onClick", () => { + const onClick = vi.fn(); + render( + , + ); + + expect(screen.getByText("YVR")).toBeInTheDocument(); + expect(screen.getByText("8.50")).toBeInTheDocument(); + expect(screen.getByText("-108")).toBeInTheDocument(); + expect(screen.getByText("41")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Observer 1")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + // the server swaps a TRACE's pathBytes to its hop hashes; per-hop SNR comes from the parsed payload + it("renders a TRACE path as resolvable hops with each hop's SNR underneath", () => { + render( + , + ); + + expect(screen.getByText("Path")).toBeInTheDocument(); + expect(screen.queryByText("ABCDEF")).not.toBeInTheDocument(); + for (const hop of ["AB", "CD", "EF"]) expect(screen.getByText(hop)).toBeInTheDocument(); + expect(screen.getByText("AB").className).toContain("text-green"); + expect(screen.getByText("-5.00 dB")).toBeInTheDocument(); + expect(screen.getByText("7.25 dB")).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("keeps non-trace paths free of SNR sub-lines", () => { + render(); + expect(screen.queryByText("-")).not.toBeInTheDocument(); + }); + + it("labels the stats and path in French", async () => { + await i18n.changeLanguage("fr"); + render(); + for (const label of ["Prop.", "Sauts", "Radio", "Trajet"]) expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/ObservationTable.test.tsx b/tests/features/packets/ObservationTable.test.tsx index d82d95f..6bca62e 100644 --- a/tests/features/packets/ObservationTable.test.tsx +++ b/tests/features/packets/ObservationTable.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, within } from "@testing-library/react"; import { ObservationTable } from "../../../src/features/packets/ObservationTable"; +import i18n from "../../../src/i18n"; import type { Observation } from "../../../src/types/api"; const obs = (id: number, over: Partial = {}): Observation => ({ @@ -97,3 +98,13 @@ describe("ObservationTable", () => { expect(cell.className).toContain("text-text-dim"); }); }); + +describe("ObservationTable in French", () => { + it("translates the column headers", async () => { + await i18n.changeLanguage("fr"); + render( {}} />); + for (const h of ["Observateur", "Zone", "Reçu", "Prop.", "Sauts", "Trajet"]) { + expect(screen.getByRole("columnheader", { name: h })).toBeInTheDocument(); + } + }); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8dc1853..b426ef6 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import i18n from "../../../src/i18n"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; import type { PacketDetail } from "../../../src/types/api"; @@ -11,6 +12,13 @@ function LocationProbe() { } describe("PacketAnalyzerDrawer close", () => { + it("leaves the originating packet URL unchanged when used in an overlay", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Close analyzer")); + expect(screen.getByTestId("search")).toHaveTextContent("hash=origin&analyze=1&observation=7"); + expect(onClose).toHaveBeenCalledOnce(); + }); it("removes ?analyze but keeps ?hash, and calls onClose", () => { const onClose = vi.fn(); render( @@ -30,6 +38,139 @@ describe("PacketAnalyzerDrawer close", () => { }); }); +describe("packet reception evidence", () => { + const reports = () => { + const d = makeDetail([hop("a", -79, 43), hop("b", -75, 45)]); + d.observations[0].pathBytes = "aabb"; + d.observations[0].observerName = "Alpha"; + d.observations.push({ ...d.observations[0], id: 2, observerId: "beta", observerName: "Beta" }); + return d; + }; + it("restores a shared report and targets its observer and map", () => { + const onViewObserver = vi.fn(); const onViewPath = vi.fn(); + render( {}} onViewObserver={onViewObserver} onViewPath={onViewPath} />); + const section = screen.getByRole("region", { name: "Observations" }); + expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open"); + const beta = within(section).getByText(/Beta/).closest("li")!; + expect(within(beta).getByRole("button", { name: /Select observation.*Beta/ })).toHaveAttribute("aria-pressed", "true"); + expect(within(beta).getByText("SNR")).toBeInTheDocument(); + expect(screen.queryByText(/^Observations \(/)).not.toBeInTheDocument(); + for (const name of ["Inspect observer", "Map path"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary"); + fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta", 2); + fireEvent.click(within(beta).getByRole("button", { name: "Map path" })); expect(onViewPath).toHaveBeenCalledWith("beta"); + expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep"); + }); + it("collapses the observations list unless a report was picked", () => { + render( {}} />); + const section = screen.getByRole("region", { name: "Observations" }); + const toggle = within(section).getByRole("button", { name: "Observations" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(section).toHaveTextContent("Heard by 2 observers"); + expect(within(section).queryByText("Path 1")).not.toBeInTheDocument(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(within(section).getByText("Path 1")).toBeInTheDocument(); + }); + it("keeps the evidence notes in an info tip instead of paragraphs", () => { + render( {}} onViewPath={() => {}} />); + const section = screen.getByRole("region", { name: "Observations" }); + expect(within(section).getByRole("button", { name: /Grouped by the repeaters.*One entry per observer/ })).toBeInTheDocument(); + expect(within(section).queryByText(/Grouped by the repeaters/, { selector: "p" })).not.toBeInTheDocument(); + expect(within(section).queryByText(/One entry per observer/, { selector: "p" })).not.toBeInTheDocument(); + expect(within(section).queryByText(/Needs at least two repeaters/, { selector: "p" })).not.toBeInTheDocument(); + }); + it("shows the selected report only in the observations list, above the raw packet", () => { + render( {}} />); + const list = screen.getByRole("region", { name: "Observations" }); + expect(screen.queryByText("Observation", { exact: true })).not.toBeInTheDocument(); + expect(within(list).getByRole("button", { name: /Select observation.*Alpha/ })).toHaveAttribute("aria-pressed", "true"); + const raw = screen.getByText("Raw Packet"); + expect(list.compareDocumentPosition(raw) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it("reports an expired selection without quietly showing another report", () => { + render( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available"); + expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); + }); + it("keeps route evidence selections strict without requiring packet URL state", () => { + render( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available"); + expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); + }); + it("explains TRACE and missing-report semantics in French", async () => { + const d = reports(); d.header.payloadType = PayloadType.TRACE; d.observations = []; + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByRole("region", { name: "Observations" })).toHaveTextContent("itinéraire prévu"); + expect(screen.getByText("Aucune observation pour ce paquet.")).toBeInTheDocument(); + }); + it("keeps a later selected path visible while bounding the initial list", () => { + const d = reports(); + d.observations = Array.from({ length: 12 }, (_, i) => ({ ...d.observations[0], id: i + 1, observerId: `observer-${i}`, pathBytes: i.toString(16).padStart(4, "0") })); + render( {}} />); + const section = screen.getByRole("region", { name: "Observations" }); + expect(within(section).getByText("Path 12").closest("details")).toHaveAttribute("open"); + expect(within(section).queryByText("Path 4")).not.toBeInTheDocument(); + fireEvent.click(within(section).getByRole("button", { name: "Load more (9 left)" })); + expect(within(section).getByText("Path 4")).toBeInTheDocument(); + expect(within(section).getByText("Path 6")).toBeInTheDocument(); + expect(within(section).queryByText("Path 7")).not.toBeInTheDocument(); + }); +}); + +describe("PacketAnalyzerDrawer in French", () => { + it("translates the empty state and close control", async () => { + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByText("Analyseur de paquets")).toBeInTheDocument(); + expect(screen.getByText("Sélectionnez un paquet à analyser")).toBeInTheDocument(); + expect(screen.getByLabelText("Fermer l’analyseur")).toBeInTheDocument(); + }); + it("translates the structure section and payload fields", async () => { + const d = makeDetail([]); + d.observations[0].pathBytes = "aabb"; + d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" }; + d.transportCodes = { regionCode: 49240, subRegionCode: 0 }; + d.parsedPayload = { type: "TEXT_MESSAGE", destinationHash: "aa", sourceHash: "bb", ciphertext: "0011" }; + await i18n.changeLanguage("fr"); + render( {}} />); + for (const text of ["Résumé", "Paquet brut", "Structure du paquet", "Octet d’en-tête", "Longueur du trajet", "Données du trajet", "Détail de la charge utile"]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("Code de portée 58C0 (2B) = 49240"); + expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("aucune portée connue"); + expect(screen.getByText("sauts=2")).toBeInTheDocument(); + expect(screen.getByText("Chiffré — clé non disponible")).toBeInTheDocument(); + }); +}); + +describe("transport scope", () => { + const transport = () => { + const d = makeDetail([]); + d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" }; + d.transportCodes = { regionCode: 49240, subRegionCode: 0 }; + d.scope = "#yow"; + return d; + }; + it("boxes the summary scope chip like the type badge", () => { + render( {}} />); + const chip = within(screen.getByText("Summary").parentElement!).getByText("#yow"); + expect((chip.getAttribute("class") ?? "").split(/\s+/)).toEqual(expect.arrayContaining(["border", "px-2", "py-0.5"])); + }); + it("breaks the transport codes out of the frame and shows the matched scope", () => { + render( {}} />); + const field = screen.getByText("Transport Codes").parentElement!; + expect(field).toHaveTextContent("Scope code 58C0 (2B) = 49240"); + expect(within(field).getByText("#yow")).toBeInTheDocument(); + expect(field).toHaveTextContent("Code 2 0000 (2B) = 0"); + }); + it("says when a scope code matched no known scope", () => { + const d = transport(); delete d.scope; + render( {}} />); + expect(screen.getByText("Transport Codes").parentElement!).toHaveTextContent("no known scope"); + }); +}); + const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); function makeDetail(resolvedPath: unknown[]): PacketDetail { @@ -76,7 +217,7 @@ describe("PacketAnalyzerDrawer view-path button", () => { {}} onViewPath={onViewPath} /> , ); - const btn = screen.getByRole("button", { name: /view path on map/i }); + const btn = screen.getByRole("button", { name: /map all paths/i }); expect(btn).toBeEnabled(); fireEvent.click(btn); expect(onViewPath).toHaveBeenCalledOnce(); @@ -88,7 +229,7 @@ describe("PacketAnalyzerDrawer view-path button", () => { {}} onViewPath={() => {}} /> , ); - expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /map all paths/i })).toBeDisabled(); }); }); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 037489c..4d47583 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; +import i18n from "../../../src/i18n"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; const pkt = (observer?: LatestObserver): PacketSummary => ({ @@ -13,6 +14,16 @@ const obs = (over: Partial = {}): LatestObserver => ({ id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, }); +afterEach(() => vi.restoreAllMocks()); + +const ambiguousSource = { + confidence: "ambiguous" as const, + nodes: [ + { id: "source-a", name: "Alpha", publicKey: "aa010203" }, + { id: "source-b", name: "Beta", publicKey: "aa040506" }, + ], +}; + describe("PacketEndpoints", () => { it("renders a single n/a when there is no observer at all", () => { render(); @@ -62,6 +73,78 @@ describe("PacketEndpoints", () => { render(); - expect(screen.getByText("?")).toBeInTheDocument(); + const chip = screen.getByText("?"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveAttribute("title", "No path resolution available"); + }); + + it("translates the no-resolution title to French", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("?")).toHaveAttribute("title", "Aucune résolution de chemin disponible"); + await act(() => i18n.changeLanguage("en")); + }); + + it("shows a missing endpoint as n/d in French", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("n/d")).toBeInTheDocument(); + }); + + it("shows an advert as its single source node with no destination", () => { + const advert = { ...pkt(obs({ resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "Fuzz HQ" }] } })), payloadType: 4, summary: "Fuzz HQ" }; + render(); + expect(screen.getAllByText("Fuzz HQ")).toHaveLength(1); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); + + it("falls back to the summary name for an advert with no resolved source", () => { + render(); + expect(screen.getByText("Fuzz HQ").className).toContain("text-green"); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); + + it("replaces n/a with the summary when there are no endpoints", () => { + render(); + expect(screen.getByText("TRACE 2ca2a79c").className).toContain("text-text-muted"); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); + + it("shows all ambiguous candidates on hover, with an additional-match count", () => { + render(); + const trigger = screen.getByText("Alpha +1").parentElement!; + fireEvent.mouseEnter(trigger); + const tip = screen.getByRole("tooltip"); + expect(within(tip).getByText("Alpha")).toBeInTheDocument(); + expect(within(tip).getByText("Beta")).toBeInTheDocument(); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); + expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); + }); + + it("keeps the containing packet row as the only keyboard button", () => { + render(); + expect(screen.getAllByRole("button")).toHaveLength(1); + const chip = screen.getByText("Alpha +1").parentElement!; + expect(chip).not.toHaveAttribute("tabindex"); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); + }); + + it("opens all candidates on touch without activating the packet row", () => { + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })); + const select = vi.fn(); + render(
); + fireEvent.click(screen.getByText("Alpha +1").parentElement!); + expect(within(screen.getByRole("tooltip")).getByText("Beta")).toBeInTheDocument(); + expect(select).not.toHaveBeenCalled(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("renders resolved endpoints without requiring physical path metadata", () => { + render(); + expect(screen.getByText("Source")).toBeInTheDocument(); }); }); diff --git a/tests/features/packets/PacketExpansion.mobile.test.tsx b/tests/features/packets/PacketExpansion.mobile.test.tsx new file mode 100644 index 0000000..4761770 --- /dev/null +++ b/tests/features/packets/PacketExpansion.mobile.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; +import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; + +const usePacketDetail = vi.fn(); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +// Force the below-md media query so the expansion takes its mobile path. +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /hover/.test(query) ? !matches : matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 2, ...over, +}); + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const header = (payloadType = PayloadType.ADVERT) => ({ + raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", + payloadType, payloadTypeName: "ADVERT", payloadVersion: 1, +}); + +const detail = (observations: Observation[], payloadType = PayloadType.ADVERT): PacketDetail => + ({ packetHash: "AA11", header: header(payloadType), observations } as unknown as PacketDetail); + +const props = { + packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, + selectedObservationId: null, onSelectObservation: () => {}, +}; + +const tokens = (el: Element) => (el.getAttribute("class") ?? "").split(/\s+/); + +beforeEach(() => { + usePacketDetail.mockReset(); + setMobile(true); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("PacketExpansion below md", () => { + // The 8-column table is ~407px wide; inside the expansion on a 390px phone that pushes Path, the + // last column, off-screen behind a nested horizontal scroll. Cards give it a full-width row. + it("renders observation cards instead of the wide table", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1), obs(2)]) }); + render(); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("shows each observation's resolved path outside of any table", () => { + usePacketDetail.mockReturnValue({ + data: detail([obs(1, { pathBytes: "414243", pathLength: { raw: "414243", hashSize: 1, hopCount: 3 } })]), + }); + render(); + + for (const hop of ["41", "42", "43"]) { + // a table cell would put it behind the nested horizontal scroll, which is the bug + expect(screen.getByText(hop).closest("table")).toBeNull(); + } + }); + + it("opens the analyzer on the tapped observation card, selecting it first", () => { + const onOpenAnalyzer = vi.fn(); + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: detail([obs(1), obs(2)]) }); + render(); + + fireEvent.click(screen.getByText("Observer 2")); + + expect(onSelectObservation).toHaveBeenCalledWith(2); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + }); + + // GET /packets/{hash} swaps a TRACE's pathBytes to its hop hashes; the SNR per hop rides on parsedPayload. + it("shows a TRACE packet's hops with the per-hop SNR from its payload", () => { + usePacketDetail.mockReturnValue({ + data: { + ...detail([obs(1, { pathBytes: "abcd", pathLength: { raw: "02", hashSize: 1, hopCount: 2 } })], PayloadType.TRACE), + parsedPayload: { type: "TRACE", pathHashes: ["ab", "cd"], snrValues: [8] }, + }, + }); + render(); + + expect(screen.queryByText("Path SNR")).not.toBeInTheDocument(); + expect(screen.getByText("AB")).toBeInTheDocument(); + expect(screen.getByText("CD")).toBeInTheDocument(); + expect(screen.getByText("8.00 dB")).toBeInTheDocument(); + }); + + // the expansion is the unfolded bottom half of the tapped card, not a separate square slab + it("frames itself as a continuation of the packet card", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const cls = tokens(screen.getByTestId("packet-expansion")); + expect(cls).toEqual(expect.arrayContaining(["border", "border-t-0", "rounded-b-md", "px-3.5"])); + }); + + // the card header directly above already names the latest observer, so the strip's copy is noise + it("hides the observer line that duplicates the card header", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + + const observerLine = screen.getByText("n/a").parentElement!; + expect(tokens(observerLine)).toEqual(expect.arrayContaining(["hidden", "md:inline"])); + }); + + it("sizes the summary strip and its action for touch", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const button = screen.getByRole("button", { name: "Map all paths" }); + expect(tokens(button)).toEqual( + expect.arrayContaining(["w-full", "md:w-auto", "py-2", "md:py-0.5", "text-[11px]", "md:text-[10px]"]), + ); + expect(tokens(button.parentElement!)).toEqual(expect.arrayContaining(["text-[11px]", "md:text-[10px]"])); + }); + + it("gives the retry action the same touch sizing", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + + expect(tokens(screen.getByRole("button", { name: /retry/i }))).toEqual( + expect.arrayContaining(["py-2", "md:py-0.5"]), + ); + }); + + // a capped inner scroller inside the page scroller is a touch scroll trap; on mobile the + // observation list flows in the page instead + it("does not cap the observation list behind an inner scroller", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const cls = (screen.getByTestId("observation-scroller").getAttribute("class") ?? "").split(/\s+/); + expect(cls).not.toContain("max-h-[360px]"); + expect(cls).not.toContain("overflow-y-auto"); + }); + + it("still renders the table at md and above", () => { + setMobile(false); + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index 889a7df..bf6d5f6 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; import { PayloadType, RouteType } from "../../../src/types/enums"; +import i18n from "../../../src/i18n"; const usePacketDetail = vi.fn(); vi.mock("../../../src/features/packets/usePacketDetail", () => ({ @@ -21,7 +22,7 @@ const obs = (id: number, over: Partial = {}): Observation => ({ sourceBroker: "b1", resolvedPath: [], ...over, }); -// minimal header so buildPacketPaths(data) (View path on map's hasPath check) doesn't crash on a +// minimal header so buildPacketPaths(data) (Map all paths's hasPath check) doesn't crash on a // partial detail fixture — any non-TRACE payload type does const header = () => ({ raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.ADVERT, payloadTypeName: "ADVERT", payloadVersion: 1 }); @@ -90,6 +91,24 @@ describe("PacketExpansion", () => { expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument(); }); + // guards against mobile card styling leaking up: at md+ the expansion stays the flat + // left-accented strip under the grid row + it("keeps the desktop strip framing at md and above", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + + const cls = (screen.getByTestId("packet-expansion").getAttribute("class") ?? "").split(/\s+/); + expect(cls).toEqual(expect.arrayContaining(["md:border-0", "md:border-l-2", "md:rounded-none", "md:pl-6"])); + }); + + it("keeps the observation scroll cap at md and above", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + + const cls = (screen.getByTestId("observation-scroller").getAttribute("class") ?? "").split(/\s+/); + expect(cls).toEqual(expect.arrayContaining(["md:max-h-[360px]", "md:overflow-y-auto"])); + }); + it("renders the timing strip from the summary without waiting for the fetch", () => { usePacketDetail.mockReturnValue({ isLoading: true }); render(); @@ -140,28 +159,28 @@ describe("PacketExpansion", () => { expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); }); - it("disables View path on map while loading", () => { + it("disables Map all paths while loading", () => { usePacketDetail.mockReturnValue({ isLoading: true }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled(); }); - it("disables View path on map on error", () => { + it("disables Map all paths on error", () => { usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled(); }); - it("enables View path on map once the fetch resolves with a drawable path", () => { + it("enables Map all paths once the fetch resolves with a drawable path", () => { usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).not.toBeDisabled(); }); - it("disables View path on map when the loaded detail has no resolvable path", () => { + it("disables Map all paths when the loaded detail has no resolvable path", () => { usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } }); render(); - const viewPathBtn = screen.getByRole("button", { name: "View path on map" }); + const viewPathBtn = screen.getByRole("button", { name: "Map all paths" }); expect(viewPathBtn).toBeDisabled(); expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map"); }); @@ -170,7 +189,18 @@ describe("PacketExpansion", () => { const onViewPath = vi.fn(); usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); - fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + fireEvent.click(screen.getByRole("button", { name: "Map all paths" })); expect(onViewPath).toHaveBeenCalledTimes(1); }); + + it("renders the timing strip and error state in French", async () => { + await i18n.changeLanguage("fr"); + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Impossible de charger les observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Carte de tous les trajets" })).toBeInTheDocument(); + expect(screen.getByText(/^premier/)).toBeInTheDocument(); + expect(screen.getByText("n/d")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index d704489..6601562 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; +import i18n from "../../../src/i18n"; import type { WsManager } from "../../../src/api/ws-manager"; import type { PacketSummary, PacketDetail } from "../../../src/types/api"; import type { WsPacketObservation } from "../../../src/types/ws"; @@ -14,6 +15,7 @@ const basePackets = () => ({ acknowledgeNewPackets: () => {}, fetchNextPage: () => {}, hasNextPage: false, + isFetching: false, isFetchingNextPage: false, isLoading: false, isError: false, @@ -28,7 +30,8 @@ vi.mock("../../../src/features/packets/usePackets", () => ({ usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); -const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined })); +const baseDetail = () => ({ data: undefined as PacketDetail | undefined, isLoading: false, isError: false, error: null as Error | null, refetch: vi.fn() }); +const usePacketDetail = vi.fn(baseDetail); vi.mock("../../../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => usePacketDetail(hash as never), })); @@ -65,8 +68,8 @@ vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ }) => (
{String(expandedHash)}
- - + + {packets.map((p) => ( + {}} onAnalyzePacket={inspect} onViewObserver={observer} />; +} +function mount({ url = "/?tab=Routes", listed }: { url?: string; listed?: KnownRoute } = {}) { + render(); +} +beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); + +describe("route detail", () => { + it("lists recent packets as far back as the server keeps them, with no window picker", async () => { + mount({ url: "/?routeRange=7d&routeSince=1&routeUntil=2" }); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "720h", limit: 50 }, expect.anything()); + expect(screen.queryByRole("button", { name: "24h" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + }); + + it("shows the route's lifetime summary from the listed route before packets load", () => { + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + mount({ listed: route }); + expect(screen.getByText("1,234")).toBeInTheDocument(); + expect(screen.getByText("North")).toBeInTheDocument(); + expect(screen.getByText("First seen")).toBeInTheDocument(); + }); + + it("fills the summary from the packet response when opened from a shared link", async () => { + mount(); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + }); + + it("keeps the matching caveats in an info tip instead of inline text", async () => { + mount(); + await screen.findByText("Garden"); + expect(screen.getByRole("button", { name: /don't prove the same physical path/ })).toBeInTheDocument(); + }); + + it("opens the exact report and observer, and pins cursor pages", async () => { + mount(); + await screen.findByText("Garden"); + fireEvent.click(screen.getByRole("button", { name: "Inspect packet" })); + expect(inspect).toHaveBeenCalledWith("aabb", 17); + fireEvent.click(screen.getByRole("button", { name: "Inspect observer" })); + expect(observer).toHaveBeenCalledWith("o1"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { pageCursor: "pinned-cursor", limit: 50 }, expect.anything())); + }); + + it("replaces results when the route changes, even while its request is pending", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByText("Other route")); + expect(screen.queryByText("Garden")).not.toBeInTheDocument(); + }); + + it("says when no observations on the route are still kept", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + mount({ listed: route }); + expect(await screen.findByText(/No observations on this route are still kept/)).toBeInTheDocument(); + }); + + it("does not fetch packets for a legacy route without a path key", () => { + render( {}} />); + expect(screen.getByText("North")).toBeInTheDocument(); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); + + it("distinguishes an unmatchable route and translates the panel", async () => { + await i18n.changeLanguage("fr"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, matchAvailable: false, items: [], hasMore: false }); + mount(); + expect(await screen.findByText(/Ce trajet ne peut pas être comparé/)).toBeInTheDocument(); + await i18n.changeLanguage("en"); + }); + + it("keeps a failed next page separate from the reports already loaded", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockRejectedValue(new Error("offline")); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load"); + expect(screen.getByText("Garden")).toBeInTheDocument(); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); + + it("colours a good SNR report the same way every other SNR cell does", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [{ ...page.items[0]!, snr: 12 }], hasMore: false }); + mount(); + expect(await screen.findByText("12.00")).toHaveClass("text-green"); + }); + + it("translates the summary section titles and labels", async () => { + await i18n.changeLanguage("fr"); + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + mount({ listed: route }); + expect(screen.getByText("Détail du trajet")).toBeInTheDocument(); + expect(screen.getByText("Résumé")).toBeInTheDocument(); + expect(screen.getByText("Horodatages")).toBeInTheDocument(); + expect(screen.getByText("Première réception")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index 1a3fd64..60bb649 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -1,9 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { RouteTable } from "../../../src/features/routes/RouteTable"; -import { RegionProvider } from "../../../src/hooks/useRegion"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { RegionProvider, useRegionSelection } from "../../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../../src/hooks/region-selection"; import { getKnownRoutesPage, @@ -11,8 +12,10 @@ import { searchCrossIATARoutes, getIatas, getRegions, + getRegion, } from "../../../src/api/client"; import type { KnownRoute, CrossIATARoute } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; vi.mock("../../../src/api/client", () => ({ getKnownRoutesPage: vi.fn(), @@ -20,8 +23,11 @@ vi.mock("../../../src/api/client", () => ({ searchCrossIATARoutes: vi.fn(), getIatas: vi.fn(), getRegions: vi.fn(), + getRegion: vi.fn(), })); +vi.mock("../../../src/features/routes/RouteDetailPanel", () => ({ RouteDetailPanel: ({ pathKey }: { pathKey?: string }) =>
{pathKey}
})); + const mockGetKnownRoutesPage = vi.mocked(getKnownRoutesPage); const mockSearchKnownRoutes = vi.mocked(searchKnownRoutes); const mockSearchCrossIATARoutes = vi.mocked(searchCrossIATARoutes); @@ -34,13 +40,13 @@ function renderTable(selection = ALL_REGIONS) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + {children} ); render(, { wrapper }); } -const openIataPicker = () => fireEvent.click(screen.getByText("IATA")); +const openIataPicker = () => fireEvent.click(screen.getByText("Areas")); const checkIata = (code: string) => fireEvent.click(screen.getByRole("option", { name: new RegExp(code) })); beforeEach(() => { @@ -139,3 +145,71 @@ describe("RouteTable search", () => { expect(await screen.findByText("42")).toBeInTheDocument(); }); }); + +function LocationKeyProbe({ keys }: { keys: string[] }) { + const location = useLocation(); + useEffect(() => { + if (keys[keys.length - 1] !== location.key) keys.push(location.key); + }, [location.key, keys]); + return null; +} + +function ChangeRegionButton() { + const { setSelection } = useRegionSelection(); + return ; +} + +it("does not navigate on a region change with no route selected", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const keys: string[] = []; + render( + + + + + + + + + , + ); + await screen.findByText("Find path"); + const initialKeyCount = keys.length; + + fireEvent.click(screen.getByRole("button", { name: "Change region" })); + + await waitFor(() => expect(mockGetKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "AAA" }))); + expect(keys.length).toBe(initialKeyCount); +}); + +it("preserves a shared saved route while a named region resolves", async () => { + vi.mocked(getRegions).mockResolvedValue([{ id: 1, slug: "ontario", name: "Ontario" }]); + vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "ontario", name: "Ontario", iatas: ["YOW"] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + await waitFor(() => expect(getRegion).toHaveBeenCalledWith(1)); + await waitFor(() => expect(getKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "YOW" }))); + expect(screen.getByTestId("saved-route-selection")).toHaveTextContent("shared-key"); + client.clear(); +}); + +it("translates the search bar, headers and empty state", async () => { + await i18n.changeLanguage("fr"); + renderTable(); + expect(await screen.findByText("Chercher un trajet")).toBeInTheDocument(); + expect(screen.getByLabelText("Hash de départ")).toHaveAttribute("placeholder", "hash de départ"); + expect(screen.getByRole("button", { name: "Rechercher" })).toBeInTheDocument(); + expect(await screen.findByText("Aucun trajet")).toBeInTheDocument(); +}); + +it("shows no routes for a region with no IATAs", async () => { + vi.mocked(getRegions).mockResolvedValue([{ id: 2, slug: "empty", name: "Empty" }]); + vi.mocked(getRegion).mockResolvedValue({ id: 2, slug: "empty", name: "Empty", iatas: [] }); + mockGetKnownRoutesPage.mockResolvedValue({ items: [{ id: 1, iata: "CCC", hopCount: 1, hops: [], firstSeen: 1, lastSeen: 5, observationCount: 9 }], nextCursor: null, hasMore: false }); + + renderTable({ regions: ["empty"], iatas: [] }); + + await waitFor(() => expect(getRegion).toHaveBeenCalled()); + expect(await screen.findByText("No routes")).toBeInTheDocument(); + expect(screen.queryByText("9")).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/ClockDriftTab.test.tsx b/tests/features/stats/ClockDriftTab.test.tsx new file mode 100644 index 0000000..3202a6d --- /dev/null +++ b/tests/features/stats/ClockDriftTab.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { ClockDriftTab } from "../../../src/features/stats/ClockDriftTab"; +import { useClockDrift } from "../../../src/features/stats/useStats"; +import type { ClockDriftEntry } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const rows: ClockDriftEntry[] = [ + { nodeId: "fixture-a", nodeName: "Alpha", nodeType: 2, nodeTypeName: "Repeater", clockDriftSeconds: 3599, clockCheckedAt: 1000, iatas: [{ iata: "YVR", lastHeard: 1000 }] }, + { nodeId: "fixture-b", nodeName: "Beta", nodeType: 3, nodeTypeName: "Room", clockDriftSeconds: -3600, clockCheckedAt: 2000, iatas: [{ iata: "YOW", lastHeard: 2000 }] }, +]; +const query = { data: rows, isLoading: false, isPending: false, isPlaceholderData: false, isError: false, isFetching: false }; +vi.mock("../../../src/features/stats/useStats", () => ({ useClockDrift: vi.fn(() => query) })); +beforeEach(() => { vi.clearAllMocks(); query.data = rows; query.isLoading = false; query.isPending = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; }); + +it("translates clock labels and direction without changing signs, thresholds or worst-first ordering", async () => { + await act(() => i18n.changeLanguage("fr")); + const { container } = render(); + expect(useClockDrift).toHaveBeenCalledWith(); + expect(screen.getByText(/Répéteurs et serveurs de salon désynchronisés/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Dérive\s*▼$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Nœud\s*▲$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Vérifié\s*▲$/ })).toBeInTheDocument(); + expect(screen.getByText("+59 min 59 s en avance")).toHaveClass("text-warn"); + expect(screen.getByText("-1 h 0 min en retard")).toHaveClass("text-danger"); + expect(screen.getByText("YVR")).toBeInTheDocument(); + expect(screen.getByText("YOW")).toBeInTheDocument(); + expect([...container.querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent)).toEqual(["BetaRoom", "AlphaRepeater"]); +}); + +it("retains a user-selected sort and raw node values across a language change", async () => { + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: /^Node\s*▲$/ })); + const order = () => [...container.querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); + await act(() => i18n.changeLanguage("fr")); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); + fireEvent.click(screen.getByRole("button", { name: /^Nœud\s*▲$/ })); + expect(order()).toEqual(["BetaRoom", "AlphaRepeater"]); + expect(screen.getByRole("button", { name: /^Nœud\s*▼$/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^Dérive\s*▲$/ })); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("shows the translated %s state without cached rows", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("Alpha")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + if (state === "isError") expect(screen.getByText("Échec du chargement")).toBeInTheDocument(); + else expect(screen.queryByText("Aucun répéteur désynchronisé")).not.toBeInTheDocument(); +}); + +it("translates an empty result without inventing out-of-sync nodes", async () => { + query.data = []; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucun répéteur désynchronisé")).toBeInTheDocument(); +}); + +it("retains valid values during a healthy background refresh", () => { + query.isFetching = true; + render(); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("+59m 59s ahead")).toHaveClass("text-warn"); + expect(screen.getByText("-1h 0m behind")).toHaveClass("text-danger"); +}); + +it("shows compact cards instead of a wide table on a phone, worst first", async () => { + await act(() => i18n.changeLanguage("en")); + const media = window.matchMedia("(max-width: 767px)"); + const spy = vi.spyOn(window, "matchMedia").mockImplementation((q) => ({ ...media, media: q, matches: q === "(max-width: 767px)" })); + try { + const { container } = render(); + expect(container.querySelector("table")).toBeNull(); + const cards = screen.getAllByRole("button", { name: /Beta|Alpha/ }); + expect(cards.map((c) => c.textContent)).toEqual([expect.stringContaining("Beta"), expect.stringContaining("Alpha")]); + expect(cards[0]).toHaveTextContent("-1h 0m behind"); + expect(cards[0]).toHaveTextContent("YOW"); + } finally { + spy.mockRestore(); + } +}); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx new file mode 100644 index 0000000..c86fdf5 --- /dev/null +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { CompareObserversTab } from "../../../src/features/stats/CompareObserversTab"; +import { getObserverComparison, getObserversPage, getObserver } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ + getObserverComparison: vi.fn(), getObserversPage: vi.fn(), getObserver: vi.fn(), +})); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => null })); +// resetAllMocks below also wipes the global matchMedia stub the tooltip hover check reads +vi.mock("../../../src/hooks/useMediaQuery", async (orig) => ({ ...(await orig()), useHasHover: () => true })); + +const a = "11111111-1111-1111-1111-111111111111"; +const b = "22222222-2222-2222-2222-222222222222"; +const query = `?tab=Analytics&statsTab=compare&compareA=${a}&compareB=${b}&compareSince=1000&compareUntil=2000`; +const counts = { observerA: a, observerB: b, since: 1000, until: 2000, totalPackets: 4, onlyA: 1, onlyB: 2, both: 1 }; + +function Location() { return {useLocation().search}; } +async function pick(label: string, name: RegExp) { + fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${label}:`) })); + fireEvent.click(await within(screen.getByRole("listbox", { name: label })).findByRole("option", { name })); +} +function mount(url = "?statsTab=compare") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render(); +} + +beforeEach(() => { + vi.resetAllMocks(); + region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getObserversPage).mockResolvedValue({ items: [ + { id: a, displayName: "Rooftop", iata: "YVR", status: "online" }, + { id: b, displayName: "Hilltop", iata: "YVR", status: "online" }, + ], hasMore: false, nextCursor: null }); + vi.mocked(getObserver).mockImplementation(async (id) => ({ id, displayName: id === a ? "Rooftop" : "Hilltop", iata: "YVR", status: "online", publicKey: "00", firstSeen: 0, lastSeen: 0, observationCount: 0, brokers: [] })); + vi.mocked(getObserverComparison).mockResolvedValue(counts); +}); + +describe("observer comparison", () => { + it("accepts a comparison longer than three days", async () => { + mount(); + await pick("Observer A", /Rooftop/); + await pick("Observer B", /Hilltop/); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalled()); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { + mount(); + expect(getObserverComparison).not.toHaveBeenCalled(); + await pick("Observer A", /Rooftop/); + await pick("Observer B", /Hilltop/); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-03T00:00" } }); + expect(getObserverComparison).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], { + observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-01-03T00:00").getTime(), + }, expect.any(AbortSignal))); + expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`); + }); + + it("restores a shared comparison and shows disjoint counts and union percentages", async () => { + mount(query); + const table = await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(within(table).getByRole("row", { name: /Only A.*1.*25.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Only B.*2.*50.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Both.*1.*25.0%/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Percentages are of all flood packets/ })).toBeInTheDocument(); + }); + + it("offers only a different observer and rejects an incomplete pair without querying", async () => { + mount(); + await pick("Observer A", /Rooftop/); + fireEvent.click(screen.getByRole("button", { name: /^Observer B:/ })); + const list = screen.getByRole("listbox", { name: "Observer B" }); + await within(list).findByRole("option", { name: /Hilltop/ }); + expect(within(list).queryByRole("option", { name: /Rooftop/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + expect(screen.getByRole("alert")).toHaveTextContent(/different observers/); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); + + it("renders an empty period without NaN percentages", async () => { + vi.mocked(getObserverComparison).mockResolvedValue({ ...counts, totalPackets: 0, onlyA: 0, onlyB: 0, both: 0 }); + mount(query); + expect(await screen.findByText(/Neither observer heard a flood packet/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("NaN"); + }); + + it("searches the server beyond the initial observer page", async () => { + mount(); + fireEvent.click(screen.getByRole("button", { name: /^Observer A:/ })); + fireEvent.change(screen.getByRole("searchbox", { name: /Observer A/ }), { target: { value: "Distant station" } }); + await waitFor(() => expect(getObserversPage).toHaveBeenCalledWith(["YVR"], { name: "Distant station", limit: 50 })); + }); + + it("keeps applied result labels when the draft selection changes", async () => { + mount(query); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(await screen.findByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-02T00:00" } }); + expect(screen.getByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + }); + + it("rejects malformed shared dates without issuing a comparison", async () => { + mount(query.replace("compareUntil=2000", "compareUntil=bad")); + expect(screen.getByRole("alert")).toHaveTextContent(/invalid or missing/); + expect(screen.getByRole("button", { name: /^Observer A:/ })).toBeInTheDocument(); + await waitFor(() => expect(getObserverComparison).not.toHaveBeenCalled()); + }); + + it("shows query failures and retries only when requested", async () => { + vi.mocked(getObserverComparison).mockRejectedValueOnce(new Error("comparison timed out; try a shorter time period")); + mount(query); + expect(await screen.findByRole("alert")).toHaveTextContent(/shorter time period/); + fireEvent.click(screen.getByRole("button", { name: "Retry comparison" })); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(getObserverComparison).toHaveBeenCalledTimes(2); + }); + + it("uses minute precision and flags a start older than packet retention", async () => { + mount(); + const start = screen.getByLabelText("Start (local time)"); + expect(start).toHaveAttribute("step", "60"); + expect(screen.getByLabelText("End (local time)")).toHaveAttribute("step", "60"); + expect((start as HTMLInputElement).value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + expect(screen.queryByText(/Only recent packets are kept/)).not.toBeInTheDocument(); + fireEvent.change(start, { target: { value: "2020-01-01T00:00" } }); + expect(screen.getByText(/Only recent packets are kept/)).toBeInTheDocument(); + }); + + it("renders the comparison in French", async () => { + await i18n.changeLanguage("fr"); + mount(query); + const table = await screen.findByRole("table", { name: "Comparaison des paquets flood" }); + expect(within(table).getByRole("row", { name: /A seulement.*1.*25.0%/ })).toBeInTheDocument(); + expect(screen.queryByText("Comparer les observateurs")).not.toBeInTheDocument(); + expect(screen.getByText("A : Rooftop · B : Hilltop")).toBeInTheDocument(); + expect(screen.getByText(/4 paquets flood/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Comparer" })).toBeInTheDocument(); + }); +}); diff --git a/tests/features/stats/EChart.test.tsx b/tests/features/stats/EChart.test.tsx new file mode 100644 index 0000000..a6e4de4 --- /dev/null +++ b/tests/features/stats/EChart.test.tsx @@ -0,0 +1,18 @@ +import { expect, it, vi } from "vitest"; +import { act, render } from "@testing-library/react"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; + +const init = vi.hoisted(() => + vi.fn(() => ({ setOption: vi.fn(), resize: vi.fn(), dispose: vi.fn(), on: vi.fn(), off: vi.fn(), isDisposed: () => false })), +); +vi.mock("../../../src/features/stats/echarts-setup", () => ({ echarts: { init } })); +vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} }); + +it("initialises charts with the French locale and rebuilds on a language switch", async () => { + render(); + expect(init).toHaveBeenLastCalledWith(expect.anything(), null, expect.objectContaining({ locale: "EN" })); + await act(() => i18n.changeLanguage("fr")); + expect(init).toHaveBeenCalledTimes(2); + expect(init).toHaveBeenLastCalledWith(expect.anything(), null, expect.objectContaining({ locale: "FR" })); +}); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx new file mode 100644 index 0000000..0526f41 --- /dev/null +++ b/tests/features/stats/MeshTab.test.tsx @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MeshTab } from "../../../src/features/stats/MeshTab"; +import i18n from "../../../src/i18n"; +import { getStatsSeries, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; +import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; +import type { SeriesValues, StatsSeries, StatsRange } from "../../../src/features/stats/types"; + +const region: { iatas: string[] | undefined; regionKey: string; isResolved: boolean; emptyRegion?: string } = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +const H = 3_600_000; +const values = (v: Partial): SeriesValues => ({ observations: 0, uniquePackets: 0, activeObservers: 0, activeIatas: 0, scopedPackets: 0, activeScopes: 0, maxPathEntries: 0, snrSum: 0, snrSamples: 0, rssiSum: 0, rssiSamples: 0, ...v }); +const series: StatsSeries = { + since: -21 * H, until: 3 * H, revision: 1, earliestComplete: 0, completeHours: 2, + hours: [ + { hour: 0, status: "complete", values: values({ observations: 100, uniquePackets: 50, activeObservers: 2 }) }, + { hour: H, status: "complete", values: values({ observations: 122, uniquePackets: 61, activeObservers: 3 }) }, + { hour: 2 * H, status: "missing", values: null }, + ], + summary: values({ observations: 222, uniquePackets: 111, activeObservers: 3, activeIatas: 4 }), +}; +const withSummary = (v: Partial): StatsSeries => ({ ...series, summary: { ...series.summary, ...v } }); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getStatsSeries: vi.fn(), getPayloadBreakdown: vi.fn(), getTopNodes: vi.fn(), getTopObservers: vi.fn(), getRadioPresets: vi.fn(), getStatsScopes: vi.fn(), getStatsNodeTypes: vi.fn() })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option, onEvents }: { option: EChartsOption; onEvents?: Record void> }) => })); + +const clients: QueryClient[] = []; +beforeEach(() => { + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; region.emptyRegion = undefined; + vi.mocked(getStatsSeries).mockReset().mockResolvedValue(series); + vi.mocked(getPayloadBreakdown).mockReset().mockResolvedValue([{ payloadType: 4, payloadTypeName: "ADVERT", count: 17 }]); + vi.mocked(getTopNodes).mockReset().mockResolvedValue([{ nodeId: "node-id", publicKey: "abcdef0123456789", nodeName: "Old node", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); + vi.mocked(getTopObservers).mockReset().mockResolvedValue([{ observerId: "observer-id", displayName: "Old observer", observerType: null, iata: "YVR", observationCount: 16 }]); + vi.mocked(getRadioPresets).mockReset().mockResolvedValue([{ preset: "910.525,62.5,7", iata: "YVR", sourceType: "node", count: 18 }]); + vi.mocked(getStatsScopes).mockReset().mockResolvedValue([{ name: "#old", packetCount: 19, observerCount: 2, nodeCount: 1 }]); + vi.mocked(getStatsNodeTypes).mockReset().mockResolvedValue([{ nodeType: 2, nodeTypeName: "Repeater", count: 20 }]); +}); +afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); + +function card(title: string) { return title === "Scopes · selected region · retained data" ? screen.getByText("Scopes", { exact: true }).closest("details")! : screen.getByText(title).parentElement!.parentElement!; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function mount(range: StatsRange = "24h") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + const onSelectObserver = vi.fn(); + const view = (range: StatsRange) => ; + const result = render(view(range)); + return { client, onSelectObserver, container: result.container, rerender: (next: StatsRange) => result.rerender(view(next)) }; +} +async function loaded() { await waitFor(() => expect(screen.getAllByTestId("chart")).toHaveLength(6)); await screen.findByText("#old"); } + + +it("hides all previous-region values and sparklines until their own requests resolve", async () => { + const { rerender, container } = mount(); await loaded(); + const next = deferred(); + vi.mocked(getStatsSeries).mockReturnValue(next.promise); + vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopNodes).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); + vi.mocked(getRadioPresets).mockImplementation(() => new Promise(() => {})); + vi.mocked(getStatsScopes).mockImplementation(() => new Promise(() => {})); + vi.mocked(getStatsNodeTypes).mockImplementation(() => new Promise(() => {})); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender("24h"); + await waitFor(() => expect(getStatsSeries).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), ["YOW"], expect.any(AbortSignal))); + expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument(); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(container.querySelectorAll("polyline")).toHaveLength(0); + expect(screen.queryByText("#old")).not.toBeInTheDocument(); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + await act(() => next.resolve(withSummary({ uniquePackets: 333 }))); + await within(card("Total packets")).findByText("333"); + expect(screen.getAllByTestId("chart")).toHaveLength(1); + within(card("Observations · 24h")).getByTestId("chart"); +}); + +it("hides pending range charts while retaining fixed-24h KPIs, sparklines and population panels", async () => { + const { rerender, container } = mount(); await loaded(); + vi.mocked(getStatsSeries).mockImplementation(() => new Promise(() => {})); + vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopNodes).mockImplementation(() => new Promise(() => {})); + rerender("7d"); + expect(within(card("Observations · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Top observers · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Payload types · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Top nodes · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(2); + expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); + expect(container.querySelectorAll("polyline")).toHaveLength(4); + expect(getStatsSeries).toHaveBeenCalledTimes(2); + expect(getTopNodes).toHaveBeenCalledTimes(2); +}); + +it("hides failed series values and sparklines without discarding the cache or healthy panels, then recovers", async () => { + const { client, container } = mount(); await loaded(); + vi.mocked(getStatsSeries).mockRejectedValue(new Error("controlled series failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-series"] })); + await waitFor(() => expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument()); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + expect(container.querySelectorAll("polyline")).toHaveLength(0); + expect(client.getQueryData(["stats-series", "YVR", "24h"])).toEqual(series); + expect(within(card("Observations · 24h")).getByText("Failed to load")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(5); + vi.mocked(getStatsSeries).mockResolvedValue(withSummary({ uniquePackets: 444 })); + await act(() => client.refetchQueries({ queryKey: ["stats-series"] })); + await within(card("Total packets")).findByText("444"); +}); + +it("draws sparklines from complete hours only", async () => { + const { container } = mount(); await loaded(); + const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ").length); + expect(lines).toEqual([2, 2, 2, 2]); +}); + +it("breaks sparklines at missing hours instead of joining across them", async () => { + const ok = (n: number) => ({ hour: n * H, status: "complete" as const, values: values({ observations: n + 1, activeObservers: n + 1 }) }); + vi.mocked(getStatsSeries).mockResolvedValue({ ...series, hours: [ok(0), ok(1), { hour: 2 * H, status: "missing", values: null }, ok(3), ok(4)] }); + const { container } = mount(); await loaded(); + const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ")); + expect(lines.map((pts) => pts.length)).toEqual([2, 2, 2, 2, 2, 2, 2, 2]); + expect(lines[1]![0]!.split(",")[0]).toBe("90"); +}); + +it("plots observations and unique packets from the series, leaving gaps for unrolled hours", async () => { + mount("7d"); await loaded(); + const option = JSON.parse(within(card("Observations · 7d")).getByTestId("chart").textContent!); + expect(option.series.map((s: { data: unknown }) => s.data)).toEqual([ + [[0, 100], [H, 122], [2 * H, null]], + [[0, 50], [H, 61], [2 * H, null]], + ]); + expect(getStatsSeries).toHaveBeenCalledTimes(2); + const spans = vi.mocked(getStatsSeries).mock.calls.map((c) => c[1] - c[0]); + expect(spans.sort()).toEqual([24 * H, 7 * 24 * H].sort()); +}); + +it("names deleted top nodes by public key", async () => { + vi.mocked(getTopNodes).mockResolvedValue([{ nodeId: null, publicKey: "deadbeef00112233", nodeName: null, nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); + mount(); await loaded(); + expect(within(card("Top nodes · 24h")).getByTestId("chart").textContent).toContain("deadbeef"); +}); + +it("does not report a cached payload total after a failed refresh", async () => { + const { client } = mount(); await loaded(); + vi.mocked(getPayloadBreakdown).mockRejectedValue(new Error("controlled payload failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-payload"] })); + await within(card("Payload types · 24h")).findByText("Failed to load"); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + expect(screen.getByText("— obs")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(5); +}); + +it("retains valid values, series, sparklines and observer navigation during same-key background refreshes", async () => { + const { client, container, onSelectObserver } = mount(); await loaded(); + const next = deferred(); vi.mocked(getStatsSeries).mockReturnValue(next.promise); + let refresh!: Promise; + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-series"] }); }); + expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(6); + expect(container.querySelectorAll("polyline")).toHaveLength(4); + fireEvent.click(within(card("Top observers · 24h")).getByTestId("chart")); + expect(onSelectObserver).toHaveBeenCalledWith("observer-id"); + await act(async () => { next.resolve(withSummary({ uniquePackets: 555 })); await refresh; }); + await within(card("Total packets")).findByText("555"); +}); + +it("distinguishes initial loading from successful empty results and zero totals", async () => { + const next = deferred(); vi.mocked(getStatsSeries).mockReturnValue(next.promise); + vi.mocked(getPayloadBreakdown).mockResolvedValue([]); + vi.mocked(getTopNodes).mockResolvedValue([]); + vi.mocked(getTopObservers).mockResolvedValue([]); + vi.mocked(getRadioPresets).mockResolvedValue([]); + vi.mocked(getStatsScopes).mockResolvedValue([]); + vi.mocked(getStatsNodeTypes).mockResolvedValue([]); + mount(); + expect(screen.queryByText("0 obs")).not.toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + await act(() => next.resolve({ ...series, completeHours: 0, hours: [], summary: values({}) })); + await screen.findByText("0 obs"); + expect(screen.getAllByText("No data")).toHaveLength(7); + for (const label of ["Total packets", "Observations", "Active observers", "Active areas"]) expect(within(card(label)).getByText("—")).toBeInTheDocument(); +}); + +it("shows real zero KPIs once some hours are complete", async () => { + vi.mocked(getStatsSeries).mockResolvedValue({ ...series, summary: values({}) }); + mount(); await loaded(); + for (const label of ["Total packets", "Observations", "Active observers", "Active areas"]) expect(within(card(label)).getByText("0")).toBeInTheDocument(); +}); + +it("keeps the scope list collapsed until requested", async () => { + mount(); await loaded(); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(scopeList).not.toHaveAttribute("open"); + fireEvent.click(screen.getByText("Scopes", { exact: true })); + expect(scopeList).toHaveAttribute("open"); + expect(within(scopeList).getByText("#old")).toBeVisible(); +}); + +it("labels the KPI cards with the rolled 24h window whatever the selected range", async () => { + mount("7d"); await loaded(); + expect(screen.getAllByText("Last 24h · to 03:00 UTC")).toHaveLength(4); + expect(within(card("Active areas")).getByText("4")).toBeInTheDocument(); +}); + +it("shows French labels for the KPIs, chart titles and scope table", async () => { + await act(() => i18n.changeLanguage("fr")); + mount(); await loaded(); + expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument(); + expect(screen.getAllByText("Dernières 24 h · jusqu’à 03:00 UTC")).toHaveLength(4); + expect(screen.getByText("Observations · 24 h")).toBeInTheDocument(); + expect(screen.getByText("Meilleurs nœuds · 24 h")).toBeInTheDocument(); + expect(screen.getByText("Meilleurs observateurs · 24 h")).toBeInTheDocument(); + expect(screen.getByText("Types de paquets · 24 h")).toBeInTheDocument(); + const typesChart = within(card("Types de nœuds · historique complet")).getByTestId("chart"); + expect(typesChart.textContent).toContain("NŒUDS"); + fireEvent.click(screen.getByText("Scopes", { exact: true })); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(within(scopeList).getByText("Scope")).toBeInTheDocument(); + expect(within(scopeList).getByText("Nœuds")).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); +}); + +it("names the observation and preset chart series in French", async () => { + await act(() => i18n.changeLanguage("fr")); + mount(); await loaded(); + const obsChart = within(card("Observations · 24 h")).getByTestId("chart").textContent; + expect(obsChart).toContain('"legend":{"data":["Observations","Paquets uniques"]'); + const presetChart = within(card("Préréglages radio · historique complet")).getByTestId("chart").textContent; + expect(presetChart).toContain('"Nœuds","Observateurs"'); + await act(() => i18n.changeLanguage("en")); +}); + +it("asks for an empty region by slug rather than falling back to every IATA", async () => { + region.iatas = undefined; region.regionKey = "region:empty"; region.emptyRegion = "empty"; + mount(); await loaded(); + for (const call of vi.mocked(getStatsSeries).mock.calls) expect(call[2]).toEqual({ region: "empty" }); + expect(getStatsNodeTypes).toHaveBeenCalledWith({ region: "empty" }); +}); diff --git a/tests/features/stats/NeighbourGraphTab.test.tsx b/tests/features/stats/NeighbourGraphTab.test.tsx new file mode 100644 index 0000000..846f579 --- /dev/null +++ b/tests/features/stats/NeighbourGraphTab.test.tsx @@ -0,0 +1,63 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NeighbourGraphTab } from "../../../src/features/stats/NeighbourGraphTab"; +import { neighbourGraphOption } from "../../../src/features/stats/neighbour-graph"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { ChartColors } from "../../../src/features/stats/chartTheme"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR" }; +const nodes = { nodes: [] as NodeSummary[], loadedCount: 0, isPaging: false, isError: false }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/features/map/useMapNodesData", () => ({ useMapNodesData: () => nodes })); +vi.mock("../../../src/features/stats/NeighbourGraph", () => ({ NeighbourGraph: () => null })); +vi.mock("../../../src/api/client", () => ({ getNodeNeighbors: vi.fn() })); + +function node(id: string, neighborIds: string[]): NodeSummary { + return { id, publicKey: id, nodeType: 2, nodeTypeName: "repeater", name: id, lat: 45, lng: -75, iatas: ["YVR"], neighborIds } as NodeSummary; +} + +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(); +} + +beforeEach(() => { + region.iatas = ["YVR"]; region.regionKey = "YVR"; + Object.assign(nodes, { nodes: [], loadedCount: 0, isPaging: false, isError: false }); +}); + +it("asks for a region in French when All is selected", async () => { + region.iatas = undefined; region.regionKey = "*"; + await i18n.changeLanguage("fr"); + mount(); + expect(screen.getByText("Choisissez une région")).toBeInTheDocument(); +}); + +it("shows French loading and empty states", async () => { + await i18n.changeLanguage("fr"); + Object.assign(nodes, { isPaging: true, loadedCount: 3 }); + const view = mount(); + expect(screen.getByText("Chargement du réseau…")).toBeInTheDocument(); + expect(screen.getByText("3 nœuds")).toBeInTheDocument(); + Object.assign(nodes, { isPaging: false }); + view.rerender(); + expect(screen.getByText("Aucun nœud dans cette région")).toBeInTheDocument(); +}); + +it("counts the full mesh in French", async () => { + await i18n.changeLanguage("fr"); + Object.assign(nodes, { nodes: [node("a", ["b"]), node("b", ["a"])] }); + mount(); + expect(screen.getByText("2 nœuds")).toBeInTheDocument(); +}); + +const grey = new Proxy({}, { get: () => "#888888" }) as ChartColors; + +it("translates the graph legend", async () => { + await i18n.changeLanguage("fr"); + const option = neighbourGraphOption({ nodes: [], links: [], total: 0, capped: false }, grey, i18n.t); + const legend = Array.isArray(option.legend) ? option.legend[0] : option.legend; + expect(legend?.data).toEqual(["Compagnon", "Répéteur", "Salon", "Capteur", "Autre"]); +}); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx new file mode 100644 index 0000000..3b3eacc --- /dev/null +++ b/tests/features/stats/ObserverTab.test.tsx @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { ObserverTab } from "../../../src/features/stats/ObserverTab"; +import i18n from "../../../src/i18n"; +import { ApiError } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types"; +import type { WsManager } from "../../../src/api/ws-manager"; + +vi.mock("../../../src/hooks/useRegion", () => ({ + useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), +})); + +vi.mock("../../../src/features/stats/useStats", () => ({ + useTopObservers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("../../../src/features/stats/useLiveStats", () => ({ + useLiveObserver: () => {}, +})); + +// ECharts needs a real canvas; the tab's behaviour is in which cards it renders, not the pixels +vi.mock("../../../src/features/stats/EChart", () => ({ + EChart: ({ option }: { option: unknown }) =>
, +})); + +const telemetryResult = { data: undefined as ObserverTelemetry | undefined, isLoading: false, isError: false }; +const activityResult = { + data: undefined as ObserverActivity | undefined, + isLoading: false, + isPlaceholderData: false, + isError: false, + error: null as unknown, + dataUpdatedAt: 0, +}; + +vi.mock("../../../src/features/stats/useTelemetry", async (importOriginal) => ({ + ...(await importOriginal()), + useObserver: () => ({ data: observer }), + useObserverTelemetry: () => telemetryResult, + useObserverActivity: () => activityResult, +})); + +const observer: Observer = { + id: "obs-1", + displayName: "Rooftop", + iata: "YOW", + status: "online", + publicKey: "aa", + firstSeen: 0, + lastSeen: Date.now(), + observationCount: 12, + brokers: [], + radioFreqMhz: 910.525, + radioSf: 7, + radioBwKhz: 62.5, + radioCr: 5, +}; + +const H = 3_600_000; + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxSecs: null, + airtimeRxSecs: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +const telemetry: ObserverTelemetry = { + range: "24h", + interval: "1h", + points: [ + point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), + point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 }), + ], +}; + +const activity: ObserverActivity = { + range: "24h", + interval: "15m", + radio: { freqMhz: 910.525, sf: 7, bwKhz: 62.5, cr: 5, preambleSymbols: 32 }, + payloadTypes: [{ payloadType: 4, payloadTypeName: "ADVERT", count: 9 }], + points: [{ t: Date.now() - 900_000, observations: 9, airtimeMs: 2200, snrAvg: 6.1, snrMin: -1, rssiAvg: -95 }], +}; + +function renderTab() { + const qc = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render( + {}} wsManager={{} as WsManager} />, + { wrapper }, + ); +} + +beforeEach(() => { + observer.brokers = []; + telemetryResult.data = telemetry; + telemetryResult.isLoading = false; + telemetryResult.isError = false; + activityResult.data = activity; + activityResult.isLoading = false; + activityResult.isPlaceholderData = false; + activityResult.isError = false; + activityResult.error = null; + activityResult.dataUpdatedAt = Date.now(); +}); + +describe("ObserverTab", () => { + it("shows the latest reported RX and TX airtime as percent beside the airtime chart", () => { + renderTab(); + // +54 s RX and +36 s TX over the hour between the two reports + expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument(); + expect(screen.getByText(/TX 1%/)).toBeInTheDocument(); + }); + + it("renders the heard charts when the server has activity for the observer", () => { + renderTab(); + expect(screen.getByText(/channel busy/i)).toBeInTheDocument(); + expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument(); + expect(screen.getByText(/received signal/i)).toBeInTheDocument(); + expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument(); + }); + + it("names the radio settings the busy percent assumes, in the same form as the header", () => { + renderTab(); + expect(screen.getAllByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toHaveLength(2); + }); + + it("hides the heard charts entirely when the server does not have the endpoint", () => { + activityResult.data = undefined; + activityResult.isError = true; + activityResult.error = new ApiError(404, "not_found", "no route"); + renderTab(); + expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/heard per/i)).not.toBeInTheDocument(); + }); + + it("leaves the header airtime stat out when the observer reports no telemetry", () => { + const zero = point(0, { airtimeRxSecs: 0, airtimeTxSecs: 0 }); + telemetryResult.data = { ...telemetry, points: [zero, { ...zero, t: H }] }; + renderTab(); + expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); + }); + + it("keeps the last successful activity visible after a failed poll", () => { + activityResult.isError = true; + activityResult.error = new Error("temporary failure"); + renderTab(); + expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument(); + expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("shows one empty card instead of flat charts when nothing was heard", () => { + activityResult.data = { ...activity, payloadTypes: [], points: [] }; + renderTab(); + expect(screen.getByText(/no packets heard/i)).toBeInTheDocument(); + expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); + }); +}); + +describe("Observer dashboard hierarchy", () => { + it("uses period metrics rather than the legacy presence counter", () => { + activityResult.data = { + ...activity, + windowStart: Date.now() - 86400000, + windowEnd: Date.now(), + generatedAt: Date.now(), + source: "raw", + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: Date.now() - 7200000, + lastCompleteHourEnd: Date.now() - 3600000, + latestRecordedAt: Date.now() - 60000, + }, + }; + renderTab(); + const cards = screen.getByRole("list", { name: "Observer metrics" }); + expect(within(cards).getByText("9")).toBeInTheDocument(); + expect(within(cards).getAllByRole("listitem")).toHaveLength(6); + expect(within(cards).queryByText("12")).not.toBeInTheDocument(); + expect( + screen.getByText("Packets heard per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("does not invent zero packet metrics on an older server", () => { + renderTab(); + expect(screen.getByText(/Packet totals unavailable/)).toBeInTheDocument(); + }); + it("provides French monitoring labels", async () => { + await i18n.changeLanguage("fr"); + renderTab(); + expect(screen.getByText("Paquets reçus")).toBeInTheDocument(); + expect(screen.getByText("Temps radio TX / RX · 24 h")).toBeInTheDocument(); + expect(screen.queryByText("Détails de l’appareil")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Copier la clé publique/ })).toBeInTheDocument(); + }); + + it("names the busy series in French", async () => { + await i18n.changeLanguage("fr"); + renderTab(); + const options = screen.getAllByTestId("chart").map((c) => c.getAttribute("data-option") ?? ""); + expect(options.some((o) => o.includes('"name":"Occupation"'))).toBe(true); + expect(options.some((o) => o.includes('"name":"Busy"'))).toBe(false); + }); + + it("keeps packet metrics without the removed traffic text badge", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); + expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); + }); + + it("omits the duplicate observer picker and marked explanatory sections", () => { + renderTab(); + expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); + expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); + expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx new file mode 100644 index 0000000..5f2cbee --- /dev/null +++ b/tests/features/stats/PathsTab.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { PathsTab } from "../../../src/features/stats/PathsTab"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; + +const query = { data: { since: 0, until: 3600000, receptions: 100, hashed: 60, empty: 20, trace: 10, unclassified: 10, + hashWidths: [{ bytes: 1, receptions: 20 }, { bytes: 2, receptions: 30 }, { bytes: 3, receptions: 10 }], pathLengths: [{ entries: 0, receptions: 20 }, { entries: 2, receptions: 60 }], + hourly: [{ hour: 0, receptions: 100, oneByte: 20, twoByte: 30, threeByte: 10, empty: 20, trace: 10, unclassified: 10 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/usePathStats", () => ({ usePathStats: vi.fn(() => query) })); +const originalData = query.data; +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { query.data = originalData; query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("translates classification and exact tables while preserving counts and the requested window", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(usePathStats).toHaveBeenCalledWith("7d"); + expect(screen.getByText("7 j")).toBeInTheDocument(); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Effectifs par catégorie de chemin" }); + expect(within(table).getByRole("row", { name: /Chemins hachés.*60.*60.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Vides.*20.*20.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Non classés.*10.*10.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue"); + fireEvent.click(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure")); + expect(screen.getByRole("table", { name: "Effectifs par largeur de hachage" })).toHaveTextContent("Octets par hachage"); + expect(within(screen.getByRole("table", { name: "Effectifs par nombre d’entrées" })).getByRole("row", { name: "0 20" })).toBeInTheDocument(); + expect(within(screen.getByRole("table", { name: "Effectifs horaires des chemins" })).getByRole("row", { name: "1970-01-01 00:00 100 20 30 10 20 10 10" })).toBeInTheDocument(); +}); + +it("redraws all chart labels on language change and keeps exact details open", async () => { + render(); + fireEvent.click(screen.getByText("Exact width, path-length and hourly values")); + const chartOptions = () => vi.mocked(EChart).mock.calls.slice(-4).map(([{ option }]) => option); + const before = chartOptions(); + expect(before).toHaveLength(4); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure").closest("details")).toHaveAttribute("open"); + const after = chartOptions(); + expect(after[0]).toMatchObject({ series: [{ data: [{ name: "1 octet", value: 20 }, { name: "2 octets", value: 30 }, { name: "3 octets", value: 10 }] }] }); + expect(after[1]).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Observations", data: [{ value: 20 }, { value: 0 }, { value: 60 }] }] }); + expect(after[2]).toMatchObject({ series: [{ name: "1 octet", data: [[0, 20]] }, { name: "2 octets", data: [[0, 30]] }, { name: "3 octets", data: [[0, 10]] }] }); + expect(after[3]).toMatchObject({ series: [{ data: [{ name: "Chemins hachés", value: 60 }, { name: "Vides", value: 20 }, { name: "Trace", value: 10 }, { name: "Non classés", value: 10 }] }] }); + after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria)); + expect(query.refetch).not.toHaveBeenCalled(); +}); + +it("uses only nonempty hash paths for multi-byte share, exposes categories and explains remaining routes", () => { + render(); + expect(usePathStats).toHaveBeenCalledWith("24h"); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + expect(screen.getByText(/unclassified packets/i)).toHaveTextContent("no usable path data"); + const table = screen.getByRole("table", { name: "Path classification counts" }); + expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); +}); +it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => { + query[state] = true; render(); + expect(screen.queryByText("66.7%")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s without displaying old paths", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("66.7%")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("période plus courte"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement des chemins…")).toBeInTheDocument(); +}); + +it("translates empty coverage without inventing hash-width votes or hourly records", async () => { + query.data = { ...originalData, receptions: 0, hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] }; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(4); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); +}); + +it("draws hourly sparks for observations, hash paths, multi-byte share and most path entries", () => { + const hour = (h: number) => ({ hour: h * 3600000, receptions: 50 + h, oneByte: 10, twoByte: 5 + h, threeByte: 1, empty: 5, trace: 1, unclassified: 1, maxEntries: 3 + h }); + query.data = { ...originalData, until: 3 * 3600000, hourly: [hour(0), hour(1), hour(2)] }; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(4); +}); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx new file mode 100644 index 0000000..6da56e3 --- /dev/null +++ b/tests/features/stats/ScopesTab.test.tsx @@ -0,0 +1,126 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { ScopesTab } from "../../../src/features/stats/ScopesTab"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; +const query = { data: [ + { name: "#west", packetCount: 3, observerCount: 2, nodeCount: 2 }, + { name: "#east", packetCount: 2, observerCount: 2, nodeCount: 3 }, +], isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; +const H = 3_600_000; +const seriesQuery = { data: { hours: [0, 1, 2].map((i) => ({ hour: i * H, status: "complete", values: null })) }, isSuccess: true, isPlaceholderData: false }; +vi.mock("../../../src/features/stats/useStats", () => ({ useScopes: () => query, useStatsSeries: () => seriesQuery })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { vi.clearAllMocks(); query.data = originalData; query.isError = false; query.isPending = false; query.isPlaceholderData = false; }); + +it("translates scope counts and membership guidance without changing scope names or values", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Effectifs des scopes" }); + expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); + expect(screen.getByText("Observateurs par scope")).toBeInTheDocument(); + expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("couvrent la période sélectionnée"); + expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Actualiser/ })).not.toBeInTheDocument(); +}); + +it("keeps the current search and chart values when changing language", async () => { + render(); + fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: " EAST " } }); + const charts = () => vi.mocked(EChart).mock.calls.slice(-3).map(([{ option }]) => option); + const bars = (chart: ReturnType[number]) => (Array.isArray(chart.series) ? chart.series : [chart.series]).map((series) => series?.data); + const before = charts(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("searchbox", { name: "Rechercher un scope" })).toHaveValue(" EAST "); + expect(screen.getByText("1 sur 2 scopes")).toBeInTheDocument(); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); + expect(screen.getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); + charts().forEach((chart, i) => { + expect(bars(chart)).toEqual(bars(before[i]!)); + expect(chart.yAxis).toEqual(before[i]!.yAxis); + expect(chart.aria).not.toEqual(before[i]!.aria); + }); + expect(query.refetch).not.toHaveBeenCalled(); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s and hides stale regional scopes", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("Impossible de charger les scopes"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement des scopes…")).toBeInTheDocument(); +}); + +it("distinguishes a translated empty dataset from a search with no match", async () => { + await act(() => i18n.changeLanguage("fr")); + const { rerender } = render(); + const search = screen.getByRole("searchbox", { name: "Rechercher un scope" }); + fireEvent.change(search, { target: { value: "#absent" } }); + expect(screen.getByText("Aucun scope ne correspond à cette recherche.")).toBeInTheDocument(); + expect(screen.getByText("0 sur 2 scopes")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + fireEvent.change(search, { target: { value: "" } }); + query.data = []; rerender(); + expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(3); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); +}); + +it("shows exact scope counts, keeps membership semantics explicit and filters the whole view", () => { + render(); + const table = screen.getByRole("table", { name: "Scope counts" }); + expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); + expect(screen.getByText("Observers by scope")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /an observer can belong/ })).toBeInTheDocument(); + expect(screen.queryByText(/an observer can belong/, { selector: "p" })).not.toBeInTheDocument(); + expect(screen.getByText(/Scope activity/)).toBeInTheDocument(); + fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } }); + expect(screen.queryByText("#east")).not.toBeInTheDocument(); + expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); +}); + +it("hides stale values while a region change is pending and on errors", () => { + query.isPlaceholderData = true; + const { rerender } = render(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + query.isPlaceholderData = false; query.isError = true; rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes"); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); +}); + +it("draws hourly sparks on all four scope cards from the scopes left after searching", () => { + query.data = [ + { name: "#west", packetCount: 3, observerCount: 2, nodeCount: 2, hourly: [{ hour: 0, packets: 1, observers: 1, nodes: 1 }, { hour: 2 * H, packets: 2, observers: 2, nodes: 0 }] }, + { name: "#east", packetCount: 2, observerCount: 2, nodeCount: 3, hourly: [{ hour: H, packets: 2, observers: 1, nodes: 3 }] }, + ] as typeof originalData; + const { container } = render(); + const points = () => [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")); + expect(points()).toHaveLength(4); + expect(screen.getByText("Line: hearing per hour")).toBeInTheDocument(); + expect(screen.getByText("Line: advertising per hour")).toBeInTheDocument(); + const before = points(); + fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "east" } }); + expect(points()).toHaveLength(4); + expect(points()).not.toEqual(before); +}); + +it("shows no scoped packet total before any hour is rolled, keeping the membership counts", async () => { + await act(() => i18n.changeLanguage("en")); + const saved = seriesQuery.data; + seriesQuery.data = { ...saved, completeHours: 0 } as typeof saved; + try { + render(); + expect(within(screen.getByText("Scoped packets").parentElement!.parentElement!).getByText("—")).toBeInTheDocument(); + expect(within(screen.getAllByText("Observers")[0]!.parentElement!.parentElement!).getByText("4")).toBeInTheDocument(); + } finally { seriesQuery.data = saved; } +}); diff --git a/tests/features/stats/SectionInfo.test.tsx b/tests/features/stats/SectionInfo.test.tsx new file mode 100644 index 0000000..c7b9520 --- /dev/null +++ b/tests/features/stats/SectionInfo.test.tsx @@ -0,0 +1,22 @@ +import { expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { StatsOverview } from "../../../src/features/stats/StatsOverview"; +import { SectionInfo } from "../../../src/features/stats/SectionInfo"; + +vi.mock("../../../src/features/stats/ScopesTab", async () => { + const { SectionInfo } = await import("../../../src/features/stats/SectionInfo"); + return { ScopesTab: () =>
}; +}); + +it("shows a section's info beside the section chooser", () => { + render(); + const tip = screen.getByRole("button", { name: /About scopes/ }); + expect(tip.closest("[data-section-info]")).toBeInTheDocument(); + expect(screen.getByRole("main")).not.toContainElement(tip); +}); + +it("keeps the info in place outside the analytics bar", () => { + render(
); + expect(screen.getByRole("main")).toContainElement(screen.getByRole("button", { name: /Standalone/ })); +}); diff --git a/tests/features/stats/SignalTab.test.tsx b/tests/features/stats/SignalTab.test.tsx new file mode 100644 index 0000000..d27615d --- /dev/null +++ b/tests/features/stats/SignalTab.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { SignalTab } from "../../../src/features/stats/SignalTab"; +import { useSignalStats } from "../../../src/features/stats/useSignalStats"; +import i18n from "../../../src/i18n"; +import { EChart } from "../../../src/features/stats/EChart"; +import type { SignalStats } from "../../../src/features/stats/types"; + +const query = { data: { since: 0, until: 3600000, receptions: 100, snr: { samples: 80, average: 0, histogram: [{ lower: -5, upper: 0, count: 80 }] }, rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, hourly: [{ hour: 0, receptions: 100, snrSamples: 80, snrAverage: 0, rssiSamples: 90, rssiAverage: -102.5 }] } as SignalStats, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; +vi.mock("../../../src/features/stats/useSignalStats", () => ({ useSignalStats: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { query.data = originalData; query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("translates the signal view while retaining the requested window, true zero and exact counts", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(useSignalStats).toHaveBeenCalledWith("7d"); + expect(screen.getByText("0.0 dB")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Disponibilité des échantillons du signal" }); + expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue"); +}); + +it("shows measured units and exact sample coverage without treating zero SNR as missing", () => { + render(); + expect(useSignalStats).toHaveBeenCalledWith("24h"); + expect(screen.getByText("0.0 dB")).toBeInTheDocument(); + expect(screen.getByText("-102.5 dBm")).toBeInTheDocument(); + expect(screen.getByText(/last hop/i)).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Signal sample availability" }); + expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /RSSI.*90.*10.*90.0%/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); +}); +it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides previous filter values when %s", (state) => { + query[state] = true; + render(); + expect(screen.queryByText("-102.5 dBm")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); +}); + +it("redraws chart labels on language change while preserving numeric series and open details", async () => { + render(); + fireEvent.click(screen.getByText("Exact histogram and hourly values")); + const details = screen.getByText("Exact histogram and hourly values").closest("details")!; + expect(details).toHaveAttribute("open"); + const plotted = () => vi.mocked(EChart).mock.calls.slice(-5).map(([{ option }]) => { + const series = Array.isArray(option.series) ? option.series : [option.series]; + return series.map((s) => s?.data); + }); + const before = plotted(); + expect(before).toHaveLength(5); + await act(() => i18n.changeLanguage("fr")); + expect(plotted()).toEqual(before); + expect(screen.getByText("Valeurs exactes des histogrammes et par heure").closest("details")).toHaveAttribute("open"); + expect(screen.getByRole("table", { name: "Histogramme SNR" })).toHaveTextContent("-5 à < 0"); + expect(vi.mocked(EChart).mock.lastCall?.[0].option).toMatchObject({ series: [{ name: "Disponibles" }, { name: "Indisponibles" }] }); + expect(query.refetch).not.toHaveBeenCalled(); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates the %s state and hides stale chart values", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("-102.5 dBm")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("période plus courte"); + expect(screen.getAllByText("Échec du chargement").length).toBeGreaterThan(0); + } else expect(screen.getByText("Chargement du signal…")).toBeInTheDocument(); +}); + +it("shows a translated empty state without implying an outage or displaying samples", async () => { + query.data = { ...originalData, receptions: 0, hourly: [], snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] } }; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(screen.getByText(/pas une panne/)).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée").length).toBeGreaterThan(0); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); +}); + +it("draws hourly sparks for observations, SNR and RSSI, and a presence strip for hours with records", () => { + const hour = (h: number) => ({ hour: h * 3600000, receptions: 10 + h, snrSamples: 5, snrAverage: h, rssiSamples: 5, rssiAverage: -100 - h }); + query.data = { ...originalData, until: 3 * 3600000, hourly: [hour(0), hour(1), hour(2)] }; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(3); + expect(container.querySelectorAll("rect")).toHaveLength(1); +}); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx new file mode 100644 index 0000000..e7f8c5d --- /dev/null +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { StatsSubHeader } from "../../../src/features/stats/StatsSubHeader"; +import i18n from "../../../src/i18n"; + +afterEach(() => vi.restoreAllMocks()); + +describe("translated analytics controls", () => { + it("keeps archived analytics ranges and has no Observer section", async () => { + const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; + const { rerender } = render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument(); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("button", { name: "7 j" })); + expect(props.onRangeChange).toHaveBeenCalledWith("7d"); + rerender(); + expect(screen.queryByRole("button", { name: "3 j" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); + rerender(); + expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); + }); + + it("keeps section and range identifiers when labels change", async () => { + const onTabChange = vi.fn(), onRangeChange = vi.fn(); + render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); + const range = screen.getByRole("group", { name: "Période" }); + expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); + expect(onTabChange).not.toHaveBeenCalled(); + expect(onRangeChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); + expect(onTabChange).toHaveBeenCalledWith("paths"); + fireEvent.click(within(range).getByRole("button", { name: "30 j" })); + expect(onRangeChange).toHaveBeenCalledWith("30d"); + }); + + it("uses the same canonical mobile choices and hides ranges for clock drift", async () => { + const media = window.matchMedia("(max-width: 767px)"); + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ ...media, media: query, matches: query === "(max-width: 767px)" })); + await act(() => i18n.changeLanguage("fr")); + const onTabChange = vi.fn(), onRangeChange = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /Section/ })); + fireEvent.click(screen.getByRole("option", { name: "Dérive d’horloge" })); + expect(onTabChange).toHaveBeenCalledWith("clockdrift"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByRole("group", { name: "Période" })).not.toBeInTheDocument(); + expect(onRangeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx new file mode 100644 index 0000000..de7f887 --- /dev/null +++ b/tests/features/stats/TalkersTab.test.tsx @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TalkersTab } from "../../../src/features/stats/TalkersTab"; +import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client"; +import type { StatsRange, TopAdvertiser, TopTalker } from "../../../src/features/stats/types"; +import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", publicKey: "aa11bb22cc33dd44", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }]; +const talkers: TopTalker[] = [{ senderName: "Old sender", messageCount: 42, lastSent: 0 }]; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getTopAdvertisers: vi.fn(), getTopTalkers: vi.fn() })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: EChartsOption }) => { + const axis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis; + return
{axis?.data?.map(String).join(" ")}
; +} })); + +const clients: QueryClient[] = []; +beforeEach(() => { + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getTopAdvertisers).mockReset().mockResolvedValue(advertisers); + vi.mocked(getTopTalkers).mockReset().mockResolvedValue(talkers); +}); +afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function mount(onViewNode?: (id: string) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const view = (range: StatsRange) => ; + const result = render(view("24h")); + return { client, rerender: (range: StatsRange) => result.rerender(view(range)) }; +} + +it.each(["region", "range"] as const)("hides previous results while the new %s is pending, then shows each resolved panel", async (filter) => { + const { rerender } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + if (filter === "region") { region.iatas = ["YOW"]; region.regionKey = "YOW"; } + const requestedAt = Date.now(); + rerender(filter === "range" ? "7d" : "24h"); + await waitFor(() => expect(getTopAdvertisers).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextAdvertisers.resolve([{ ...advertisers[0]!, nodeName: "New advertiser" }])); + await screen.findByText("New advertiser"); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextTalkers.resolve([{ ...talkers[0]!, senderName: "New sender" }])); + await screen.findByText("New sender"); + const request = vi.mocked(getTopAdvertisers).mock.calls[1]!; + expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]); + expect(request[2]).toBe(20); + const windowMs = (filter === "range" ? 7 : 1) * 86_400_000; + expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs); + expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs); +}); + +it("shows an advertiser refetch error instead of cached rows, keeps the sender panel, and recovers", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopAdvertisers).mockRejectedValue(new Error("controlled advertiser failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Recovered advertiser"); + expect(screen.queryByText("Failed to load")).not.toBeInTheDocument(); + expect(getTopTalkers).toHaveBeenCalledOnce(); +}); + +it("keeps advertisers usable when only the sender request fails", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopTalkers).mockRejectedValue(new Error("controlled sender failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-talkers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(getTopAdvertisers).toHaveBeenCalledOnce(); +}); + +it("retains valid rows and rates during an ordinary background refresh", async () => { + // past :35 the rolled window is a full 24h + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.UTC(2026, 9, 2, 12, 40)); + onTestFinished(() => { vi.useRealTimers(); }); + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const next = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(next.promise); + let refresh!: Promise; + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); }); + expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching"); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(screen.getByText("10/d")).toBeInTheDocument(); + expect(screen.getByText("4/d")).toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + await act(async () => { next.resolve([{ ...advertisers[0]!, nodeName: "Fresh advertiser" }]); await refresh; }); + await screen.findByText("Fresh advertiser"); +}); + +it("distinguishes initial loading from successful empty results", async () => { + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + mount(); + expect(screen.queryByText("No advertisers")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => { nextAdvertisers.resolve([]); nextTalkers.resolve([]); }); + await screen.findByText("No advertisers"); + expect(screen.getByText("No data")).toBeInTheDocument(); +}); + +it("opens the clicked advertiser's node", async () => { + const onViewNode = vi.fn(); + mount(onViewNode); + fireEvent.click(await screen.findByText("Old advertiser")); + expect(onViewNode).toHaveBeenCalledWith("fixture-node"); +}); + +it("lists a deleted node by public key without opening it", async () => { + vi.mocked(getTopAdvertisers).mockResolvedValue([...advertisers, { ...advertisers[0]!, nodeId: null, publicKey: "deadbeef00112233", nodeName: null }]); + const onViewNode = vi.fn(); + mount(onViewNode); + fireEvent.click(await screen.findByText("deadbeef")); + expect(onViewNode).not.toHaveBeenCalled(); +}); + +it("renders the leaderboards in French", async () => { + await i18n.changeLanguage("fr"); + mount(); + await screen.findByText("Old advertiser"); + expect(screen.getByText("Principaux annonceurs · 24 h")).toBeInTheDocument(); + expect(screen.getByText("Principaux émetteurs · 24 h")).toBeInTheDocument(); + expect(screen.getByText("Nœud")).toBeInTheDocument(); +}); + +it("divides advert rates by the hours the rollup actually covers", async () => { + // at 12:10 UTC the 11:00 hour isn't rolled yet, so a 24h request covers 23 rolled hours + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.UTC(2026, 9, 2, 12, 10)); + onTestFinished(() => { vi.useRealTimers(); }); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, floodAdvertCount: 20, directAdvertCount: 4 }]); + mount(); + expect(await screen.findByText("21/d")).toBeInTheDocument(); + expect(screen.getByText("4.2/d")).toBeInTheDocument(); +}); diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx new file mode 100644 index 0000000..f32a10b --- /dev/null +++ b/tests/features/stats/TrafficTab.test.tsx @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, within } from "@testing-library/react"; +import { TrafficTab } from "../../../src/features/stats/TrafficTab"; +import { useStatsObservations } from "../../../src/features/stats/useStats"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; + +const query = { data: [{ hour: Date.UTC(2026, 8, 19, 12), iata: "YOW", observationCount: 1200, uniquePackets: 9999, activeObservers: 88 }], dataUpdatedAt: Date.UTC(2026, 8, 19, 12, 30), isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; +vi.mock("../../../src/features/stats/useStats", () => ({ useStatsObservations: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); + +beforeEach(() => { + query.data = originalData; query.isPending = false; query.isLoading = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; + vi.clearAllMocks(); +}); + +describe("Traffic page", () => { + it("translates the page and exact table without changing reception counts, IATA codes or ranges", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(useStatsObservations).toHaveBeenCalledWith("7d"); + expect(screen.getByText("Activité horaire · 7 j")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Observations par zone" }); + expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument(); + expect(screen.queryByText("9,999")).not.toBeInTheDocument(); + expect(screen.getByText(/Heures en UTC/)).toHaveTextContent("pas les paquets uniques"); + expect(screen.getByText(/pas une panne/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument(); + expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument(); + }); + + it("redraws grouped and unassigned chart labels on language change without altering data", async () => { + const row = originalData[0]!; + query.data = [row, { ...row, iata: "", observationCount: 9000 }, ...["YVR", "YYZ", "YUL", "YHZ", "YYC", "YEG", "YQB", "YXE"].map((iata, i) => ({ ...row, iata, observationCount: i + 1 }))]; + const chartOptions = () => vi.mocked(EChart).mock.calls.slice(-3).map(([{ option }]) => option); + const numbers = (options: ReturnType) => options.map((option) => + (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data?.map((entry) => + entry && typeof entry === "object" && !Array.isArray(entry) && "value" in entry ? entry.value : entry))); + render(); + const before = chartOptions(), values = numbers(before); + await act(() => i18n.changeLanguage("fr")); + const after = chartOptions(); + expect(numbers(after)).toEqual(values); + after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria)); + expect(after[0]).toMatchObject({ series: expect.arrayContaining([expect.objectContaining({ name: "Non attribué" }), expect.objectContaining({ name: "Autres zones" })]) }); + expect(after[2]).toMatchObject({ series: [{ data: expect.arrayContaining([expect.objectContaining({ name: "Autres zones", value: 6 }), expect.objectContaining({ name: "Non attribué", value: 9000 })]) }] }); + expect(screen.getByRole("row", { name: /Non attribué.*9,000/ })).toBeInTheDocument(); + expect(screen.getByRole("row", { name: /YOW.*1,200/ })).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Autres zones/ })).not.toBeInTheDocument(); + expect(query.refetch).not.toHaveBeenCalled(); + }); + + it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s and hides prior-region data", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("Impossible de charger le trafic"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement du trafic…")).toBeInTheDocument(); + }); + + it("translates the empty view without inventing receptions or implying an outage", async () => { + query.data = []; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(screen.getByText(/pas une panne/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument(); + expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(3); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("shows exact reception counts and UTC/missing-history guidance without a refresh action", () => { + render(); + expect(useStatsObservations).toHaveBeenCalledWith("24h"); + const table = screen.getByRole("table", { name: "Observations by area" }); + expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument(); + expect(screen.queryByText("9,999")).not.toBeInTheDocument(); + expect(screen.getByText(/not unique packets/)).toHaveTextContent(/UTC.*outage/); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); + }); + + it("does not display previous-region data while a new query is loading", () => { + query.isPlaceholderData = true; + render(); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + }); + + it("provides a visible error state without stale chart values", () => { + query.isError = true; + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not load traffic"); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + }); +}); + +it("draws hourly sparks for observations, areas and the busiest hour, and a presence strip for active hours", () => { + const at = (h: number, iata: string, n: number) => ({ hour: Date.UTC(2026, 8, 19, h), iata, observationCount: n, uniquePackets: 0, activeObservers: 0 }); + query.data = [at(10, "YOW", 5), at(11, "YOW", 9), at(11, "YUL", 2), at(12, "YOW", 3)]; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(3); + expect(container.querySelectorAll("[data-peak]")).toHaveLength(1); + expect(container.querySelectorAll("rect")).toHaveLength(1); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx new file mode 100644 index 0000000..00f79be --- /dev/null +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { expect, it, vi } from "vitest"; +import { StatsOverview } from "../../../src/features/stats/StatsOverview"; + +vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

})); +vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) =>

Signal range {range}

})); +vi.mock("../../../src/features/stats/PathsTab", () => ({ PathsTab: ({ range }: { range: string }) =>

Paths range {range}

})); +vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); +vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); +function Location() { return {useLocation().search}; } + +it("keeps seven-day links and falls back from retired 3d links", () => { + const { unmount } = render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); +}); + +it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { + render(); + expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Paths & hashes" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "30d" })); + expect(screen.getByText("Paths range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); + +it("opens Signal from a shared URL and retains regional state when changing range", () => { + render(); + expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "7d" })); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); + +it("opens Traffic from a shared URL and keeps unrelated selections when changing the range", () => { + render(); + expect(screen.getByText("Traffic range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Traffic" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "30d" })); + expect(screen.getByText("Traffic range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("observerId=kept"); + fireEvent.click(screen.getByRole("button", { name: "Mesh" })); + expect(screen.getByText("Mesh charts")).toBeInTheDocument(); +}); + +it("opens Scopes with the range control and preserves the chosen range for Traffic", () => { + render(); + expect(screen.getByText("Scope charts")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Time range" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Traffic" })); + expect(screen.getByText("Traffic range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx new file mode 100644 index 0000000..6255e2f --- /dev/null +++ b/tests/features/stats/cards.test.tsx @@ -0,0 +1,15 @@ +import { expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { StatCard } from "../../../src/features/stats/cards"; + +it("marks the peak hour on a sparkline when asked", () => { + const { container } = render(); + const dot = container.querySelector("[data-peak]")!; + expect(dot.getAttribute("x1")).toBe("40"); +}); + +it("draws a presence strip with one bar per run of reporting hours", () => { + const { container } = render(); + const bars = [...container.querySelectorAll("rect")]; + expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]); +}); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index 6a4784d..8bb2229 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */ import { describe, it, expect } from "vitest"; -import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption } from "../../../src/features/stats/chartOptions"; +import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, observationsAreaOption, batteryOption, noiseFloorOption, queueOption } from "../../../src/features/stats/chartOptions"; import type { ChartColors } from "../../../src/features/stats/chartTheme"; -import type { TelemetryPoint } from "../../../src/features/stats/types"; +import type { ActivityPoint, TelemetryPoint } from "../../../src/features/stats/types"; const colors: ChartColors = { primary: "#3b82f6", @@ -133,8 +133,8 @@ describe("leaderboardOption", () => { const point = (t: number, p: Partial): TelemetryPoint => ({ t, batteryMv: null, - airtimeTxPct: null, - airtimeRxPct: null, + airtimeTxSecs: null, + airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, @@ -142,23 +142,93 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ ...p, }); +const H = 3_600_000; + describe("airtimeOption", () => { const points = [ - point(1000, { airtimeRxPct: 10, airtimeTxPct: 4 }), - point(2000, { airtimeRxPct: 12, airtimeTxPct: 4 }), - point(3000, { airtimeRxPct: 11, airtimeTxPct: 7 }), + point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), + point(H, { airtimeRxSecs: 46, airtimeTxSecs: 4 }), // +36 s over 1 h → 1% + point(3 * H, { airtimeRxSecs: 40, airtimeTxSecs: 76 }), // RX dips → clamp at 0; TX +72 s over 2 h → 1% ]; - it("charts raw counters as clamped per-report deltas", () => { - const opt = airtimeOption(points, colors, false) as Record; - expect(opt.series[0].data).toEqual([[2000, 2], [3000, 0]]); // RX dips → clamp at 0 - expect(opt.series[1].data).toEqual([[2000, 0], [3000, 3]]); // TX + it("charts raw counters as percent of the wall-clock gap between reports", () => { + const opt = airtimeOption(points, colors, null) as Record; + expect(opt.series[0].name).toBe("RX"); + expect(opt.series[0].data).toEqual([[H, 1], [3 * H, 0]]); + expect(opt.series[1].data).toEqual([[H, 0], [3 * H, 1]]); }); - it("charts bucketed points as-is", () => { - const opt = airtimeOption(points, colors, true) as Record; - expect(opt.series[0].data).toEqual([[1000, 10], [2000, 12], [3000, 11]]); - expect(opt.series[1].data).toEqual([[1000, 4], [2000, 4], [3000, 7]]); + it("charts bucketed deltas as percent of the bucket width", () => { + const bucketed = [point(0, { airtimeRxSecs: 216, airtimeTxSecs: 0 }), point(6 * H, { airtimeRxSecs: 0, airtimeTxSecs: 1080 })]; + const opt = airtimeOption(bucketed, colors, 6 * H) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [6 * H, 0]]); + expect(opt.series[1].data).toEqual([[0, 0], [6 * H, 5]]); + }); + + it("labels the axis in percent", () => { + const opt = airtimeOption(points, colors, null) as Record; + expect(opt.yAxis.axisLabel.formatter).toContain("%"); + }); +}); + +const heard = (t: number, p: Partial = {}): ActivityPoint => ({ + t, + observations: 0, + airtimeMs: 0, + snrAvg: null, + snrMin: null, + rssiAvg: null, + ...p, +}); + +const window = { start: 0, end: 4 * H }; + +describe("busyOption", () => { + const points = [heard(0, { airtimeMs: 36_000 }), heard(H, { airtimeMs: null }), heard(2 * H, { airtimeMs: 0 })]; + + it("charts on-air time as percent of each bucket, keeping uncosted buckets as gaps", () => { + const opt = busyOption(points, colors, H, window) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [H, null], [2 * H, 0]]); + expect(opt.yAxis.axisLabel.formatter).toContain("%"); + }); + + it("pins the time axis to the selected window so silence stays visible", () => { + const opt = busyOption(points, colors, H, window) as Record; + expect(opt.xAxis.min).toBe(0); + expect(opt.xAxis.max).toBe(4 * H); + }); + + it("prorates the bucket still in progress by the time elapsed so far", () => { + const full = [heard(0, { airtimeMs: 36_000 }), heard(H, { airtimeMs: 36_000 }), heard(2 * H, { airtimeMs: 36_000 })]; + const opt = busyOption(full, colors, H, { start: 0, end: 2 * H + 1_800_000 }) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [H, 1], [2 * H, 2]]); + }); + + it("emits only gaps when the bucket width is unknown", () => { + const opt = busyOption(points, colors, null, window) as Record; + expect(opt.series[0].data).toEqual([[0, null], [H, null], [2 * H, null]]); + }); +}); + +describe("heardOption", () => { + it("charts the observation count per bucket over the window", () => { + const opt = heardOption([heard(0, { observations: 7 }), heard(H, { observations: 0 })], colors, window) as Record; + expect(opt.series[0].data).toEqual([[0, 7], [H, 0]]); + expect(opt.xAxis.min).toBe(0); + expect(opt.xAxis.max).toBe(4 * H); + }); +}); + +describe("snrHeardOption", () => { + const points = [heard(0, { snrAvg: 6.2, snrMin: -3 }), heard(H), heard(2 * H, { snrAvg: 4, snrMin: 1 })]; + + it("charts the average and the worst SNR as two series, leaving quiet buckets as gaps", () => { + const opt = snrHeardOption(points, colors, window) as Record; + expect(opt.series.map((s: { name: string }) => s.name)).toEqual(["Avg", "Min"]); + expect(opt.series[0].data).toEqual([[0, 6.2], [H, null], [2 * H, 4]]); + expect(opt.series[1].data).toEqual([[0, -3], [H, null], [2 * H, 1]]); + expect(opt.series[0].connectNulls).toBe(false); + expect(opt.xAxis.max).toBe(4 * H); }); }); @@ -179,3 +249,14 @@ describe("receiveErrorsOption", () => { expect(opt.series[0].data).toEqual([[1000, 5], [2000, 8], [3000, 8]]); }); }); + +describe("time-axis charts", () => { + it("label hours in UTC like the rest of analytics", () => { + const w = { start: 0, end: 4 * 3_600_000 }; + const options = [ + observationsAreaOption([], colors), airtimeOption([], colors, null), batteryOption([], colors), noiseFloorOption([], colors), + queueOption([], colors), receiveErrorsOption([], colors, true), busyOption([], colors, null, w), heardOption([], colors, w), snrHeardOption([], colors, w), + ] as Record[]; + for (const opt of options) expect(opt.useUTC).toBe(true); + }); +}); diff --git a/tests/features/stats/clock-query.test.tsx b/tests/features/stats/clock-query.test.tsx new file mode 100644 index 0000000..61a6e9a --- /dev/null +++ b/tests/features/stats/clock-query.test.tsx @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ClockDriftTab } from "../../../src/features/stats/ClockDriftTab"; +import { getClockDrift } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YVR"], regionKey: "YVR" }) })); +vi.mock("../../../src/api/client", () => ({ getClockDrift: vi.fn(() => Promise.resolve([])) })); +afterEach(() => vi.clearAllMocks()); + +it("reuses the regional clock query and limit when only the language changes", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No repeaters out of sync"); + expect(getClockDrift).toHaveBeenCalledOnce(); + expect(getClockDrift).toHaveBeenCalledWith(["YVR"], 100); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucun répéteur désynchronisé")).toBeInTheDocument(); + expect(getClockDrift).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-clock-drift", "YVR", 100]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/has-telemetry.test.ts b/tests/features/stats/has-telemetry.test.ts index 419301c..7093b3f 100644 --- a/tests/features/stats/has-telemetry.test.ts +++ b/tests/features/stats/has-telemetry.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { hasTelemetry } from "../../../src/features/stats/transforms"; import type { TelemetryPoint } from "../../../src/features/stats/types"; -const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); +const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); describe("hasTelemetry", () => { it("is false for no points", () => { @@ -18,7 +18,7 @@ describe("hasTelemetry", () => { }); it("is false when every metric is zero (bots / MQTT bridges report all-zero rows)", () => { - const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxPct: 0, airtimeRxPct: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; + const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxSecs: 0, airtimeRxSecs: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; expect(hasTelemetry([allZero, allZero])).toBe(false); }); }); diff --git a/tests/features/stats/path-query.test.tsx b/tests/features/stats/path-query.test.tsx new file mode 100644 index 0000000..572b393 --- /dev/null +++ b/tests/features/stats/path-query.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; +import { getPathStats } from "../../../src/api/client"; +import type { StatsRange } from "../../../src/features/stats/types"; +import { PathsTab } from "../../../src/features/stats/PathsTab"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getPathStats: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => usePathStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getPathStats).not.toHaveBeenCalled(); + region.isResolved = true; rerender({ range: "24h" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledOnce()); + const first = vi.mocked(getPathStats).mock.calls[0]!; + expect(first[1] % 60_000).toBe(0); + expect(first[1] - first[0]).toBe(24 * 3_600_000); + expect(first[2]).toEqual(["YVR"]); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledTimes(2)); + expect(first[3]?.aborted).toBe(true); + const second = vi.mocked(getPathStats).mock.calls[1]!; + expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000); + expect(second[2]).toEqual(["YOW"]); + unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); +}); + +it("retains the same cached path request and window when only the language changes", async () => { + vi.mocked(getPathStats).mockResolvedValueOnce({ since: 0, until: 3_600_000, receptions: 0, + hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No observations in this window."); + expect(getPathStats).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(getPathStats).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-paths", "YVR", "7d"]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/paths.test.ts b/tests/features/stats/paths.test.ts new file mode 100644 index 0000000..14ce453 --- /dev/null +++ b/tests/features/stats/paths.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { pathHours, pathLengthOption, pathTrendOption } from "../../../src/features/stats/paths"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { PathStats } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const hour = 3_600_000; +const fixture: PathStats = { since: hour / 2, until: 3 * hour, receptions: 10, hashed: 6, empty: 2, trace: 1, unclassified: 1, + hashWidths: [{ bytes: 1, receptions: 2 }, { bytes: 2, receptions: 3 }, { bytes: 3, receptions: 1 }], + pathLengths: [{ entries: 0, receptions: 2 }, { entries: 2, receptions: 5 }, { entries: 63, receptions: 1 }], + hourly: [{ hour: 0, receptions: 8, oneByte: 2, twoByte: 3, threeByte: 1, empty: 1, trace: 1, unclassified: 0, maxEntries: 4 }, { hour: 2 * hour, receptions: 2, oneByte: 0, twoByte: 0, threeByte: 0, empty: 1, trace: 0, unclassified: 1, maxEntries: 0 }] }; + +describe("path analytics charts", () => { + it("keeps absent hours distinct from known zero hash-path hours and preserves response values", () => { + const before = JSON.stringify(fixture), hours = pathHours(fixture); + expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour]); + expect(hours.map((h) => h.oneByte)).toEqual([2, null, 0]); + expect(hours.map((h) => h.twoByte)).toEqual([3, null, 0]); + expect(hours.map((h) => h.receptions)).toEqual([8, null, 2]); + expect(hours.map((h) => h.maxEntries)).toEqual([4, null, 0]); + expect(JSON.stringify(fixture)).toBe(before); + expect(pathHours(undefined)).toEqual([]); + expect(pathHours({ ...fixture, until: 30 * 24 * hour + hour / 2 })).toHaveLength(721); + }); + it("fills zero-count length bins while preserving empty-path counts and visible single-hour points", () => { + const c = readChartColors(); + const length = pathLengthOption(fixture.pathLengths, c, i18n.getFixedT("en")); + expect(length).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + const option = length as { xAxis: { data: number[] }; series: { data: { value: number }[] }[] }; + expect(option.xAxis.data).toHaveLength(64); + expect(option.series[0]!.data[0]!.value).toBe(2); + expect(option.series[0]!.data[1]!.value).toBe(0); + expect(option.series[0]!.data.reduce((n, d) => n + d.value, 0)).toBe(8); + expect(pathTrendOption(pathHours(fixture), c, i18n.getFixedT("en"))).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] }); + }); + + it("translates chart descriptions and width labels without changing numerical data or gaps", () => { + const c = readChartColors(), hours = pathHours(fixture); + const en = i18n.getFixedT("en"), fr = i18n.getFixedT("fr"); + const before = pathTrendOption(hours, c, en), after = pathTrendOption(hours, c, fr); + expect(after).toMatchObject({ useUTC: true, aria: { label: { description: expect.stringContaining("lacunes") } }, series: [ + { name: "1 octet", connectNulls: false, data: [[0, 2], [hour, null], [2 * hour, 0]] }, + { name: "2 octets", data: [[0, 3], [hour, null], [2 * hour, 0]] }, + { name: "3 octets", data: [[0, 1], [hour, null], [2 * hour, 0]] }, + ] }); + const data = (option: typeof before) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(data(after)).toEqual(data(before)); + const length = pathLengthOption(fixture.pathLengths, c, fr); + expect(length).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Observations" }], aria: { label: { description: expect.stringContaining("Zéro") } } }); + expect(data(length)).toEqual(data(pathLengthOption(fixture.pathLengths, c, en))); + }); +}); diff --git a/tests/features/stats/region-gate.test.tsx b/tests/features/stats/region-gate.test.tsx new file mode 100644 index 0000000..4956933 --- /dev/null +++ b/tests/features/stats/region-gate.test.tsx @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import * as client from "../../../src/api/client"; +import { + usePayloadBreakdown, useTopNodes, useTopObservers, useTopAdvertisers, useTopTalkers, useRadioPresets, useNodeTypes, useClockDrift, +} from "../../../src/features/stats/useStats"; + +const region = { iatas: undefined as string[] | undefined, regionKey: "west", isResolved: false }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => { + const empty = () => vi.fn(() => Promise.resolve([])); + return { + getPayloadBreakdown: empty(), getTopNodes: empty(), getTopObservers: empty(), getTopAdvertisers: empty(), + getTopTalkers: empty(), getRadioPresets: empty(), getStatsNodeTypes: empty(), getClockDrift: empty(), + }; +}); +afterEach(() => vi.clearAllMocks()); + +const hooks = [ + ["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown], + ["useTopNodes", () => useTopNodes("24h"), client.getTopNodes], + ["useTopObservers", () => useTopObservers("24h"), client.getTopObservers], + ["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers], + ["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers], + ["useRadioPresets", () => useRadioPresets(), client.getRadioPresets], + ["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes], + ["useClockDrift", () => useClockDrift(), client.getClockDrift], +] as const; + +it.each(hooks)("%s waits for the selected region instead of fetching every IATA", async (_name, hook, fetcher) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, unmount } = renderHook(hook, { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await Promise.resolve(); + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe("idle"); + unmount(); queryClient.clear(); +}); diff --git a/tests/features/stats/scope-query.test.tsx b/tests/features/stats/scope-query.test.tsx new file mode 100644 index 0000000..2cb9936 --- /dev/null +++ b/tests/features/stats/scope-query.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { rolledWindow, useScopes } from "../../../src/features/stats/useStats"; +import { getStatsScopes } from "../../../src/api/client"; +import { ScopesTab } from "../../../src/features/stats/ScopesTab"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getStatsScopes: vi.fn(() => Promise.resolve([])), getStatsSeries: vi.fn(() => Promise.resolve({ hours: [] })) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("sends the selected IATAs and fetches a separate global query when the filter is cleared", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender, unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(Number), expect.any(AbortSignal))); + region.iatas = undefined; region.regionKey = "*"; rerender(); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(undefined, expect.any(Number), expect.any(AbortSignal))); + expect(client.getQueryCache().findAll({ queryKey: ["stats-scopes"] })).toHaveLength(2); + unmount(); client.clear(); +}); + +it("does not fetch a global fallback for unresolved selected regions", () => { + region.isResolved = false; + const client = new QueryClient(); + const { unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(getStatsScopes).not.toHaveBeenCalled(); + unmount(); client.clear(); +}); + +it("reuses the regional query when only the language changes", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No scope data available."); + expect(getStatsScopes).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); + expect(getStatsScopes).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-scopes", "YVR", "7d"], ["stats-series", "YVR", "7d"]]); + unmount(); client.clear(); +}); + +it("windows scope counts like the series, from the same rolled hour", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.UTC(2026, 0, 2, 12, 10)); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = renderHook(() => useScopes("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledOnce()); + expect(vi.mocked(getStatsScopes).mock.calls[0]![1]).toBe(rolledWindow("24h").since); + expect(rolledWindow("24h").since).toBe(Date.UTC(2026, 0, 1, 11)); + unmount(); client.clear(); vi.useRealTimers(); +}); diff --git a/tests/features/stats/scopes.test.ts b/tests/features/stats/scopes.test.ts new file mode 100644 index 0000000..43061ee --- /dev/null +++ b/tests/features/stats/scopes.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { scopeSummary, scopeChartOption, scopeHourly } from "../../../src/features/stats/scopes"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import i18n from "../../../src/i18n"; + +it("totals memberships explicitly and bounds charts without dropping the remainder", () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ name: `#s${i}`, packetCount: i + 1, observerCount: 2, nodeCount: 1 })); + const original = JSON.stringify(rows); + expect(scopeSummary(rows)).toEqual({ active: 20, packets: 210, memberships: 40, nodes: 20 }); + const option = scopeChartOption(rows, "packetCount", readChartColors(), i18n.getFixedT("en")); + expect(option).toMatchObject({ animation: false, tooltip: { renderMode: "richText" } }); + expect(JSON.stringify(option)).toContain("Other scopes"); + const bars = option as { series: { data: { value: number }[] }[] }; + expect(bars.series[0]!.data.reduce((sum, row) => sum + row.value, 0)).toBe(210); + expect(JSON.stringify(rows)).toBe(original); + expect(scopeSummary([])).toEqual({ active: 0, packets: 0, memberships: 0, nodes: 0 }); +}); + +it("translates only the remainder and description for every metric, preserving names, ranking and colors", () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ name: `#scope${i}`, packetCount: i, observerCount: 15 - i, nodeCount: i % 3 })); + const before = JSON.stringify(rows), colors = readChartColors(); + for (const metric of ["packetCount", "observerCount", "nodeCount"] as const) { + const en = scopeChartOption(rows, metric, colors, i18n.getFixedT("en")); + const fr = scopeChartOption(rows, metric, colors, i18n.getFixedT("fr")); + const values = (option: typeof fr) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(values(fr)).toEqual(values(en)); + const labels = (option: typeof fr) => (option.yAxis as { data: string[] }).data; + expect(labels(fr).slice(0, 12)).toEqual(labels(en).slice(0, 12)); + expect(labels(en).at(-1)).toBe("Other scopes"); + expect(labels(fr).at(-1)).toBe("Autres scopes"); + expect(fr).toMatchObject({ aria: { label: { description: expect.stringContaining("valeurs exactes") } } }); + const bars = fr as { series: { data: { value: number }[] }[] }; + expect(bars.series[0]!.data.reduce((sum, row) => sum + row.value, 0)).toBe(rows.reduce((sum, row) => sum + row[metric], 0)); + } + expect(JSON.stringify(rows)).toBe(before); +}); + +it("sums the shown scopes per rolled hour, counting omitted hours as zero and unrolled hours as gaps", () => { + const H = 3_600_000; + const rows = [ + { name: "#a", packetCount: 4, observerCount: 1, nodeCount: 0, hourly: [{ hour: 0, packets: 3, observers: 2, nodes: 1 }, { hour: 2 * H, packets: 1, observers: 1, nodes: 0 }] }, + { name: "#b", packetCount: 2, observerCount: 1, nodeCount: 0, hourly: [{ hour: 0, packets: 2, observers: 1, nodes: 0 }, { hour: H, packets: 0, observers: 0, nodes: 4 }] }, + ]; + const hours = [{ hour: 0, status: "complete" as const }, { hour: H, status: "complete" as const }, { hour: 2 * H, status: "missing" as const }]; + expect(scopeHourly(rows, hours)).toEqual({ + packets: [5, 0, null], + active: [2, 1, null], // a nodes-only hour still makes #b active + observers: [3, 0, null], + nodes: [1, 4, null], + }); + expect(scopeHourly(rows, undefined)).toBeNull(); + // a server without hourly breakdowns must not read as a flat zero line + expect(scopeHourly([...rows, { name: "#old", packetCount: 1, observerCount: 1, nodeCount: 0 }], hours)).toBeNull(); +}); + +it("leaves hours outside the scope response's window as gaps rather than zero", () => { + const H = 3_600_000; + const rows = [{ name: "#a", packetCount: 2, observerCount: 1, nodeCount: 0, hourly: [{ hour: H, packets: 2, observers: 1, nodes: 0 }] }]; + const hours = [0, H, 2 * H, 3 * H].map((hour) => ({ hour, status: "complete" })); + expect(scopeHourly(rows, hours, { since: H, until: 3 * H })?.packets).toEqual([null, 2, 0, null]); +}); diff --git a/tests/features/stats/series-window.test.ts b/tests/features/stats/series-window.test.ts new file mode 100644 index 0000000..5809e2f --- /dev/null +++ b/tests/features/stats/series-window.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest"; +import { rolledWindow } from "../../../src/features/stats/useStats"; + +const H = 3_600_000; + +it("ends the window at the newest hour the server can have rolled", () => { + // the hour starting at H becomes rollable at H + 95 min, matching the server's overview window + expect(rolledWindow("24h", 12 * H + 34 * 60_000)).toEqual({ since: -13 * H, until: 11 * H }); + expect(rolledWindow("24h", 12 * H + 35 * 60_000)).toEqual({ since: -12 * H, until: 12 * H }); + expect(rolledWindow("7d", 12 * H + 35 * 60_000)).toEqual({ since: 12 * H - 7 * 24 * H, until: 12 * H }); +}); diff --git a/tests/features/stats/signal-query.test.tsx b/tests/features/stats/signal-query.test.tsx new file mode 100644 index 0000000..6b8102d --- /dev/null +++ b/tests/features/stats/signal-query.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useSignalStats } from "../../../src/features/stats/useSignalStats"; +import { getSignalStats } from "../../../src/api/client"; +import type { StatsRange } from "../../../src/features/stats/types"; +import { SignalTab } from "../../../src/features/stats/SignalTab"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getSignalStats: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => useSignalStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getSignalStats).not.toHaveBeenCalled(); + region.isResolved = true; rerender({ range: "24h" }); + await waitFor(() => expect(getSignalStats).toHaveBeenCalledOnce()); + const first = vi.mocked(getSignalStats).mock.calls[0]!; + expect(first[1] % 60_000).toBe(0); + expect(first[1] - first[0]).toBe(24 * 3_600_000); + expect(first[2]).toEqual(["YVR"]); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" }); + await waitFor(() => expect(getSignalStats).toHaveBeenCalledTimes(2)); + expect(first[3]?.aborted).toBe(true); + const second = vi.mocked(getSignalStats).mock.calls[1]!; + expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000); + expect(second[2]).toEqual(["YOW"]); + unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); +}); + +it("keeps the cached query and time window when only the display language changes", async () => { + vi.mocked(getSignalStats).mockResolvedValueOnce({ since: 0, until: 3_600_000, receptions: 0, + snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] }, hourly: [] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No observations in this window."); + expect(getSignalStats).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(getSignalStats).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-signal", "YVR", "24h"]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/signal.test.ts b/tests/features/stats/signal.test.ts new file mode 100644 index 0000000..0e2288b --- /dev/null +++ b/tests/features/stats/signal.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { signalHours, signalBinLabel, signalHistogramOption, signalTrendOption, signalCoverageOption } from "../../../src/features/stats/signal"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { SignalStats } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const en = i18n.getFixedT("en"); +const fr = i18n.getFixedT("fr"); + +const hour = 3_600_000; +export const signalFixture: SignalStats = { + since: hour / 2, until: 3 * hour + hour / 2, receptions: 100, + snr: { samples: 80, average: 0, histogram: [{ lower: null, upper: -30, count: 2 }, { lower: -30, upper: -25, count: 78 }, { lower: 30, upper: null, count: 0 }] }, + rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, + hourly: [ + { hour: 0, receptions: 50, snrSamples: 50, snrAverage: 0, rssiSamples: 50, rssiAverage: -100 }, + { hour: 2 * hour, receptions: 40, snrSamples: 30, snrAverage: 0, rssiSamples: 40, rssiAverage: -105.625 }, + { hour: 3 * hour, receptions: 10, snrSamples: 0, snrAverage: null, rssiSamples: 0, rssiAverage: null }, + ], +}; + +describe("signal analytics", () => { + it("keeps UTC window edges, true zeros, missing hours and missing readings distinct", () => { + const before = JSON.stringify(signalFixture); + const hours = signalHours(signalFixture); + expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour, 3 * hour]); + expect(hours.map((h) => h.snrAverage)).toEqual([0, null, 0, null]); + expect(hours.map((h) => h.receptions)).toEqual([50, null, 40, 10]); + expect(JSON.stringify(signalFixture)).toBe(before); + expect(signalHours(undefined)).toEqual([]); + expect(signalHours({ ...signalFixture, until: 30 * 24 * hour + hour / 2, hourly: [] })).toHaveLength(721); + }); + it("labels exact half-open bins including overflow without assigning quality ratings", () => { + expect(signalBinLabel({ lower: null, upper: -30, count: 2 }, en)).toBe("< -30"); + expect(signalBinLabel({ lower: -30, upper: -25, count: 2 }, en)).toBe("-30 to < -25"); + expect(signalBinLabel({ lower: 30, upper: null, count: 2 }, en)).toBe("≥ 30"); + const colors = readChartColors(); + expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors, en)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + expect(signalTrendOption(signalHours(signalFixture), "snr", colors, en)).toMatchObject({ animation: false, series: [{ connectNulls: false, showSymbol: true, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + }); + + it("translates plotted text without changing bin boundaries, coverage counts or UTC gaps", () => { + const before = JSON.stringify(signalFixture), colors = readChartColors(); + expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors, fr)).toMatchObject({ + tooltip: { renderMode: "richText" }, + aria: { label: { description: expect.stringContaining("Distribution du SNR en dB") } }, + xAxis: { data: ["< -30 dB", "-30 à < -25 dB", "≥ 30 dB"] }, + series: [{ name: "Échantillons SNR", data: [{ value: 2 }, { value: 78 }, { value: 0 }] }], + }); + expect(signalCoverageOption(signalFixture, colors, fr)).toMatchObject({ + series: [{ name: "Disponibles", data: [80, 90] }, { name: "Indisponibles", data: [20, 10] }], + }); + const trend = signalTrendOption(signalHours(signalFixture), "snr", colors, fr); + expect(trend).toMatchObject({ useUTC: true, series: [{ connectNulls: false, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + const tooltip = trend.tooltip as { valueFormatter: (value: unknown) => string }; + expect(tooltip.valueFormatter(null)).toBe("Aucun échantillon"); + expect(tooltip.valueFormatter(0)).toBe("0.00 dB"); + expect(JSON.stringify(signalFixture)).toBe(before); + }); +}); diff --git a/tests/features/stats/traffic-query.test.tsx b/tests/features/stats/traffic-query.test.tsx new file mode 100644 index 0000000..62939e8 --- /dev/null +++ b/tests/features/stats/traffic-query.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useStatsObservations } from "../../../src/features/stats/useStats"; +import { getStatsObservations } from "../../../src/api/client"; +import { TrafficTab } from "../../../src/features/stats/TrafficTab"; +import i18n from "../../../src/i18n"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getStatsObservations: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("waits for a selected region to resolve instead of making an unfiltered request", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getStatsObservations).not.toHaveBeenCalled(); + unmount(); client.clear(); +}); + +it("keeps the same traffic cache entry and request when only the language changes", async () => { + vi.mocked(getStatsObservations).mockResolvedValueOnce([]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No observations in this window."); + expect(getStatsObservations).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument(); + expect(getStatsObservations).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-observations", "YVR", "7d"]]); + unmount(); client.clear(); +}); + +it("cancels the old region request and uses a new cache key when filters change", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsObservations).toHaveBeenCalledOnce()); + const first = vi.mocked(getStatsObservations).mock.calls[0]!; + expect(first[0]).toEqual(["YVR"]); + expect(first[1]).toBeGreaterThan(Date.now() - 24 * 3_600_000 - 5_000); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender(); + await waitFor(() => expect(getStatsObservations).toHaveBeenCalledTimes(2)); + expect(first[2]?.aborted).toBe(true); + expect(vi.mocked(getStatsObservations).mock.calls[1]?.[0]).toEqual(["YOW"]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/traffic.test.ts b/tests/features/stats/traffic.test.ts new file mode 100644 index 0000000..be2553d --- /dev/null +++ b/tests/features/stats/traffic.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { trafficAreaLabel, trafficModel, trafficHeatmapOption, trafficTrendOption } from "../../../src/features/stats/traffic"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { ObservationPoint } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const hour = 3_600_000; +const now = Date.UTC(2026, 8, 19, 12, 30); +const end = Date.UTC(2026, 8, 19, 12); +const point = (time: number, iata: string, count: number): ObservationPoint => ({ hour: time, iata, observationCount: count, uniquePackets: 100, activeObservers: 50 }); + +describe("traffic exploration", () => { + it("counts receptions, keeps missing hours null, and anchors the bounded window in UTC", () => { + const data = [point(end - 2 * hour, "YVR", 2), point(end - 2 * hour, "YOW", 3), point(end, "YVR", 7)]; + const before = JSON.stringify(data); + const model = trafficModel(data, "24h", now); + expect(model.hours).toHaveLength(24); + expect(model.hours.at(-2)?.total).toBeNull(); + expect(model.hours.at(-3)?.total).toBe(5); + expect(model.hours.slice(-3).map((h) => h.areas)).toEqual([2, null, 1]); + expect(model.total).toBe(12); + expect(model.reportedHours).toBe(2); + expect(model.areas.map((area) => [area.name, area.total])).toEqual([["YVR", 9], ["YOW", 3]]); + expect(model.peak?.hour).toBe(end); + expect(model.peak?.total).toBe(7); + expect(model.days).toEqual(["2026-09-18", "2026-09-19"]); + expect(model.heatmap).toContainEqual([12, 1, 7]); + expect(model.heatmap).not.toContainEqual([11, 1, 0]); + expect(JSON.stringify(data)).toBe(before); + }); + + it("groups smaller IATAs without losing counts and excludes out-of-window/future rows", () => { + const data = Array.from({ length: 10 }, (_, i) => point(end, `X${i}`, i + 1)); + data.push(point(end - 25 * hour, "OLD", 1_000), point(end + hour, "FUT", 1_000)); + const model = trafficModel(data, "24h", now); + expect(model.total).toBe(55); + expect(model.series).toHaveLength(8); + expect(model.series.at(-1)?.name).toBe("Other IATAs"); + expect(model.series.reduce((sum, series) => sum + series.total, 0)).toBe(55); + expect(model.series.reduce((sum, series) => sum + (series.values.at(-1) ?? 0), 0)).toBe(55); + expect(model.series.at(-1)?.total).toBe(6); + expect(trafficModel(data, "30d", now).hours).toHaveLength(720); + }); + + it("handles empty data without invented traffic and exposes safe, gap-preserving chart options", () => { + const model = trafficModel([], "7d", now); + expect(model.total).toBe(0); + expect(model.peak).toBeNull(); + expect(model.heatmap).toEqual([]); + expect(model.hours.every((point) => point.total === null)).toBe(true); + const populated = trafficModel([point(end, "YOW", 1)], "24h", now); + const colors = readChartColors(); + expect(trafficHeatmapOption(populated, colors, i18n.getFixedT("en"))).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, visualMap: { min: 0, max: 1 } }); + expect(trafficTrendOption(populated, colors, i18n.getFixedT("en"))).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, series: [{ connectNulls: false }] }); + }); + + it("translates display labels and plural tooltips without changing the model, UTC cells or gaps", () => { + const model = trafficModel([point(end - 2 * hour, "YOW", 0), point(end, "YOW", 3), point(end, "", 1)], "24h", now); + const before = JSON.stringify(model), colors = readChartColors(), en = i18n.getFixedT("en"), fr = i18n.getFixedT("fr"); + expect(trafficAreaLabel("YOW", fr)).toBe("YOW"); + expect(trafficAreaLabel("Unassigned", fr)).toBe("Non attribué"); + expect(trafficAreaLabel("Other IATAs", fr)).toBe("Autres zones"); + const trend = trafficTrendOption(model, colors, fr); + expect(trend).toMatchObject({ useUTC: true, series: [{ name: "YOW", connectNulls: false }, { name: "Non attribué" }], aria: { label: { description: expect.stringContaining("lacunes") } } }); + const chartData = (option: typeof trend) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(chartData(trend)).toEqual(chartData(trafficTrendOption(model, colors, en))); + expect(model.hours.slice(-3).map((row) => row.total)).toEqual([0, null, 4]); + const heatmap = trafficHeatmapOption(model, colors, fr); + expect(heatmap).toMatchObject({ visualMap: { text: ["Plus", "Moins"], min: 0, max: 4 }, series: [{ name: "Observations", data: model.heatmap }] }); + expect(chartData(heatmap)).toEqual(chartData(trafficHeatmapOption(model, colors, en))); + const tooltip = (option: typeof heatmap) => (option as { tooltip: { formatter: (item: { value: number[] }) => string } }).tooltip.formatter; + expect(tooltip(heatmap)({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 observation"); + expect(tooltip(heatmap)({ value: [12, 1, 1200] })).toBe(`2026-09-19 12:00 UTC\n${(1200).toLocaleString()} observations`); + expect(tooltip(trafficHeatmapOption(model, colors, en))({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 observation"); + expect(JSON.stringify(model)).toBe(before); + }); +}); diff --git a/tests/features/stats/transforms.test.ts b/tests/features/stats/transforms.test.ts new file mode 100644 index 0000000..46799b1 --- /dev/null +++ b/tests/features/stats/transforms.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { intervalToMs, airtimePctSeries, latestAirtimePct, fillActivity, busyPct, payloadBarItems } from "../../../src/features/stats/transforms"; +import type { ActivityPoint, TelemetryPoint } from "../../../src/features/stats/types"; + +const H = 3_600_000; + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxSecs: null, + airtimeRxSecs: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +const activity = (t: number, p: Partial = {}): ActivityPoint => ({ + t, + observations: 0, + airtimeMs: 0, + snrAvg: null, + snrMin: null, + rssiAvg: null, + ...p, +}); + +describe("intervalToMs", () => { + it("parses the minute and hour intervals the server accepts", () => { + expect(intervalToMs("5m")).toBe(300_000); + expect(intervalToMs("15m")).toBe(900_000); + expect(intervalToMs("1h")).toBe(H); + expect(intervalToMs("6h")).toBe(6 * H); + expect(intervalToMs("24h")).toBe(24 * H); + }); + + it("is null for anything else", () => { + expect(intervalToMs("")).toBeNull(); + expect(intervalToMs("1d")).toBeNull(); + expect(intervalToMs("h")).toBeNull(); + }); +}); + +describe("airtimePctSeries", () => { + // raw points carry cumulative on-air seconds; the series is the increase as a percent of the + // wall-clock gap between the two reports + const raw = [ + point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), + point(H, { airtimeRxSecs: 46, airtimeTxSecs: 4 }), // +36 s over 1 h + point(3 * H, { airtimeRxSecs: 40, airtimeTxSecs: 76 }), // rx reset, tx +72 s over 2 h + ]; + + it("divides each cumulative delta by the real gap to the previous report", () => { + expect(airtimePctSeries(raw, "airtimeRxSecs", null)).toEqual([[H, 1], [3 * H, 0]]); + expect(airtimePctSeries(raw, "airtimeTxSecs", null)).toEqual([[H, 0], [3 * H, 1]]); + }); + + it("leaves a gap when either side of a delta is missing", () => { + const withHole = [point(0, { airtimeRxSecs: 10 }), point(H, { airtimeRxSecs: null }), point(2 * H, { airtimeRxSecs: 82 })]; + expect(airtimePctSeries(withHole, "airtimeRxSecs", null)).toEqual([[H, null], [2 * H, null]]); + }); + + it("divides bucketed deltas by the bucket width", () => { + const bucketed = [point(0, { airtimeRxSecs: 216 }), point(6 * H, { airtimeRxSecs: 1080 })]; + expect(airtimePctSeries(bucketed, "airtimeRxSecs", 6 * H)).toEqual([[0, 1], [6 * H, 5]]); + }); + + it("rounds to three decimals so tooltips stay readable", () => { + const pts = [point(0, { airtimeRxSecs: 0 }), point(H, { airtimeRxSecs: 1 })]; // 1 s / 3600 s + expect(airtimePctSeries(pts, "airtimeRxSecs", null)).toEqual([[H, 0.028]]); + }); +}); + +describe("latestAirtimePct", () => { + it("reports the last delta for raw points", () => { + const raw = [point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), point(H, { airtimeRxSecs: 46, airtimeTxSecs: 76 })]; + expect(latestAirtimePct(raw, null)).toEqual({ rx: 1, tx: 2 }); + }); + + it("reports the last bucket for bucketed points", () => { + const bucketed = [point(0, { airtimeRxSecs: 1, airtimeTxSecs: 1 }), point(6 * H, { airtimeRxSecs: 216, airtimeTxSecs: 0 })]; + expect(latestAirtimePct(bucketed, 6 * H)).toEqual({ rx: 1, tx: 0 }); + }); + + it("is null when there is nothing to difference", () => { + expect(latestAirtimePct([], null)).toEqual({ rx: null, tx: null }); + expect(latestAirtimePct([point(0, { airtimeRxSecs: 10 })], null)).toEqual({ rx: null, tx: null }); + }); +}); + +describe("fillActivity", () => { + const I = 900_000; + + it("emits every bucket in the window, zero where the server sent nothing", () => { + const heard = activity(I, { observations: 3, airtimeMs: 1200, snrAvg: 5.5, snrMin: -2, rssiAvg: -90 }); + const out = fillActivity([heard], I, { start: 0, end: 4 * I }); + expect(out.map((p) => p.t)).toEqual([0, I, 2 * I, 3 * I, 4 * I]); + expect(out[1]).toEqual(heard); + expect(out[0]).toEqual(activity(0)); + expect(out[4]).toEqual(activity(4 * I)); + }); + + it("leaves hours neither the rollup nor the raw tail covered as gaps", () => { + const H = 3_600_000; + // rolled through hour 2, raw rows only from hour 5: hours 2-4 were never read + const out = fillActivity([activity(H, { observations: 4 })], H, { start: 0, end: 6 * H }, { rolledUntil: 2 * H, rawFrom: 5 * H }); + expect(out.map((p) => p.observations)).toEqual([0, 4, null, null, null, 0, 0]); + expect(out[3]!.airtimeMs).toBeNull(); + }); + + it("treats everything before the raw tail as a gap when nothing has been rolled yet", () => { + const H = 3_600_000; + const out = fillActivity([activity(4 * H, { observations: 2 })], H, { start: 0, end: 5 * H }, { rawFrom: 3 * H }); + expect(out.map((p) => p.observations)).toEqual([null, null, null, 0, 2, 0]); + }); + + it("starts at the first complete bucket and ends at the one in progress", () => { + // the server rounds the window start up to the next bucket, so a floored first bucket would be a fake zero + const out = fillActivity([], I, { start: I + 1, end: 3 * I - 1 }); + expect(out.map((p) => p.t)).toEqual([2 * I]); + }); + + it("keeps a server bucket newer than the window end rather than dropping it", () => { + // a client clock behind the server must not hide the newest real bucket + const out = fillActivity([activity(3 * I, { observations: 2 })], I, { start: 0, end: 2 * I + 1 }); + expect(out.map((p) => p.t)).toEqual([0, I, 2 * I, 3 * I]); + expect(out[3]!.observations).toBe(2); + }); + + it("drops server points outside the window", () => { + const out = fillActivity([activity(9 * I, { observations: 1 })], I, { start: 0, end: 2 * I }); + expect(out.every((p) => p.observations === 0)).toBe(true); + }); +}); + +describe("busyPct", () => { + it("is the share of the bucket spent on air, to three decimals", () => { + expect(busyPct(9_000, 900_000)).toBe(1); + expect(busyPct(1_234, 900_000)).toBe(0.137); + }); + + it("is null when the bucket could not be costed", () => { + expect(busyPct(null, 900_000)).toBeNull(); + }); +}); + +describe("payloadBarItems", () => { + it("lowercases the type names and orders by count for the bar chart", () => { + const items = payloadBarItems([ + { payloadType: 5, payloadTypeName: "GRP_TXT", count: 3 }, + { payloadType: 4, payloadTypeName: "ADVERT", count: 9 }, + ]); + expect(items).toEqual([{ name: "advert", value: 9 }, { name: "grp_txt", value: 3 }]); + }); +}); diff --git a/tests/features/stats/useLiveStats.test.tsx b/tests/features/stats/useLiveStats.test.tsx index 1d5df8c..c50cced 100644 --- a/tests/features/stats/useLiveStats.test.tsx +++ b/tests/features/stats/useLiveStats.test.tsx @@ -1,68 +1,32 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useLiveOverview } from "../../../src/features/stats/useLiveStats"; -import type { StatsOverview } from "../../../src/features/stats/types"; +import { useLiveObserver } from "../../../src/features/stats/useLiveStats"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { WsPacketObservation } from "../../../src/types/ws"; +import type { WsObserverStatus } from "../../../src/types/ws"; -vi.mock("../../../src/hooks/useRegion", () => ({ - useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), -})); - -const overview: StatsOverview = { - totalPackets: 100, - totalObservations: 200, - activeObservers: 5, - activeIatas: 2, - windowHours: 24, -}; - -// Captures the packet handler the hook registers and lets the test push events through it. -function fakeManager() { - const handlers: Array<(d: WsPacketObservation["data"]) => void> = []; - const manager = { - onPacketObservation: (h: (d: WsPacketObservation["data"]) => void) => { - handlers.push(h); - return () => {}; - }, - } as unknown as WsManager; - return { manager, emit: (d: WsPacketObservation["data"]) => handlers.forEach((h) => h(d)) }; -} - -describe("useLiveOverview", () => { - let rafCallbacks: FrameRequestCallback[]; - - beforeEach(() => { - rafCallbacks = []; - vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { - rafCallbacks.push(cb); - return rafCallbacks.length; - }); - vi.stubGlobal("cancelAnimationFrame", () => {}); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("bumps the cached stats-overview counters on packet observations", () => { +describe("useLiveObserver", () => { + it("refreshes the selected observer's telemetry, but not its heard activity, on a status event", () => { const qc = new QueryClient(); - qc.setQueryData(["stats-overview", "YOW"], overview); - const { manager, emit } = fakeManager(); - + qc.setQueryData(["observer-telemetry", "obs-1", "24h", "1h"], { range: "24h", interval: "1h", points: [] }); + qc.setQueryData(["observer-activity", "obs-1", "24h"], { range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] }); + const handlers: Array<(d: WsObserverStatus["data"]) => void> = []; + const manager = { + onObserverStatus: (h: (d: WsObserverStatus["data"]) => void) => { + handlers.push(h); + return () => {}; + }, + } as unknown as WsManager; const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); - renderHook(() => useLiveOverview(manager), { wrapper }); + renderHook(() => useLiveObserver(manager, "obs-1", "24h"), { wrapper }); - emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]); - emit({ packet: { isFirstObservation: false } } as WsPacketObservation["data"]); - rafCallbacks.forEach((cb) => cb(0)); // flush the coalesced frame + handlers.forEach((h) => h({ observerId: "obs-1" } as WsObserverStatus["data"])); - const after = qc.getQueryData(["stats-overview", "YOW"]); - expect(after?.totalPackets).toBe(101); - expect(after?.totalObservations).toBe(202); + expect(qc.getQueryState(["observer-telemetry", "obs-1", "24h", "1h"])?.isInvalidated).toBe(true); + // heard activity changes with packets, not status, and refreshes on its own interval + expect(qc.getQueryState(["observer-activity", "obs-1", "24h"])?.isInvalidated).toBe(false); }); }); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts new file mode 100644 index 0000000..9dd576f --- /dev/null +++ b/tests/features/stats/useTelemetry.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { activityParamsFor, activityRefetchInterval } from "../../../src/features/stats/useTelemetry"; +import { ApiError } from "../../../src/api/client"; + +describe("activityParamsFor", () => { + it("pairs each stats range with the bucket the charts are sized for", () => { + expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); + expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); + expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); + }); +}); + +describe("activityRefetchInterval", () => { + it("polls every minute so the right edge of the heard charts keeps moving", () => { + expect(activityRefetchInterval(null)).toBe(60_000); + expect(activityRefetchInterval(new Error("network"))).toBe(60_000); + }); + + it("stops polling once the server has said it has no activity endpoint", () => { + expect(activityRefetchInterval(new ApiError(404, "unknown", "not found"))).toBe(false); + }); +}); diff --git a/tests/features/traces/TraceDetailPanel.test.tsx b/tests/features/traces/TraceDetailPanel.test.tsx new file mode 100644 index 0000000..1f4ce1a --- /dev/null +++ b/tests/features/traces/TraceDetailPanel.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TraceDetailPanel } from "../../../src/features/traces/TraceDetailPanel"; +import { getTraceDetail } from "../../../src/api/client"; +import type { TracePacket } from "../../../src/types/api"; + +vi.mock("../../../src/api/client", () => ({ getTraceDetail: vi.fn() })); + +const pkt = (over: Partial): TracePacket => ({ + packetHash: "aa11", routeType: 1, routeTypeName: "FLOOD", firstHeardAt: 1, lastHeardAt: 2, + rawPath: [], resolvedRoute: [], ...over, +}); + +function renderPanel(packets: TracePacket[]) { + vi.mocked(getTraceDetail).mockResolvedValue({ traceTag: "deadbeef", packets }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} onAnalyze={() => {}} /> + , + ); +} + +// the server sends null for both when the payload didn't parse or the packet has no IATAs +describe("TraceDetailPanel", () => { + it("renders hops when resolvedRoute is null", async () => { + renderPanel([pkt({ rawPath: [{ hash: "ab", snr: 4 }], resolvedRoute: null })]); + expect(await screen.findByText("AB")).toBeInTheDocument(); + }); + + it("shows the no-path note when rawPath is null", async () => { + renderPanel([pkt({ rawPath: null, resolvedRoute: null })]); + expect(await screen.findByText("no path")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index d070e3a..f1f2343 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -4,15 +4,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { TraceList } from "../../../src/features/traces/TraceList"; import { RegionProvider } from "../../../src/hooks/useRegion"; -import { ALL_REGIONS } from "../../../src/hooks/region-selection"; -import { getTraces, getTraceDetail, getRegions } from "../../../src/api/client"; +import { ALL_REGIONS, type RegionSelection } from "../../../src/hooks/region-selection"; +import { getTraces, getTraceDetail, getRegions, getRegion } from "../../../src/api/client"; import { timeAgoMs } from "../../../src/lib/formatters"; import type { TraceTagSummary, TraceDetail } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; vi.mock("../../../src/api/client", () => ({ getTraces: vi.fn(), getTraceDetail: vi.fn(), getRegions: vi.fn(), + getRegion: vi.fn(), })); const mockGetTraces = vi.mocked(getTraces); @@ -31,11 +33,11 @@ const detail: TraceDetail = { ], }; -function renderTraces(onAnalyze = vi.fn()) { +function renderTraces(onAnalyze = vi.fn(), selection: RegionSelection = ALL_REGIONS) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + {children} ); render(, { wrapper }); @@ -174,6 +176,14 @@ describe("TraceList", () => { await waitFor(() => expect(mockGetTraces.mock.calls.at(-1)?.[1]).toMatchObject({ type: "PING" })); }); + it("explains that the type filter classifies whole tags", async () => { + mockGetTraces.mockResolvedValue([]); + + renderTraces(); + + expect(await screen.findByText(/TRACE if any packet in the tag is multi-hop/)).toBeInTheDocument(); + }); + it("shows an empty state when there are no traces", async () => { mockGetTraces.mockResolvedValue([]); @@ -181,4 +191,43 @@ describe("TraceList", () => { expect(await screen.findByText("No traces")).toBeInTheDocument(); }); + + it("translates the list and the detail panel", async () => { + await i18n.changeLanguage("fr"); + mockGetTraces.mockResolvedValue([tag("3f2a11c0", 2)]); + mockGetTraceDetail.mockResolvedValue(detail); + renderTraces(); + expect(await screen.findByText("1 étiquette")).toBeInTheDocument(); + expect(screen.getByText("2 paq. · 1 iata")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Tous" })).toBeInTheDocument(); + fireEvent.click(screen.getByText("3F2A11C0")); + expect(await screen.findByText("Paquets")).toBeInTheDocument(); + expect(await screen.findByText("2 paquets")).toBeInTheDocument(); + expect(screen.getAllByText("analyser →")).toHaveLength(2); + }); +}); + +it("waits for a named region to load before asking for its traces", async () => { + let loadRegions!: (value: { id: number; slug: string; name: string }[]) => void; + mockGetRegions.mockReturnValue(new Promise((done) => { loadRegions = done; })); + vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "onqc", name: "Ottawa", iatas: ["YOW"] }); + mockGetTraces.mockResolvedValue([]); + + renderTraces(vi.fn(), { regions: ["onqc"], iatas: [] }); + + await new Promise((done) => setTimeout(done, 50)); + expect(mockGetTraces).not.toHaveBeenCalled(); + expect(screen.queryByText("No traces")).not.toBeInTheDocument(); + + loadRegions([{ id: 1, slug: "onqc", name: "Ottawa" }]); + await waitFor(() => expect(mockGetTraces).toHaveBeenCalledWith(["YOW"], expect.anything())); +}); + +it("stops waiting once the region list loads without the selected region", async () => { + mockGetRegions.mockResolvedValue([]); + mockGetTraces.mockResolvedValue([]); + + renderTraces(vi.fn(), { regions: ["deleted-region"], iatas: [] }); + + await waitFor(() => expect(mockGetTraces).toHaveBeenCalled()); }); diff --git a/tests/hooks/region-selection.test.ts b/tests/hooks/region-selection.test.ts index cd94df5..8b66ace 100644 --- a/tests/hooks/region-selection.test.ts +++ b/tests/hooks/region-selection.test.ts @@ -3,6 +3,7 @@ import { ALL_REGIONS, isAllRegions, resolveIatas, + emptyRegionSlug, regionKey, parseSelection, selectionToParams, @@ -14,6 +15,7 @@ import { const regionIatas = new Map([ ["western-canada", ["YVR", "YYJ"]], ["cascadia", ["YVR", "SEA"]], // intentionally overlaps YVR with western-canada + ["empty", []], ]); describe("isAllRegions", () => { @@ -117,3 +119,16 @@ describe("serialize/deserialize", () => { expect(deserializeSelection('{"regions":"oops"}')).toEqual(ALL_REGIONS); }); }); + +describe("emptyRegionSlug", () => { + it("names the selected region when it resolves to no IATAs", () => { + expect(emptyRegionSlug({ regions: ["empty"], iatas: [] }, regionIatas)).toBe("empty"); + }); + + it("is undefined for all regions, a populated selection, or regions still loading", () => { + expect(emptyRegionSlug(ALL_REGIONS, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty", "cascadia"], iatas: [] }, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty"], iatas: ["YVR"] }, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty", "unloaded"], iatas: [] }, regionIatas)).toBeUndefined(); + }); +}); diff --git a/tests/hooks/useRegion.test.tsx b/tests/hooks/useRegion.test.tsx new file mode 100644 index 0000000..ee7a1fb --- /dev/null +++ b/tests/hooks/useRegion.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { RegionProvider, useRegion } from "../../src/hooks/useRegion"; +import { shouldRetryQuery } from "../../src/api/rate-limit"; + +const getRegions = vi.fn(); +const getRegion = vi.fn(); +vi.mock("../../src/api/client", () => ({ + getRegions: () => getRegions(), + getRegion: (id: number) => getRegion(id), +})); + +const notFound = Object.assign(new Error("region not found"), { status: 404 }); + +function setup(regions: string[]) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: shouldRetryQuery, retryDelay: 0 } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useRegion(), { wrapper }); +} + +beforeEach(() => { + getRegions.mockReset(); + getRegion.mockReset(); + getRegions.mockResolvedValue([{ id: 1, slug: "west", name: "West" }, { id: 2, slug: "gone", name: "Gone" }]); +}); + +describe("useRegion", () => { + // the list and detail are cached separately server-side, so a listed region can 404 on its detail + it("still resolves when one region's detail is missing", async () => { + getRegion.mockImplementation((id: number) => + id === 1 ? Promise.resolve({ id: 1, slug: "west", name: "West", iatas: ["YVR"] }) : Promise.reject(notFound)); + const { result } = setup(["west"]); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.iatas).toEqual(["YVR"]); + }); + + it("treats a selected region whose detail 404s like any other unknown slug", async () => { + getRegion.mockImplementation((id: number) => + id === 1 ? Promise.resolve({ id: 1, slug: "west", name: "West", iatas: ["YVR"] }) : Promise.reject(notFound)); + const { result } = setup(["gone"]); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.regionKey).toBe("*"); + }); +}); diff --git a/tests/hooks/useScopes.test.tsx b/tests/hooks/useScopes.test.tsx new file mode 100644 index 0000000..970825f --- /dev/null +++ b/tests/hooks/useScopes.test.tsx @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useScopes } from "../../src/hooks/useScopes"; +import { getScopes } from "../../src/api/client"; + +const region = { iatas: ["YOW"] as string[] | undefined, regionKey: "YOW", isResolved: true }; +vi.mock("../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../src/api/client", () => ({ getScopes: vi.fn(async (iatas?: string[]) => (iatas ? ["#yow"] : ["#can", "#yow", "#yyz"])) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YOW"]; region.regionKey = "YOW"; region.isResolved = true; }); + +const setup = (keep?: string | string[]) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const hook = renderHook(() => useScopes(keep), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + return { ...hook, client }; +}; + +it("lists only the selected region's scopes, and every scope for All", async () => { + const { result, rerender, client } = setup(); + await waitFor(() => expect(result.current).toEqual(["#yow"])); + expect(getScopes).toHaveBeenCalledWith(["YOW"]); + region.iatas = undefined; region.regionKey = "*"; rerender(); + await waitFor(() => expect(result.current).toEqual(["#can", "#yow", "#yyz"])); + expect(getScopes).toHaveBeenLastCalledWith(undefined); + expect(client.getQueryCache().findAll({ queryKey: ["scopes"] })).toHaveLength(2); +}); + +it("keeps a selected scope from another region so a shared link still filters", async () => { + const { result } = setup(["#yyz"]); + await waitFor(() => expect(result.current).toEqual(["#yow", "#yyz"])); +}); + +it("does not fall back to every scope while the selected region is unresolved", () => { + region.isResolved = false; + const { result } = setup("#yow"); + expect(getScopes).not.toHaveBeenCalled(); + expect(result.current).toEqual(["#yow"]); +}); diff --git a/tests/i18n/index.test.ts b/tests/i18n/index.test.ts new file mode 100644 index 0000000..e60acc7 --- /dev/null +++ b/tests/i18n/index.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n, { languages, readLanguagePreference } from "../../src/i18n"; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.removeItem("beacon-language"); +}); + +describe("language preferences and catalogs", () => { + it("offers the bundled English and French catalogs by their native names", () => { + expect(languages).toEqual(expect.arrayContaining([ + { code: "en", name: "English" }, + { code: "fr", name: "Français" }, + ])); + }); + + it("defaults to English and restores a supported saved choice", () => { + localStorage.removeItem("beacon-language"); + expect(readLanguagePreference()).toBe("en"); + localStorage.setItem("beacon-language", "fr"); + expect(readLanguagePreference()).toBe("fr"); + }); + + it.each(["unknown", "constructor", "", "../fr"])("ignores unsupported saved language %j", (value) => { + localStorage.setItem("beacon-language", value); + expect(readLanguagePreference()).toBe("en"); + }); + + it("still changes language when browser storage is unavailable", async () => { + vi.stubGlobal("localStorage", { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() {}, + }); + try { + expect(readLanguagePreference()).toBe("en"); + await i18n.changeLanguage("fr"); + expect(i18n.t("tabs.Packets")).toBe("Paquets"); + expect(document.documentElement.lang).toBe("fr"); + expect(document.documentElement.dir).toBe("ltr"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not persist a language on module init, only on an explicit change", async () => { + // Isolated in-memory store: the real localStorage is a single process-wide object (Node's + // localStorage shadows jsdom's per test file), so a leftover "beacon-language" from another + // suite sharing this worker could otherwise land here before the dynamic import reads it. + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }); + try { + vi.resetModules(); + const fresh = await import("../../src/i18n"); + expect(localStorage.getItem("beacon-language")).toBeNull(); + await fresh.default.changeLanguage("fr"); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("persists only the language preference and restores English document metadata", async () => { + localStorage.setItem("beacon-region", "YVR"); + await i18n.changeLanguage("fr"); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + expect(localStorage.getItem("beacon-region")).toBe("YVR"); + await i18n.changeLanguage("en"); + expect(document.documentElement.lang).toBe("en"); + localStorage.removeItem("beacon-region"); + }); + + it("falls back to English for missing and empty translated strings", async () => { + i18n.addResource("en", "translation", "fallbackTest", "English fallback"); + await i18n.changeLanguage("fr"); + expect(i18n.t("fallbackTest")).toBe("English fallback"); + i18n.addResource("fr", "translation", "fallbackTest", ""); + expect(i18n.t("fallbackTest")).toBe("English fallback"); + }); + + it("uses each language's plural forms and interpolates the retry duration", () => { + expect(i18n.t("region.count", { lng: "en", count: 1 })).toBe("1 region"); + expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions"); + expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région"); + expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions"); + expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("DÉBIT LIMITÉ 5 s"); + }); + + it("has whole-phrase battery/noise labels in both catalogs (no joined-word strings)", () => { + expect(i18n.t("observerPage.batteryV", { lng: "en" })).toBe("Battery V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "en" })).toBe("Noise dBm"); + expect(i18n.t("observerPage.batteryV", { lng: "fr" })).toBe("Batterie V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "fr" })).toBe("Bruit dBm"); + }); + + it("interpolates the payload-type total as a plain value, not a plural count", () => { + // formatCount can return a non-numeric string like "1.2k"; a `count` placeholder would feed that + // into plural resolution instead of a straight interpolation. + expect(i18n.t("mesh.obs", { lng: "en", value: "1.2k" })).toBe("1.2k obs"); + expect(i18n.t("mesh.obs", { lng: "fr", value: "1,2k" })).toBe("1,2k obs"); + }); +}); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index db25110..80d7abf 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -1,14 +1,20 @@ import { describe, it, expect } from "vitest"; +import i18n from "../../src/i18n"; import { + formatRadio, + formatRadioParts, formatHex, formatAbsolute, + formatUtc, timeAgoMs, + timeAgoParts, formatSnr, snrLevel, formatPropagation, formatCount, formatClockDrift, formatRatePerDay, + formatUptime, } from "../../src/lib/formatters"; const DAY_MS = 86_400_000; @@ -36,6 +42,26 @@ describe("formatAbsolute", () => { }); }); +describe("formatUtc", () => { + const t = 3_665_000; // 1970-01-01 01:01:05 UTC + + it("formats as YYYY-MM-DD HH:MM by default", () => { + expect(formatUtc(t)).toBe("1970-01-01 01:01"); + }); + + it("appends :SS when seconds is requested", () => { + expect(formatUtc(t, { seconds: true })).toBe("1970-01-01 01:01:05"); + }); + + it("shows only HH:MM when timeOnly is requested", () => { + expect(formatUtc(t, { timeOnly: true })).toBe("01:01"); + }); + + it("shows HH:MM:SS when both timeOnly and seconds are requested", () => { + expect(formatUtc(t, { timeOnly: true, seconds: true })).toBe("01:01:05"); + }); +}); + describe("timeAgoMs", () => { it("renders sub-minute as seconds and minutes/hours/days above that", () => { const now = Date.now(); @@ -50,6 +76,20 @@ describe("timeAgoMs", () => { }); }); +describe("timeAgoParts", () => { + it("splits the same clamping/flooring timeAgoMs uses into a count and unit", () => { + const now = Date.now(); + expect(timeAgoParts(now - 5_000)).toEqual({ count: 5, unit: "s" }); + expect(timeAgoParts(now - 5 * 60_000)).toEqual({ count: 5, unit: "m" }); + expect(timeAgoParts(now - 3 * 3_600_000)).toEqual({ count: 3, unit: "h" }); + expect(timeAgoParts(now - 2 * 86_400_000)).toEqual({ count: 2, unit: "d" }); + }); + + it("clamps future timestamps (clock skew) to 0s", () => { + expect(timeAgoParts(Date.now() + 60_000)).toEqual({ count: 0, unit: "s" }); + }); +}); + describe("snrLevel", () => { it("returns good for SNR >= 10", () => { expect(snrLevel(10.5)).toBe("good"); @@ -109,6 +149,16 @@ describe("formatPropagation", () => { }); describe("formatClockDrift", () => { + it("accepts translated direction labels without changing signs, magnitude or rounding", () => { + const labels = { inSync: "synchronisé", ahead: "en avance", behind: "en retard" }; + expect(formatClockDrift(0, labels)).toBe("synchronisé"); + expect(formatClockDrift(42, labels)).toBe("+42s en avance"); + expect(formatClockDrift(-45, labels)).toBe("-45s en retard"); + expect(formatClockDrift(432, labels)).toBe("+7m 12s en avance"); + expect(formatClockDrift(-3670, labels)).toBe("-1h 1m en retard"); + expect(formatClockDrift(3600, labels)).toBe("+1h 0m en avance"); + }); + it("labels a zero drift as in sync", () => { expect(formatClockDrift(0)).toBe("in sync"); }); @@ -155,3 +205,34 @@ describe("formatRatePerDay", () => { expect(formatRatePerDay(undefined, 7 * DAY_MS)).toBe("—"); }); }); + +describe("formatRadioParts", () => { + it("lists frequency, spreading factor, bandwidth and coding rate in the observer panel's order", () => { + expect(formatRadioParts({ freqMhz: 910.525, sf: 7, bwKhz: 62.5, cr: 5 })).toBe("910.525 MHz · SF7 · 62.5 kHz · CR 4/5"); + }); + + it("leaves out whatever is unknown and is null when nothing is", () => { + expect(formatRadioParts({ freqMhz: 910.525, sf: 7, bwKhz: 62.5 })).toBe("910.525 MHz · SF7 · 62.5 kHz"); + expect(formatRadioParts({ sf: 11 })).toBe("SF11"); + expect(formatRadioParts({})).toBeNull(); + }); + + it("backs the compact-string formatter so both read the same", () => { + expect(formatRadio("915,250,11")).toBe(formatRadioParts({ freqMhz: 915, sf: 11, bwKhz: 250 })); + }); +}); + +describe("formatters in French", () => { + it("uses French unit words for uptime and daily rates", async () => { + await i18n.changeLanguage("fr"); + expect(formatUptime(90061)).toBe("1 j 1 h 1 min"); + expect(formatUptime(300)).toBe("5 min"); + expect(formatRatePerDay(340, DAY_MS)).toBe("340/j"); + expect(formatClockDrift(-3670, { inSync: "synchronisé", ahead: "en avance", behind: "en retard" })).toBe("-1 h 1 min en retard"); + }); + + it("keeps the compact English units", () => { + expect(formatUptime(90061)).toBe("1d 1h 1m"); + expect(formatUptime(3660)).toBe("1h 1m"); + }); +}); diff --git a/tests/lib/runtime-env.test.ts b/tests/lib/runtime-env.test.ts new file mode 100644 index 0000000..844ad0d --- /dev/null +++ b/tests/lib/runtime-env.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { runtimeEnv } from "../../src/lib/runtime-env"; + +describe("runtimeEnv", () => { + afterEach(() => { + delete window.__BEACON_CONFIG__; + }); + + it("prefers a non-empty value from window.__BEACON_CONFIG__", () => { + window.__BEACON_CONFIG__ = { VITE_BANNER: "from container" }; + expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from container"); + }); + + it("falls back to import.meta.env when the runtime value is empty or missing", () => { + window.__BEACON_CONFIG__ = { VITE_BANNER: "" }; + expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from build"); + delete window.__BEACON_CONFIG__; + expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from build"); + }); + + it("ignores non-string runtime values", () => { + window.__BEACON_CONFIG__ = { VITE_MAP_ZOOM: 7 }; + expect(runtimeEnv("VITE_MAP_ZOOM", "4")).toBe("4"); + }); + + it("returns undefined when neither source has a value", () => { + expect(runtimeEnv("VITE_NOT_SET_ANYWHERE", undefined)).toBeUndefined(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 68c2a33..315c9a7 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,6 +1,7 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, vi } from "vitest"; +import i18n from "../src/i18n"; // jsdom doesn't implement matchMedia. Provide a default stub so components that read media queries // mount as "desktop" by default — a hover-capable pointer, not below the mobile width. Individual @@ -20,6 +21,25 @@ if (!window.matchMedia) { })); } -afterEach(() => { +// Node 26 exposes `localStorage` as undefined without `--localstorage-file`, shadowing jsdom's. +if (!globalThis.localStorage) { + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { return store.size; }, + }, + configurable: true, + writable: true, + }); +} + +afterEach(async () => { cleanup(); + await i18n.changeLanguage("en"); + try { localStorage.clear(); } catch { /* stubbed per-test */ } }); From 7fb24789d1649aa079e4568d335d60b3400d87be Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:43:03 -0400 Subject: [PATCH 3/5] v2.0.1 (#144) --- package-lock.json | 4 +- package.json | 2 +- src/i18n/locales/sv.json | 908 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 911 insertions(+), 3 deletions(-) create mode 100644 src/i18n/locales/sv.json diff --git a/package-lock.json b/package-lock.json index 4957279..7441ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.0", + "version": "2.0.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 483e457..ebabcfa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.0", + "version": "2.0.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json new file mode 100644 index 0000000..2b6507b --- /dev/null +++ b/src/i18n/locales/sv.json @@ -0,0 +1,908 @@ +{ + "name": "Svenska", + "translation": { + "language": { + "label": "Språk", + "help": "Välj ett språk." + }, + "tabs": { + "Packets": "Paket", + "Channels": "Kanaler", + "Map": "Karta", + "Nodes": "Noder", + "Observers": "Observatörer", + "Routes": "Rutter", + "Traces": "Spårningar", + "Analytics": "Analys" + }, + "navigation": { + "primary": "Huvudnavigering", + "more": "Mer", + "moreTabs": "Fler flikar" + }, + "connection": { + "live": "LIVE", + "stale": "INAKTUELL {{age}}", + "offline": "OFFLINE", + "rateLimited": "HASTIGHETSBEGRÄNSAD {{seconds}} s" + }, + "region": { + "label": "Region", + "allShort": "ALLA", + "allRegions": "Alla regioner", + "group": "Regioner", + "areaGroup": "Områden", + "count_one": "{{count}} region", + "count_other": "{{count}} regioner", + "iataCount_one": "{{count}} område", + "iataCount_other": "{{count}} områden", + "filter": "Sök efter region eller områdeskod…", + "failed": "Kunde inte läsa in", + "noMatches": "Inga träffar" + }, + "theme": { + "label": "Tema" + }, + "common": { + "loading": "Läser in…", + "loadFailed": "Kunde inte läsa in", + "noData": "Inga data", + "dataUnavailable": "Data är inte tillgängliga", + "info": "Mer information", + "closeDetailPanel": "Stäng detaljpanelen", + "expandDetailPanel": "Expandera detaljpanelen", + "minimizeDetailPanel": "Minimera detaljpanelen", + "notFound": "Hittades inte", + "loadingDots": "Läser in...", + "copyLink": "Kopiera länk", + "copied": "Kopierad", + "copyFailed": "Kopieringen misslyckades", + "filters": "Filter", + "closeFilters": "Stäng filter", + "clearAll": "Rensa alla", + "done": "Klar", + "all": "Alla", + "noOptions": "Inga alternativ", + "copy": "Kopiera", + "close": "Stäng", + "clear": "Rensa", + "none": "Ingen", + "noMatches": "Inga träffar", + "filterPlaceholder": "Filtrera..." + }, + "mesh": { + "totalPackets": "Totalt antal paket", + "observations": "Observationer", + "activeObservers": "Aktiva observatörer", + "activeIatas": "Aktiva områden", + "lastHours": "Senaste {{hours}} h", + "lastHoursTo": "Senaste {{hours}} h · till {{time}} UTC", + "observationsTitle": "Observationer · {{range}}", + "topObservers": "Mest aktiva observatörer · {{range}}", + "payloadTypes": "Nyttolasttyper · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Mest aktiva noder · {{range}}", + "nodeTypes": "Nodtyper · hela perioden", + "radioPresets": "Radioförinställningar · hela perioden", + "scope": "Scope", + "packets": "Paket", + "observers": "Observatörer", + "nodes": "Noder", + "nodesCenter": "NODER" + }, + "stats": { + "section": "Statistikavsnitt", + "mobileSection": "Avsnitt", + "timeRange": "Tidsintervall", + "tabs": { + "mesh": "Mesh", + "traffic": "Trafik", + "signal": "RF / signal", + "paths": "Vägar och hashvärden", + "scopes": "Scopes", + "talkers": "Sändare", + "clockdrift": "Klockdrift", + "compare": "Jämför observatörer", + "graph": "Granngraf" + }, + "ranges": { + "24h": "24 h", + "7d": "7 d", + "30d": "30 d" + } + }, + "signal": { + "subtitle": "Signalstyrka för paket som tagits emot av observatörer.", + "error": "Kunde inte läsa in signaldata. Försök uppdatera eller välj en kortare tidsperiod.", + "receptions": "Observationer", + "meanSnr": "Genomsnittligt SNR", + "meanRssi": "Genomsnittligt RSSI", + "hoursWithRecords": "Aktiva timmar", + "measurement": "Mätt av varje observatör på det sista hoppet. Tiderna anges i UTC.", + "window": "{{since}} till {{until}} UTC, aktuell timme exkluderad. Tomma timmar innebär inte ett avbrott.", + "histogramTitle": "Fördelning av {{metric}} · {{unit}}", + "meanTitle": "Genomsnittligt {{metric}} per timme · {{unit}}", + "availability": "Tillgängliga mätvärden", + "averagesTitle": "Vad genomsnittsvärdena omfattar", + "loading": "Läser in signal…", + "empty": "Inga observationer under den här perioden.", + "availabilityTable": "Tillgängliga signalmätvärden", + "metric": "Mätvärde", + "samples": "Mätningar", + "missing": "Saknas", + "share": "Andel", + "averagesHelp": "Varje observation väger lika tungt; saknade mätvärden utelämnas.", + "details": "Exakta histogram- och timvärden", + "histogramTable": "Histogram för {{metric}}", + "binsHelp": "Varje intervall inkluderar sin nedre gräns; de yttersta intervallen fångar allt utanför.", + "hourlyTable": "Signalvärden per timme", + "utcHour": "UTC-timme", + "receptionsColumn": "Observationer", + "metricSamples": "Mätningar av {{metric}}", + "meanUnit": "Genomsnitt {{unit}}", + "binInterval": "{{lower}} till < {{upper}}", + "histogramDescription": "Fördelning av {{metric}} i {{unit}}. Exakta intervallgränser och antal visas nedan.", + "trendDescription": "Genomsnittligt {{metric}} per timme i {{unit}}, UTC. Luckor innebär att mätvärden saknas. Exakta värden finns i timtabellen.", + "coverageDescription": "Tillgängliga och saknade mätvärden för SNR och RSSI. Exakta antal och procenttal finns i tabellen över tillgängliga mätvärden.", + "available": "Tillgänglig", + "unavailable": "Ej tillgänglig", + "noSample": "Ingen mätning" + }, + "paths": { + "subtitle": "Hur rutthashar lagras i mottagna paket.", + "error": "Kunde inte läsa in vägdata. Försök uppdatera eller välj en kortare tidsperiod.", + "receptions": "Observationer", + "withHashPaths": "Med hashvägar", + "multiByteShare": "Andel med flera byte", + "mostEntries": "Flest vägposter", + "measurement": "Vägposter räknas per observation.", + "window": "{{since}} till {{until}} UTC, aktuell timme exkluderad.", + "widthTitle": "Observerade hashbredder", + "lengthTitle": "Mottagna vägposter", + "trendTitle": "Observationer med hashvägar över tid", + "coverageTitle": "Täckning per vägformat", + "countsTitle": "Vad antalen omfattar", + "loading": "Läser in vägar…", + "empty": "Inga observationer under den här perioden.", + "classificationTable": "Antal per vägklassificering", + "category": "Kategori", + "receptionsColumn": "Observationer", + "share": "Andel", + "categories": { + "hashed": "Hashvägar", + "empty": "Tomma", + "trace": "Trace", + "unclassified": "Oklassificerade" + }, + "countsHelp": "Tomma vägar har ingen hashbredd, och oklassificerade paket saknade användbara vägdata.", + "details": "Exakta värden för bredd, väglängd och timmar", + "widthTable": "Antal per hashbredd", + "bytesPerHash": "Byte per hash", + "lengthTable": "Antal per vägpost", + "headerEntries": "Poster i headern", + "lengthsHelp": "Noll betyder en tom väg; timmar utan data lämnas som luckor.", + "hourlyTable": "Vägantal per timme", + "utcHour": "UTC-timme", + "hashWidth_one": "{{count}} byte", + "hashWidth_other": "{{count}} byte", + "hashPathsCenter": "HASHVÄGAR", + "receptionsCenter": "OBSERVATIONER", + "widthDescription": "Andel per hashbredd bland icke-tomma vägar. Exakta antal visas nedan.", + "coverageDescription": "Vägtyp för varje observation. Exakta antal visas bredvid.", + "lengthDescription": "Vägposter per observation. Noll betyder en tom väg. Exakta antal visas i tabellen.", + "entries": "Vägposter", + "trendDescription": "Observationer per timme med 1-, 2- eller 3-byte långa hashvägar, UTC. Luckor innebär att data saknas. Exakta värden finns i timtabellen." + }, + "traffic": { + "subtitle": "När och var observatörer tar emot paket.", + "error": "Kunde inte läsa in trafikdata. Försök uppdatera.", + "receptions": "Observationer", + "reportingIatas": "Aktiva områden", + "busiestHour": "Mest aktiva timme", + "hoursWithRecords": "Aktiva timmar", + "measurement": "Antalen avser observationer, inte unika paket. Tiderna anges i UTC; tomma timmar innebär inte ett avbrott.", + "trendTitle": "Observationer per område", + "hourlyRecords": "Per timme · UTC", + "hourlyTitle": "Aktivitet per timme · {{range}}", + "shareTitle": "Andel observationer", + "byIata": "Observationer per område", + "loading": "Läser in trafik…", + "empty": "Inga observationer under den här perioden.", + "receptionsColumn": "Observationer", + "area": "Område", + "share": "Andel", + "groupingHelp": "Diagram grupperar mindre områden under ”Övriga områden”; tabellen behåller alla områden.", + "receptionsCenter": "OBSERVATIONER", + "shareDescription": "Andel observationer per område. Exakta antal och procenttal visas i tabellen.", + "unassigned": "Ej tilldelat", + "otherIatas": "Övriga områden", + "trendDescription": "Observationer per timme och område. Luckor innebär att data saknas. Exakta totaler finns i datatabellen.", + "heatmapDescription": "Aktivitet per UTC-dag och timme. Ljusare celler innebär fler observationer.", + "more": "Fler", + "fewer": "Färre", + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observationer" + }, + "scopes": { + "subtitle": "Scope-aktivitet för den valda regionen.", + "search": "Sök efter scope", + "error": "Kunde inte läsa in scopes. Försök uppdatera.", + "active": "Scopes med data", + "scopedPackets": "Paket med scope", + "memberships": "Observatörer", + "defaultNodes": "Noder i standardscope", + "observersLine": "Linje: mottagning per timme", + "nodesLine": "Linje: annonsering per timme", + "measurement": "Paketantal omfattar det valda intervallet. Antalet observatörer och noder är aktuella värden, och en observatör kan tillhöra flera scopes.", + "packetsTitle": "Paket per scope", + "membershipsTitle": "Observatörer per scope", + "countsTitle": "Antal per scope", + "visibleCount_one": "{{visible}} av {{count}} scope", + "visibleCount_other": "{{visible}} av {{count}} scopes", + "loading": "Läser in scopes…", + "noMatches": "Inga scopes matchar sökningen.", + "empty": "Inga scope-data är tillgängliga.", + "scope": "Scope", + "packets": "Paket", + "observers": "Observatörer", + "nodes": "Noder", + "groupingHelp": "Diagram visar de 12 största scopes samt eventuell återstod. Tabellen behåller alla matchande scopes.", + "other": "Övriga scopes", + "chartDescription": "Antal per transport-scope. Exakta värden för alla matchande scopes listas i tabellen." + }, + "nodeDetail": { + "viewOnMap": "Visa på karta", + "title": "Noddetaljer", + "copyLink": "Kopiera nodlänk", + "notFound": "Noden hittades inte", + "summary": "Sammanfattning", + "copyKey": "Kopiera publik nyckel", + "viewObserver": "Visa observatör →", + "location": "Position", + "lat": "Lat", + "lng": "Long", + "source": "Källa", + "capabilities": "Funktioner", + "minFirmware": "Min. firmware", + "multibytePaths": "Flerbytevägar", + "multibyteTraces": "Flerbytespårningar", + "yes": "ja", + "no": "nej", + "radio": "Radio", + "scope": "Scope", + "timestamps": "Tidsstämplar", + "first": "Första", + "last": "Senaste", + "advert": "Annons", + "clockDrift": "Klockdrift", + "neighbors": "Grannar", + "neighborsCount": "Grannar ({{count}})", + "noNeighbors": "Inga kända grannar", + "recentPackets": "Senaste paket", + "noRecentPackets": "Inga senaste paket", + "observations_one": "{{formatted}} obs", + "observations_other": "{{formatted}} obs", + "hops": "Hopp" + }, + "nodes": { + "lastHeard": "senast mottagen {{ago}}", + "colName": "Namn", + "colType": "Typ", + "colRadio": "Radio", + "colAreas": "Områden", + "colNeighbors": "Grannar", + "colLocation": "Position", + "observer": "Observatör", + "empty": "Inga noder", + "neighbors_one": "{{formatted}} granne", + "neighbors_other": "{{formatted}} grannar", + "possiblyForeign": "Möjligen främmande", + "possiblyForeignHint": "Nodens position ligger utanför serverns lokala område." + }, + "packetRow": { + "noResolution": "Ingen vägupplösning tillgänglig", + "observer": "observatör", + "first": "första", + "last": "senaste", + "spread": "spann", + "noPath": "Ingen upplöst väg att visa på kartan", + "noObservations": "Inga observationer", + "loadFailed": "Kunde inte läsa in observationer", + "heardBy_one": "Mottaget av {{count}} observatör", + "heardBy_other": "Mottaget av {{count}} observatörer", + "unknown": "Okänd" + }, + "clockDrift": { + "caption": "Repeatrar och rumsservrar ur synk · störst avvikelse först", + "node": "Nod", + "drift": "Drift", + "checked": "Kontrollerad", + "inSync": "synkroniserad", + "ahead": "före", + "behind": "efter", + "empty": "Inga repeatrar ur synk", + "areas": "Områden" + }, + "timestamp": { + "ago": "för {{duration}} sedan", + "unit": { + "s": "{{count}} s", + "m": "{{count}} min", + "h": "{{count}} h", + "d": "{{count}} d" + } + }, + "observerPage": { + "back": "Tillbaka till observatörer", + "detail": "Observatörsdetaljer", + "range": "Tidsintervall", + "open": "Öppna observatörspanel", + "legacy": "Observationer (hela perioden)", + "metrics": "Observatörsmätvärden", + "records": "Mottagna paket", + "lastPacket": "Senaste paket", + "battery": "Batteri", + "batteryV": "Batteri V", + "uptime": "Drifttid", + "noise": "Brusgolv", + "noiseDbm": "Brus dBm", + "packets": "Paket", + "search": "Sök efter observatör", + "choose": "Välj en observatör", + "searchHelp": "Sök efter namn för att hitta observatörer utanför de första 50 resultaten.", + "summaryMissing": "Pakettotaler är inte tillgängliga.", + "statusRecent": "Aktuell status", + "statusStale": "Statusen är inaktuell", + "statusMissing": "Ingen statusrapport", + "activity": "Mottagna paket per {{interval}}", + "mix": "Fördelning av pakettyper", + "signal": "Mottagen signal", + "airtime": "Radiotid TX / RX", + "device": "Enhetstelemetri", + "queue": "Kölängd", + "errors": "Mottagningsfel", + "busy": "Kanalbeläggning", + "empty": "Inga paket mottagna under perioden", + "noTelemetry": "Ingen telemetri", + "noTelemetryHelp": "Den här observatören skickade ingen enhetstelemetri under perioden.", + "loadFailed": "Observatören kunde inte läsas in.", + "retry": "Försök igen", + "model": "Modell", + "firmware": "Firmware", + "client": "Klient", + "radio": "Radio", + "publicKey": "Publik nyckel", + "copy": "Kopiera", + "copied": "Kopierad", + "packetArrival": "Paketankomst", + "firstSeen": "Först sedd", + "copyLink": "Kopiera observatörslänk", + "window": "UTC-intervall", + "time": "Tid (UTC)", + "mean": "Genomsnitt", + "minimum": "Minimum", + "unknown": "Okänd", + "recording": "Läser in observatör…", + "hour": "timme", + "day": "dag", + "sidebarSearch": "Sök observatörer", + "noMatches": "Inga träffar", + "none": "Inga observatörer", + "packetsLastHour": "Paket senaste timmen", + "copyKey": "Kopiera publik nyckel", + "sort": "Sortera observatörer", + "sortActivity": "Aktivitet", + "sortName": "Namn", + "deviceInfo": "Enhet", + "brokers": "Brokers", + "copyLinkShort": "Kopiera länk", + "advertsHeard": "Mottagna annonser", + "noAdverts": "Inga annonser mottagna", + "unknownNode": "okänd", + "hops": "Hopp" + }, + "observerCompare": { + "open": "Jämför med…", + "close": "Stäng jämförelse", + "title": "Jämför observatörer", + "partner": "Observatör B", + "choose": "Välj en observatör att jämföra med", + "invalidObserver": "Välj en annan observatör med en giltig identifierare.", + "invalidTime": "Jämförelsetiden är ogiltig eller för gammal. Uppdatera för att använda det senaste kompletta intervallet.", + "refresh": "Uppdatera", + "unavailable": "Jämförelse är inte tillgänglig på den här servern.", + "currentNote": "Totalerna omfattar detta intervall; batteri, drifttid och brusgolv är aktuella värden.", + "metrics": "Mätvärden för observatörsjämförelse", + "metric": "Mätvärde", + "activity": "Mottagna paket", + "overlap": "Mottagna av båda", + "retention": "Endast paket som servern fortfarande behåller jämförs.", + "total_one": "{{count}} flood-paket", + "total_other": "{{count}} flood-paket", + "empty": "Ingen av observatörerna tog emot något flood-paket under intervallet.", + "onlyA": "Endast A", + "both": "Båda", + "onlyB": "Endast B", + "definition": "Varje flood-paket räknas en gång. Detta är inte ett mått på paketförlust.", + "retainedWindow": "Paket sparas bara i några dagar, så äldre intervall kan vara ofullständiga.", + "overlapShortRanges": "Paketöverlappning är tillgänglig för 24 h och 7 d.", + "invalidPair": "Välj två olika observatörer.", + "invalidRange": "Välj en giltig starttid och en senare sluttid.", + "observerA": "Observatör A", + "chooseObserver": "Välj en observatör", + "start": "Start (lokal tid)", + "end": "Slut (lokal tid)", + "retentionWarning": "Endast senaste paket sparas, så början av intervallet kan vara tom.", + "compare": "Jämför", + "selection": "Val", + "invalidLink": "Jämförelselänken innehåller ogiltiga eller saknade värden. Välj observatörer och datum nedan.", + "floodHeard": "Mottagna flood-paket", + "pair": "A: {{a}} · B: {{b}}", + "allRegions": "Alla", + "localTimeNote": "Lokal tid; sluttiden är exkluderad.", + "comparing": "Jämför…", + "retryComparison": "Försök jämföra igen", + "packetsTotal_one": "{{value}} flood-paket", + "packetsTotal_other": "{{value}} flood-paket", + "percentNote": "Procenttalen avser alla flood-paket som någon av observatörerna tog emot; varje paket räknas en gång. Detta är inte ett mått på paketförlust.", + "emptyRegion": "Ingen av observatörerna tog emot något flood-paket under perioden och i regionen.", + "tableLabel": "Jämförelse av flood-paket", + "heardBy": "Mottaget av", + "packets": "Paket", + "percentUnion": "% av unionen" + }, + "investigation": { + "title": "Observationer", + "observations_one": "{{count}} observation", + "observations_other": "{{count}} observationer", + "heardBy_one": "Mottaget av {{count}} observatör", + "heardBy_other": "Mottaget av {{count}} observatörer", + "heardTimes_one": "Mottaget en gång", + "heardTimes_other": "Mottaget {{count}} gånger", + "byObservers_one": "av {{count}} observatör", + "byObservers_other": "av {{count}} observatörer", + "pathNote": "Grupperat efter repeatrarna som varje kopia passerade. Korta ID:n kan motsvara mer än en nod.", + "traceNote": "TRACE-poster visar den planerade rutten, inte vägen som paketet faktiskt tog.", + "empty": "Inga observationer för paketet.", + "path": "Väg {{number}}", + "tracePath": "Avsedd rutt {{number}}", + "noEntries": "Inga vägposter", + "unavailablePath": "Vägdata (byte) är inte tillgängliga", + "inspect": "Välj observation", + "observer": "Granska observatör", + "map": "Visa väg på karta", + "unmappable": "Kräver minst två repeatrar med kända positioner.", + "retention": "En post per observatör. Äldre paket försvinner enligt serverns lagringstid för paket.", + "unavailableReport": "Observationen är inte längre tillgänglig eller så är länken ogiltig. Välj en nedan.", + "copy": "Kopiera länk", + "copyPacket": "Kopiera paketlänk", + "viewAll": "Visa alla vägar på karta", + "mapDialog": "Karta över paketväg", + "mapTitle": "Paketväg", + "copyPath": "Kopiera väglänk", + "closeMap": "Stäng vägkarta", + "allPaths": "Alla vägar", + "traceRoute": "Trace-rutt", + "mapNote": "Linjerna förbinder repeatrar med kända positioner. De bevisar inte en direkt radiolänk.", + "unavailableMap": "Den här vägen kan inte visas på kartan. Välj en annan väg eller Alla vägar.", + "first": "Första", + "last": "Senaste", + "span": "Mottaget under", + "nodeDialog": "Noddetaljer", + "packetDialog": "Paketanalyserare", + "closeObserver": "Stäng observatörsdetaljer", + "loadMore": "Läs in fler ({{count}} kvar)" + }, + "routeEvidence": { + "recent": "Senaste observationer", + "loading": "Läser in observationer…", + "missing": "Senaste observationer är inte tillgängliga för den här rutten på servern.", + "error": "Kunde inte läsa in senaste observationer. Försök igen.", + "retry": "Försök igen", + "match": "Observationer längs exakt dessa hopp med {{width}} byte per hopp, nyaste först. TRACE-paket ingår inte.", + "caution": "Matchande korta ID:n bevisar inte att det är samma fysiska väg.", + "retention": "Äldre paket försvinner; antalet ”Mottaget” ovan omfattar ruttens hela livstid.", + "unavailable": "Den här rutten kan inte matchas mot paket.", + "empty": "Inga observationer på rutten finns kvar. Äldre observationer kan ha löpt ut.", + "more": "Läs in fler", + "cap": "Visar de 500 senaste observationerna.", + "inspect": "Granska paket" + }, + "channelMessages": { + "select": "Välj en kanal", + "back": "Tillbaka till kanaler", + "hash": "hash: {{hash}}", + "keyKnown": "känd nyckel", + "noKey": "ingen nyckel", + "hashtag": "hashtag", + "keyMissing": "Ingen dekrypteringsnyckel är konfigurerad för den här kanalen.", + "scope": "Scope", + "allScopes": "Alla scopes", + "unknownScope": "Okänd scope", + "inspect": "Granska paket från {{sender}}", + "loading": "Läser in meddelanden…", + "loadingOlder": "Läser in äldre meddelanden… ({{count}} visas)", + "loadOlder": "Läs in äldre meddelanden", + "empty": "Inga meddelanden för den här kanalen.", + "emptyScope": "Inga meddelanden matchar {{scope}}.", + "emptyNoKey": "Meddelanden kan inte dekrypteras utan kanalnyckeln.", + "error": "Kunde inte läsa in meddelanden.", + "retry": "Försök igen", + "viewPacket": "Visa paket", + "heardTimes_one": "Mottaget {{count}} gång", + "heardTimes_other": "Mottaget {{count}} gånger" + }, + "header": { + "settings": "Inställningar", + "github": "GitHub" + }, + "channels": { + "noMatches": "Inga matchande kanaler är inlästa.", + "loadError": "Kunde inte läsa in kanaler.", + "loadingChannels": "Läser in kanaler...", + "retryChannels": "Försök läsa in kanaler igen", + "loadOthers": "Läs in andra kanaler", + "loadMore": "Läs in fler kanaler", + "key": "nyckel", + "filters": "Kanalfilter", + "fieldName": "Namn", + "fieldHash": "Hash", + "keyFilter": "Nyckel", + "hashtagFilter": "Hashtag", + "keyKnownOption": "Känd", + "keyUnknownOption": "Okänd", + "any": "Valfri", + "yes": "Ja", + "no": "Nej" + }, + "map": { + "settings": "Kartinställningar", + "tiles": "Kartplattor", + "nodeType": "Nodtyp", + "nodeTypeLabel": "Nodtyp", + "clustering": "Klustring", + "neighborLines": "Grannlinjer", + "neighborLinesLabel": "Grannlinjer", + "areaBorders": "Områdesgränser", + "areaBordersLabel": "Områdesgränser", + "all": "Alla", + "on": "På", + "off": "Av", + "selected": "Vald", + "areaOutline": "Områdeskontur", + "observations": "Observationer", + "fainter": "svagare = mottaget längre tillbaka i tiden", + "copyLink": "Kopiera kartlänk", + "copyLinkLabel": "Kopiera en länk till den här kartvyn", + "liveMap": "Livekarta", + "playFlow": "Starta liveflöde av paket på kartan", + "stopFlow": "Stoppa liveflöde av paket på kartan", + "loadFailed": "Kartan kunde inte läsas in", + "loadFailedHint": "Kontrollera anslutningen och läs in sidan igen", + "style": "Kartstil", + "styles": { + "dark": "Mörk", + "liberty": "Liberty", + "positron": "Ljus" + }, + "controls": { + "toggleAttribution": "Visa/dölj källangivelse", + "mapFeedback": "Kartfeedback", + "map": "Karta", + "resetBearing": "Dra för att rotera kartan, klicka för att återställa norr", + "zoomIn": "Zooma in", + "zoomOut": "Zooma ut", + "closePopup": "Stäng popup" + } + }, + "nodeFilters": { + "label": "Nodfilter", + "type": "Typ", + "multibytePaths": "Flerbytevägar", + "multibyteTraces": "Flerbytespårningar", + "scope": "Scope", + "any": "Valfri", + "yes": "Ja", + "no": "Nej", + "fieldName": "Namn", + "fieldPubkey": "Publik nyckel", + "all": "Alla" + }, + "packetAnalyzer": { + "title": "Paketanalyserare", + "close": "Stäng analyseraren", + "empty": "Välj ett paket att analysera", + "summary": "Sammanfattning", + "rawPacket": "Råpaket", + "structure": "Paketstruktur", + "headerByte": "Headerbyte", + "ver": "Ver", + "type": "Typ", + "route": "Rutt", + "transportCodes": "Transportkoder", + "scopeCode": "Scope-kod", + "noKnownScope": "ingen känd scope", + "code2": "Kod 2", + "pathLength": "Väglängd", + "hashSize": "Hashstorlek", + "pathData": "Vägdata", + "originPubkey": "Ursprunglig publik nyckel", + "payloadBreakdown": "Nyttolastens uppbyggnad", + "payloadData": "Nyttolastdata", + "bytes_one": "{{count}} byte", + "bytes_other": "{{count}} byte", + "bits": { + "ver": "ver", + "type": "typ", + "route": "rutt", + "hash": "hash={{size}}B", + "hops": "hopp={{value}}", + "loc": "pos", + "name": "namn" + }, + "payload": { + "yes": "Ja", + "no": "Nej", + "encrypted": "Krypterad — nyckel saknas", + "to": "Till", + "from": "Från", + "ciphertext": "Chiffertext", + "decrypted": "Dekrypterad", + "channel": "Kanal", + "publicKey": "Publik nyckel", + "timestamp": "Tidsstämpel", + "signature": "Signatur", + "appFlags": "Appflaggor", + "role": "Roll", + "loc": "Pos", + "name": "Namn", + "location": "Position", + "traceTag": "Trace-tagg", + "authCode": "Autentiseringskod", + "flags": "Flaggor", + "tracePath": "Trace-väg", + "sender": "Avsändare", + "message": "Meddelande", + "attempt": "Försök {{value}}", + "flagsValue": "Flaggor {{value}}", + "data": "Data", + "time": "Tid", + "tag": "Tagg", + "checksum": "Kontrollsumma", + "bundledType": "Inbäddad typ", + "bundledData": "Inbäddade data", + "typeFilter": "Typfilter", + "typeN": "Typ {{value}}", + "any": "valfri", + "since": "Sedan", + "prefixOnly": "Endast prefix", + "nodeType": "Nodtyp", + "requestSnr": "Begär SNR", + "nodeToNode": "(nod-till-nod)", + "keyPrefix": "Nyckelprefix", + "raw": "Rå", + "destHash": "Destinationshash", + "ephemeralKey": "Tillfällig nyckel" + } + }, + "observation": { + "prop": "Prop", + "path": "Väg", + "observer": "Observatör", + "area": "Område", + "heard": "Mottaget", + "hops": "Hopp" + }, + "packetEndpoints": { + "na": "ej tillämpligt" + }, + "routes": { + "columns": { + "area": "Område", + "hops": "Hopp", + "route": "Rutt", + "obs": "Obs", + "firstSeen": "Först sedd", + "lastSeen": "Senast sedd" + }, + "hops_one": "{{count}} hopp", + "hops_other": "{{count}} hopp", + "obs_one": "{{value}} obs", + "obs_other": "{{value}} obs", + "first": "första", + "last": "senaste", + "findPath": "Hitta väg", + "fromPlaceholder": "från hash", + "fromLabel": "Från hash", + "toPlaceholder": "till hash", + "toLabel": "Till hash", + "areas": "Områden", + "search": "Sök", + "clear": "Rensa", + "searching": "Söker…", + "noCross": "Inga rutter mellan dessa områden", + "noMatches": "Inga matchande rutter", + "empty": "Inga rutter" + }, + "routeDetail": { + "title": "Ruttdetaljer", + "summary": "Sammanfattning", + "hops": "Hopp", + "heard": "Mottaget", + "route": "Rutt", + "timestamps": "Tidsstämplar", + "firstSeen": "Först sedd", + "lastSeen": "Senast sedd" + }, + "traces": { + "packets": "Paket", + "packetCount_one": "{{count}} paket", + "packetCount_other": "{{count}} paket", + "noPackets": "Inga paket", + "noPath": "ingen väg", + "unknown": "Okänd", + "analyze": "analysera →", + "first": "Första", + "last": "Senaste", + "tagCount_one": "{{count}} tagg", + "tagCount_other": "{{count}} taggar", + "pkt_one": "{{count}} pkt", + "pkt_other": "{{count}} pkt", + "iata_one": "{{count}} iata", + "iata_other": "{{count}} iata", + "typeLabel": "Spårningstyp", + "typeHint": "Filtrerar hela taggar: TRACE om något paket i taggen har flera hopp, annars PING. Scope- och tidsfilter matchar taggens första mottagning; antal och tider omfattar alltid hela taggen.", + "all": "Alla", + "trace": "Trace", + "ping": "Ping", + "empty": "Inga spårningar" + }, + "loadingPill": { + "nodes": { + "loading_one": "Läser in noder… ({{count}})", + "loading_other": "Läser in noder… ({{count}})", + "partial_one": "Vissa noder kunde inte läsas in ({{count}} visas)", + "partial_other": "Vissa noder kunde inte läsas in ({{count}} visas)", + "failed": "Kunde inte läsa in noder" + }, + "routes": { + "loading_one": "Läser in rutter… ({{count}})", + "loading_other": "Läser in rutter… ({{count}})", + "partial_one": "Vissa rutter kunde inte läsas in ({{count}} visas)", + "partial_other": "Vissa rutter kunde inte läsas in ({{count}} visas)", + "failed": "Kunde inte läsa in rutter" + }, + "packets": { + "loading_one": "Läser in paket… ({{count}})", + "loading_other": "Läser in paket… ({{count}})", + "partial_one": "Vissa paket kunde inte läsas in ({{count}} visas)", + "partial_other": "Vissa paket kunde inte läsas in ({{count}} visas)", + "failed": "Kunde inte läsa in paket" + }, + "messages": { + "loading_one": "Läser in meddelanden… ({{count}})", + "loading_other": "Läser in meddelanden… ({{count}})", + "partial_one": "Vissa meddelanden kunde inte läsas in ({{count}} visas)", + "partial_other": "Vissa meddelanden kunde inte läsas in ({{count}} visas)", + "failed": "Kunde inte läsa in meddelanden" + }, + "observers": { + "loading_one": "Läser in observatörer… ({{count}})", + "loading_other": "Läser in observatörer… ({{count}})", + "partial_one": "Vissa observatörer kunde inte läsas in ({{count}} visas)", + "partial_other": "Vissa observatörer kunde inte läsas in ({{count}} visas)", + "failed": "Kunde inte läsa in observatörer" + } + }, + "talkers": { + "node": "Nod", + "flood": "Flood", + "direct": "Direkt", + "topAdvertisers": "Mest aktiva annonsörer · {{range}}", + "floodDirect": "flood · direkt", + "noAdvertisers": "Inga annonsörer", + "topTalkers": "Mest aktiva sändare · {{range}}", + "byName": "efter namn" + }, + "neighbourGraph": { + "pickRegion": "Välj en region", + "allTooBig": "Alla regioner omfattar över 5 000 noder — välj en region i REGION-väljaren ovan eller begränsa till ett enda område för att visa dess mesh.", + "loadFailed": "Kunde inte läsa in noder", + "loadingMesh": "Läser in mesh…", + "nodes_one": "{{count}} nod", + "nodes_other": "{{count}} noder", + "noNodes": "Inga noder i den här regionen", + "neighborhoodOf": "Grannskap för", + "neighbors_one": "{{count}} granne", + "neighbors_other": "{{count}} grannar", + "clickEmpty": "Klicka på ett tomt område för att visa hela mesh-nätet.", + "showing": "Visar {{cap}} av {{total}} noder", + "narrow": "Begränsa till ett enda område för att visa resten.", + "searchName": "Namn", + "other": "Övriga", + "unknownType": "okänd", + "edgeToday": "{{obs}} obs · sedd idag", + "edgeAgo": "{{obs}} obs · sedd för {{days}} d sedan" + }, + "nodeTypes": { + "companion": "Companion", + "repeater": "Repeater", + "room_server": "Rumsserver", + "sensor": "Sensor" + }, + "observerFilters": { + "label": "Observatörsfilter", + "online": "Online", + "offline": "Offline", + "name": "Namn", + "status": "Status", + "type": "Typ", + "broker": "Broker", + "scope": "Scope" + }, + "charts": { + "uniquePackets": "Unika paket", + "busy": "Beläggning" + }, + "packetList": { + "pathSearchInvalid": "Ange fullständiga hopphashar med samma längd, separerade med blanksteg, kommatecken eller → (t.ex. 7f a4).", + "pathSearchHint": "Söker i den senaste relävägen i inlästa paket. Använd fullständiga hopphashar, t.ex. 7f a4 (blanksteg, kommatecken eller →). Trace-paket exkluderas.", + "selectedPacket": "Valt paket", + "invalidHash": "Ogiltig pakethash.", + "notFound": "Paketet hittades inte.", + "loadFailed": "Kunde inte läsa in det valda paketet.", + "retry": "Försök igen", + "retryLabel": "Försök läsa in valt paket igen", + "outsideResults": "Det valda paketet ligger utanför de inlästa resultaten.", + "openAnalyzer": "Öppna analyseraren", + "loadingSelected": "Läser in valt paket…", + "dismissSelected": "Stäng valt paket", + "dropped_one": "{{count}} paket tappades — data kan vara ofullständiga", + "dropped_other": "{{count}} paket tappades — data kan vara ofullständiga", + "dismiss": "stäng", + "newPackets_one": "{{count}} nytt paket", + "newPackets_other": "{{count}} nya paket", + "scrollToTop": "· rulla längst upp", + "live": "Livepaket", + "noMatching": "Inga matchande paket är inlästa.", + "loadingPackets": "Läser in paket...", + "loadOlder": "Läs in äldre paket" + }, + "packetTable": { + "hash": "Hash", + "obs": "Obs", + "hops": "Hopp", + "summary": "Sammanfattning / Källa → Mål", + "summaryTitle": "Sammanfattning och källa / mål", + "area": "Område", + "age": "Ålder", + "unknown": "Okänd" + }, + "filters": { + "types": "Typer", + "routes": "Rutter", + "observers": "Observatörer", + "scope": "Scope", + "packetFilters": "Paketfilter", + "field": { + "hash": "Hash", + "path": "Senaste väg", + "payload": "Nyttolast" + } + }, + "search": { + "placeholder": "Sök efter {{field}}..." + }, + "errorBoundary": { + "message": "Något gick fel när den här vyn skulle visas.", + "reload": "läs in igen", + "retry": "försök igen" + }, + "splash": { + "tagline": "MeshCore nätverksanalysator" + }, + "units": { + "perDay": "{{value}}/d" + } + } +} From 2538ff7588a7578d48d19fc1d5b43973a30351a6 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:06:03 -0400 Subject: [PATCH 4/5] chore: add .github/CODEOWNERS for main branch policy Signed-off-by: MrAlders0n --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d369ebd --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change to main needs a review from the repo owner +* @MrAlders0n From 82fb6835066aed7acc3f611cab450b3760d08fa3 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:06:04 -0400 Subject: [PATCH 5/5] chore: add .github/workflows/main-source-guard.yml for main branch policy Signed-off-by: MrAlders0n --- .github/workflows/main-source-guard.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/main-source-guard.yml diff --git a/.github/workflows/main-source-guard.yml b/.github/workflows/main-source-guard.yml new file mode 100644 index 0000000..911186f --- /dev/null +++ b/.github/workflows/main-source-guard.yml @@ -0,0 +1,24 @@ +name: Main PR source guard + +# Runs from main's copy of this file, so a PR can't edit the check away. +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, edited] + +permissions: {} + +jobs: + main-source-is-dev: + name: main-source-is-dev + runs-on: ubuntu-latest + steps: + - name: Require PR from this repo's dev branch + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + if [ "$HEAD_REF" != "dev" ] || [ "$HEAD_REPO" != "$GITHUB_REPOSITORY" ]; then + echo "::error::PRs into main must come from $GITHUB_REPOSITORY:dev (got $HEAD_REPO:$HEAD_REF)" + exit 1 + fi