From 139d3ce508235fb5b8c98ae7a42fae86de4467a7 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 4 Oct 2026 16:31:50 -0400 Subject: [PATCH] feat: stage current preview on dev for dev1 evaluation Port the qualified web preview at c41e6fa8940fba929704bc2f9a94f119fef4a4ca onto org dev b4d498e697182f83bef47152c3f35afb226a2252. Preserve upstream changes and branch policies; keep the independent private Collector service separate. Preview-Version: 2.2.1-n30nex.1 Preview-Revision: c41e6fa8940fba929704bc2f9a94f119fef4a4ca --- .build/docker-entrypoint.sh | 2 +- .env.example | 5 +- docs/my-atlas.md | 62 ++++ index.html | 2 +- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 18 +- src/api/client.ts | 54 +++- src/components/AppShell.tsx | 26 +- src/components/BottomNav.tsx | 9 +- src/components/NodeTypeBadge.tsx | 17 + src/components/ObserverIcon.tsx | 2 +- src/components/Sparkline.tsx | 53 +++ src/features/atlas/AtlasCard.tsx | 123 +++++++ src/features/atlas/MyAtlasPage.tsx | 74 +++++ src/features/atlas/atlas.ts | 70 ++++ src/features/atlas/useSavedAtlas.ts | 29 ++ src/features/channels/types.ts | 1 + src/features/map/MapSettingsPanel.tsx | 7 + src/features/map/MapView.tsx | 10 +- src/features/map/map-url.ts | 6 + src/features/map/packet-path.ts | 10 +- src/features/map/useMapNodes.ts | 19 +- src/features/nodes/CollectedNodeTelemetry.tsx | 45 +++ src/features/nodes/NodeDetailPanel.tsx | 10 +- src/features/nodes/NodePage.tsx | 69 ++++ src/features/nodes/NodeTable.tsx | 16 +- src/features/nodes/NodeTelemetry.tsx | 43 +++ src/features/nodes/types.ts | 1 + src/features/observers/ObserverSummary.tsx | 24 +- src/features/observers/observer-navigation.ts | 2 +- src/features/packets/ObservationCard.tsx | 2 +- src/features/packets/PacketAnalyzerDrawer.tsx | 17 +- src/features/packets/PacketInvestigation.tsx | 2 +- src/features/packets/packet-structure.tsx | 6 +- src/features/routes/RouteDetailPanel.tsx | 35 +- src/features/routes/RouteTable.tsx | 4 +- src/features/stats/cards.tsx | 56 +--- src/features/stats/chartOptions.ts | 22 +- src/features/topology/TopologyCanvas.tsx | 59 ++++ src/features/topology/TopologyPage.tsx | 192 +++++++++++ src/features/topology/chatter.ts | 50 +++ src/features/topology/topology-renderer.ts | 304 ++++++++++++++++++ src/features/topology/topology.ts | 277 ++++++++++++++++ src/features/traces/TraceDetailPanel.tsx | 2 + src/features/traces/TraceList.tsx | 72 +++-- src/features/traces/TraceQualityNotice.tsx | 13 + src/features/traces/trace-quality.ts | 5 + src/hooks/region-selection.ts | 6 + src/i18n/locales/en.json | 263 ++++++++++++++- src/i18n/locales/fr.json | 263 ++++++++++++++- src/index.css | 8 + src/lib/constants.ts | 2 +- src/types/api.ts | 10 + tests/App.selectionReset.test.tsx | 26 +- tests/api/route-evidence.test.ts | 11 + tests/components/AppShell.test.tsx | 20 ++ tests/components/BottomNav.test.tsx | 11 + tests/components/Sparkline.test.tsx | 21 ++ tests/deploy/docker-entrypoint.test.ts | 2 +- tests/features/atlas/MyAtlasPage.test.tsx | 197 ++++++++++++ tests/features/atlas/atlas.test.ts | 39 +++ tests/features/map/packet-path.test.ts | 9 +- .../nodes/CollectedNodeTelemetry.test.tsx | 46 +++ tests/features/nodes/NodePage.test.tsx | 50 +++ tests/features/nodes/NodeTelemetry.test.tsx | 30 ++ .../ObserverSummary.compact.test.tsx | 12 + .../packets/PacketAnalyzerDrawer.test.tsx | 14 +- .../features/packets/packet-structure.test.ts | 10 + .../features/routes/RouteDetailPanel.test.tsx | 36 ++- tests/features/stats/MeshTab.test.tsx | 5 +- tests/features/stats/cards.test.tsx | 2 +- .../features/topology/TopologyCanvas.test.ts | 36 +++ tests/features/topology/TopologyPage.test.tsx | 77 +++++ tests/features/topology/chatter.test.ts | 37 +++ tests/features/topology/topology.test.ts | 139 ++++++++ tests/features/traces/TraceList.test.tsx | 112 ++++++- tests/hooks/region-selection.test.ts | 7 + tests/lib/constants.test.ts | 4 +- 79 files changed, 3237 insertions(+), 201 deletions(-) create mode 100644 docs/my-atlas.md create mode 100644 src/components/NodeTypeBadge.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/features/atlas/AtlasCard.tsx create mode 100644 src/features/atlas/MyAtlasPage.tsx create mode 100644 src/features/atlas/atlas.ts create mode 100644 src/features/atlas/useSavedAtlas.ts create mode 100644 src/features/nodes/CollectedNodeTelemetry.tsx create mode 100644 src/features/nodes/NodePage.tsx create mode 100644 src/features/nodes/NodeTelemetry.tsx create mode 100644 src/features/topology/TopologyCanvas.tsx create mode 100644 src/features/topology/TopologyPage.tsx create mode 100644 src/features/topology/chatter.ts create mode 100644 src/features/topology/topology-renderer.ts create mode 100644 src/features/topology/topology.ts create mode 100644 src/features/traces/TraceQualityNotice.tsx create mode 100644 src/features/traces/trace-quality.ts create mode 100644 tests/components/Sparkline.test.tsx create mode 100644 tests/features/atlas/MyAtlasPage.test.tsx create mode 100644 tests/features/atlas/atlas.test.ts create mode 100644 tests/features/nodes/CollectedNodeTelemetry.test.tsx create mode 100644 tests/features/nodes/NodePage.test.tsx create mode 100644 tests/features/nodes/NodeTelemetry.test.tsx create mode 100644 tests/features/topology/TopologyCanvas.test.ts create mode 100644 tests/features/topology/TopologyPage.test.tsx create mode 100644 tests/features/topology/chatter.test.ts create mode 100644 tests/features/topology/topology.test.ts diff --git a/.build/docker-entrypoint.sh b/.build/docker-entrypoint.sh index 79033244..e6fb80b1 100644 --- a/.build/docker-entrypoint.sh +++ b/.build/docker-entrypoint.sh @@ -4,7 +4,7 @@ set -e # Runtime config lives in /config.js (served no-cache), not the hashed bundle, so env changes take # effect on any restart and reach returning visitors. Empty values fall back to the app defaults. OUT="${BEACON_CONFIG_OUT:-/srv/config.js}" -KEYS="VITE_API_BASE VITE_WS_URL VITE_MAP_CENTER VITE_MAP_ZOOM VITE_DISABLED_TABS VITE_ENABLED_THEMES VITE_APP_NAME VITE_SKIP_SPLASH VITE_BANNER" +KEYS="VITE_API_BASE VITE_WS_URL VITE_MAP_CENTER VITE_MAP_ZOOM VITE_DISABLED_TABS VITE_ENABLED_THEMES VITE_APP_NAME VITE_SKIP_SPLASH VITE_BANNER VITE_CHANGELOG_URL" # Values are read from ENVIRON and JSON-escaped, so any character is safe. awk -v keys="$KEYS" 'BEGIN { diff --git a/.env.example b/.env.example index 50ce1659..57af9c14 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # VITE_MAP_ZOOM=3.2 # Hide tabs completely (comma list, case-insensitive). Unset = show all. -# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# Options: Packets,MyAtlas,Topology,Channels,Map,Nodes,Observers,Routes,Traces,Analytics # VITE_DISABLED_TABS=Map,Traces # Only offer these themes in the picker (comma list of theme ids). Unset = the default set. @@ -28,3 +28,6 @@ # Notice shown above the header on every page; [label](url) and bare URLs become links. Unset = none. # VITE_BANNER=This is the Beacon development instance and may be unstable. For everyday use, visit [live.meshcore.ca](https://live.meshcore.ca). + +# Optional header link to this deployment's changelog/build details. Unset = hidden. +# VITE_CHANGELOG_URL=/beacon-dev/source.html diff --git a/docs/my-atlas.md b/docs/my-atlas.md new file mode 100644 index 00000000..e3b6985e --- /dev/null +++ b/docs/my-atlas.md @@ -0,0 +1,62 @@ +# My Atlas + +Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The +canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does +not publish someone else's saved nodes. + +Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve +nodes. **Manage card** moves or removes a card. The selected nodes, their order +and the 24-hour/3-day activity window are stored in this browser on this origin +under `beacon-my-atlas-v1`. There is no account or cross-device synchronization. +Clearing site data removes the collection. When browser storage is unavailable, +the page explains that edits may only last for the current visit. + +Cards use the current full node identity, not an ambiguous short path prefix. +Names refresh from the node record. If an old server ID disappears or points at +a different key, the page searches for the exact full key before loading reports. +An unavailable node stays in the collection and remains removable. + +## Reading the cards + +- **Received reports** are stored receptions of packets originating from the exact + node key, after server deduplication. The page loads the latest 200 reports per + node, then filters that sample to the selected rolling window. Counts are not + a total of all node traffic. The card flags when more reports are available. +- Each bar covers one hour of the rolling window. Blank bars mean no reports in + the loaded sample; they do not prove silence or packet loss. **Statistics & + chart values** exposes the numeric hourly values and sample definition. +- **Mean SNR / RSSI** average finite, available measurements from that sample. + The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR + with a real RSSI is included. These are last-hop measurements at the observers, + not a measurement of the origin's radio health. Meters include numbers and + accessible values; missing measurements display a dash. +- Freshness uses the server's `stale` verdict. It does not infer online/offline + status from the report count or invent a local threshold. +- **Heard by** lazily opens the reporting observers for the latest loaded packet + in the selected window. It is not lifetime reach or all observers of the node. + Repeated reports from the same observer are grouped. Observer buttons open + inspection; the packet action opens the exact packet and observed paths. +- **Open node** reuses Beacon's node inspection. Statistics also show existing + neighbour count, radio settings, default scope and full public key. + +## Cost and compatibility + +The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency. +No query runs for an empty collection before a valid search. Search is debounced, +limited to eight results and rejects invalid key input before issuing a request. +Each saved card normally makes two requests (node plus 200-report page) per +minute while the page is active and the document is visible. Error refreshes stop +until Retry or a later visit. A changed server ID can require two extra identity +requests. A single expanded Heard by panel uses the existing packet-detail cache. +Requests consume TanStack Query's abort signal when the page leaves. + +No server schema, retention settings, MQTT behavior or authentication changes are +needed. Existing raw-retention limits still apply. Longer-lived analytics remain +separate. English and French labels ship together. Deployers can hide the page +with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration. + +The focused tests cover persistence, corrupt/blocked storage, cross-tab changes, +exact-key recovery, rename display, bounded/lazy loading, missing signals, +expired evidence, range selection, order/removal and both languages. Release QA +also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, +and the matching public source offer. diff --git a/index.html b/index.html index e072ff15..1629ba56 100644 --- a/index.html +++ b/index.html @@ -14,7 +14,7 @@
- + diff --git a/package-lock.json b/package-lock.json index 7441ecc0..4c2ef326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.1", + "version": "2.2.1-n30nex.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.1", + "version": "2.2.1-n30nex.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 ebabcfa7..a4c039b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.1", + "version": "2.2.1-n30nex.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index c2ae75ed..b0e8a45f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,9 @@ 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. +const NodePage = lazy(() => import("./features/nodes/NodePage").then(m => ({ default: m.NodePage }))); +const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage }))); +const TopologyPage = lazy(() => import("./features/topology/TopologyPage").then(m => ({ default: m.TopologyPage }))); const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView }))); // Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. @@ -87,13 +90,19 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { // wouldn't otherwise know was shareable). All-regions clears both params; any legacy ?region is folded // in. The guard skips redundant writes (and any setSearchParams feedback loop); replace keeps it out of // history. -function RegionUrlSync() { +export function RegionUrlSync() { const { selection } = useRegionSelection(); + const previousSelection = useRef(selection); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { const next = selectionToParams(selection, searchParams); + if (previousSelection.current !== selection) { + next.delete("topoNode"); + next.delete("topoFocus"); + } + previousSelection.current = selection; if (next.toString() === searchParams.toString()) return; setSearchParams(next, { replace: true, state: location.state }); }, [selection, searchParams, setSearchParams, location.state]); @@ -221,7 +230,8 @@ function AppInner() { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); - if (tab !== "Routes") for (const key of ["route", "routeIata"]) next.delete(key); + if (tab !== "Nodes") { next.delete("nodePage"); next.delete("nodeRange"); } + if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil", "routeHashSize", "routePathBytes"]) next.delete(key); // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); // AppShell fires onTabChange even for a no-op click on the already-active tab — only an actual @@ -301,6 +311,8 @@ function AppInner() { }, []); const tabContent: Record = { + MyAtlas: , + Topology: , Packets: ( ), - Nodes: , + Nodes: searchParams.get("nodePage") ? : , Observers: , Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the diff --git a/src/api/client.ts b/src/api/client.ts index 8689ec05..f612280e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -70,6 +70,21 @@ async function request(path: string, params?: Record | null; + sensors?: { channel: number; kind: string; value: number; unit: string }[] | null; +} + +export async function getCollectedNodeTelemetry(key: string, signal?: AbortSignal): Promise<{ items: CollectedNodeSample[]; limit: number }> { + try { return await request(`/node-telemetry/${encodeURIComponent(key)}`, undefined, signal); } + catch (error) { if (isNotFound(error)) return { items: [], limit: 500 }; throw error; } +} + export function getObserverComparison( iatas: StatsRegion, params: { observerA: string; observerB: string; since: number; until: number }, @@ -109,8 +124,8 @@ export function getPackets( }); } -export function getPacketDetail(packetHash: string): Promise { - return request(`/packets/${packetHash}`); +export function getPacketDetail(packetHash: string, signal?: AbortSignal): Promise { + return request(`/packets/${packetHash}`, undefined, signal); } export function getIatas(): Promise { @@ -199,6 +214,7 @@ export interface RouteCursor { export async function getKnownRoutesPage( params?: { iata?: string; hopCount?: number; cursor?: RouteCursor; limit?: number }, + signal?: AbortSignal, ): Promise> { const limit = params?.limit ?? DEFAULT_PAGE_SIZE; const items = await request("/routes", { @@ -207,11 +223,11 @@ export async function getKnownRoutesPage( cursor: params?.cursor?.lastSeen, cursorId: params?.cursor?.id, limit, - }); + }, signal); return toCursorPage(items, limit, (r) => ({ lastSeen: r.lastSeen, id: r.id })); } -export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number }, signal?: AbortSignal): Promise { +export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number; hashSize?: number; pathBytes?: string }, signal?: AbortSignal): Promise { return request(`/routes/${encodeURIComponent(iata)}/${encodeURIComponent(pathKey)}/observations`, params, signal); } @@ -289,6 +305,7 @@ export function getNodesPage( supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, + signal?: AbortSignal, ): Promise> { if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/nodes", { @@ -301,7 +318,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } // Paginated /observers, mirroring getNodesPage; used by the Observers table. @@ -321,18 +338,19 @@ export function getObserversPage( }); } -export function getNode(nodeId: string): Promise { - return request(`/nodes/${nodeId}`); +export function getNode(nodeId: string, signal?: AbortSignal): Promise { + return request(`/nodes/${nodeId}`, undefined, signal); } export function getNodeObservations( nodeId: string, params?: { cursor?: number; limit?: number }, + signal?: AbortSignal, ): Promise> { return request(`/nodes/${nodeId}/observations`, { cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, - }); + }, signal); } export function getNodeNeighbors(nodeId: string): Promise { @@ -416,3 +434,23 @@ export function isNotFound(err: unknown): boolean { } export { ApiError }; + +// Public cached MeshMapper metadata; counts are regional, never per-link evidence. +export interface ScopeCatalogue { + iata: string; + url: string; + generatedAt: number; + checkedAt: number; + freshUntil: number; + lastError?: string; + repeaters: number; + scoped: number; + scopes: { name: string; repeaters: number; default: number; monitored: boolean; wardriving: boolean }[]; +} +export function getScopeCatalogues(): Promise { + return request("/scope-catalogues"); +} + +export function getTopologyLinks(iatas: string[] | undefined, window: string, signal?: AbortSignal): Promise<{ links: [string,string][]; capped: boolean; since: number; until: number }> { + return request("/routes/topology", { iatas: iatas?.join(","), window }, signal); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 6151ab90..e5ed7f4e 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -15,6 +15,7 @@ import { useIsMobile } from "../hooks/useMediaQuery"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL, BANNER } from "../lib/constants"; +import { runtimeEnv } from "../lib/runtime-env"; import { InstanceBanner } from "./InstanceBanner"; import type { WsManager } from "../api/ws-manager"; @@ -39,12 +40,12 @@ function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compa if (status === "connected") { if (compact) return ( - + ); return (
- + {t("connection.live")}
); @@ -132,11 +133,11 @@ function RegionSelector() { renderTrigger={({ toggle }) => ( )} @@ -380,6 +381,19 @@ function GitHubLink() { ); } +function ChangelogLink({ compact = false }: { compact?: boolean }) { + const { t } = useTranslation(); + const href = runtimeEnv("VITE_CHANGELOG_URL", import.meta.env.VITE_CHANGELOG_URL)?.trim(); + if (!href) return null; + try { + if (!["http:", "https:"].includes(new URL(href, window.location.href).protocol)) return null; + } catch { return null; } + return + {compact ? : t("header.changelog")} + ; +} + 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. @@ -441,6 +455,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
+
@@ -456,6 +471,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
+
diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 26bed734..4621efc7 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -5,13 +5,13 @@ import { ENABLED_TABS } from "../lib/constants"; import { touchClick } from "../lib/touch-click"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. -const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; -const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; switch (name) { + case "MyAtlas": + return ; case "Packets": return ( @@ -113,9 +113,8 @@ 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)); - const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t)); + const primary = ENABLED_TABS.slice(0, 4); + const overflow = ENABLED_TABS.slice(4); const overflowActive = overflow.includes(activeTab); const pick = (tab: string) => { diff --git a/src/components/NodeTypeBadge.tsx b/src/components/NodeTypeBadge.tsx new file mode 100644 index 00000000..91f4c500 --- /dev/null +++ b/src/components/NodeTypeBadge.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import { ObserverIcon } from "./ObserverIcon"; +import { Tooltip } from "./Tooltip"; + +const colors: Record = { companion: "primary", repeater: "green", room_server: "secondary", sensor: "warn" }; +const glyphs: Record = { companion: "▲", repeater: "◆", room_server: "▣", sensor: "⬡" }; + +export function NodeTypeBadge({ typeName, observer = false }: { typeName: string; observer?: boolean }) { + const { t } = useTranslation(); + const role = typeName.toLowerCase(); + const color = `var(--color-${colors[role] ?? "text-muted"})`; + return + {observer && } + + {t(`nodeTypes.${role}`, { defaultValue: typeName })} + ; +} diff --git a/src/components/ObserverIcon.tsx b/src/components/ObserverIcon.tsx index a4c68f62..af1641ca 100644 --- a/src/components/ObserverIcon.tsx +++ b/src/components/ObserverIcon.tsx @@ -2,7 +2,7 @@ // features/map/node-icons.ts) — kept as a literal hex so the nodes bundle doesn't pull in map code. export function ObserverIcon({ className = "" }: { className?: string }) { return ( -
; + +export function Sparkline({ values, color, markPeak, times, gapMs }: { values: (number | null)[]; color: string; markPeak?: boolean; times?: number[]; gapMs?: number }) { + const { t } = useTranslation(); + const known = values.filter((v): v is number => v !== null && Number.isFinite(v)); + const max = known.length ? Math.max(...known) : 0, min = known.length ? Math.min(...known) : 0; + const range = max - min; + const timed = times?.length === values.length && times.every((v,i) => Number.isFinite(v) && (!i || v > times[i-1]!)) && times.at(-1)! > times[0]! ? times : null; + const x = (i: number) => timed ? 2 + (timed[i]! - timed[0]!) / (timed.at(-1)! - timed[0]!) * (SPARK_W - 4) : values.length > 1 ? 2 + i / (values.length - 1) * (SPARK_W - 4) : SPARK_W / 2; + const y = (v: number) => range ? SPARK_H - 3 - (v - min) / range * (SPARK_H - 6) : SPARK_H / 2; + const runs: string[][] = [], bridges: [number,number][] = []; + let previous = -1; + values.forEach((v,i) => { + if (v === null || !Number.isFinite(v)) return; + const gap = previous >= 0 && (i > previous + 1 || !!(timed && gapMs && timed[i]! - timed[previous]! > gapMs)); + if (previous < 0 || gap) runs.push([]); + if (gap) bridges.push([previous,i]); + runs.at(-1)!.push(`${x(i)},${y(v)}`); previous = i; + }); + const peak = values.indexOf(max); + return + {t("charts.gapLegend")} + {known.length === 0 && } + {bridges.map(([a,b]) => )} + {runs.map(run => run.length > 1 ? : )} + {markPeak && known.length > 0 && } + ; +} + +// One bar per run of hours that reported, so gaps read at a glance. +export 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) => ( + + ))} + + ); +} diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx new file mode 100644 index 00000000..6851cf8f --- /dev/null +++ b/src/features/atlas/AtlasCard.tsx @@ -0,0 +1,123 @@ +import { NodeTelemetry } from "../nodes/NodeTelemetry"; +import { CollectedNodeTelemetry } from "../nodes/CollectedNodeTelemetry"; +import { NodeTypeBadge } from "../../components/NodeTypeBadge"; +import { Sparkline } from "../../components/Sparkline"; +import { useId, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getNodeNeighbors, getPacketDetail } from "../../api/client"; +import { Timestamp } from "../../components/Timestamp"; +import { useTick } from "../../hooks/useTick"; +import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas"; + +export interface AtlasActions { + onViewNode: (id: string) => void; + onViewObserver: (id: string) => void; + onAnalyzePacket: (hash: string, observationId?: number) => void; +} + +function SignalMeter({ label, value, values, min, max, unit, color }: { label: string; value: number | null; values: (number|null)[]; min: number; max: number; unit: string; color: string }) { + const { t, i18n } = useTranslation(); + const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; + return
+
{label}{value===null ? "—" : text}
+ +
; +} + +export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & { + pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void; + onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean; +}) { + const { t, i18n } = useTranslation(); + const now = useTick(60_000); + const id = useId(); + const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false, + refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false }); + const node = query.data?.node; + const [neighborsOpen,setNeighborsOpen] = useState(false); + const neighbors = useQuery({queryKey:["node-neighbors",node?.id],queryFn:()=>getNodeNeighbors(node!.id),enabled:active && neighborsOpen && !!node,staleTime:120_000,retry:false}); + const sample = summarizeReports(query.data?.page.items ?? [], range, now); + const latest = sample.reports[0]; + const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false }); + const detail = heard.data; + const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; + const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : []; + const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase(); + const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); + const maximum = Math.max(1, ...sample.bins); + const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + const button = "min-h-8 rounded-sm border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
+
+

+ +
+
+
+
+ {query.isPending ?

{t("common.loading")}

: query.isError &&

{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")}

} + {!node && } + {node && <> +
+
+
{t("atlas.reports")}{number(sample.reports.length)}
+ + {sample.bins.map((count, index) => {time(sample.since + index * 3_600_000)}: {count})} + +
{t(`atlas.ranges.${range}`)}{t("atlas.now")}
+

{t("atlas.sample", { limit: REPORT_LIMIT })}

+
+
+ + +
+ {!sample.reports.length &&

{t("atlas.noReports")}

} +
+ + +
+ {heardOpen &&
+

{t("atlas.latestAdvert")}

+ {!latest ?

{t("atlas.noReports")}

: heard.isPending ?

{t("common.loading")}

: heard.isError || !correctOrigin ?

{t("atlas.heardUnavailable")}

: <> +

{t("atlas.observers", { count: observers.length })}

+
    {observers.map(row =>
  • {row.iata}
  • )}
+ + } +
} +
setNeighborsOpen(e.currentTarget.open)}> + {t("atlas.neighbors")} · {number(node.knownNeighborCount)} +

{t("atlas.neighborSource")}

+ {neighbors.isPending && neighborsOpen ?

{t("common.loading")}

: neighbors.isError ?

{t("common.loadFailed")}

:
    {neighbors.data?.map(n=>
  • )}
} +
+
+ {t("atlas.statistics")} +
+

{t("atlas.definition")}

+

{t("atlas.signalDefinition")}

+ {query.data?.page.hasMore &&

{t("atlas.capped")}

} +
+
{t("atlas.hops")}
{sample.hops === null ? "—" : number(sample.hops)}
+
{t("atlas.neighbors")}
{number(node.knownNeighborCount)}
+
{t("atlas.radio")}
{node.radio || "—"}
+
{t("atlas.scope")}
{node.defaultScope || "—"}
+
+

{node.publicKey}

+
{t("atlas.chartValues")}
{sample.bins.map((count, index) => )}
{t("atlas.hour")}{t("atlas.reports")}
{time(sample.since + index * 3_600_000)}{count}
+

{t("atlas.updated")}

+
+
+ } +
+ {t("atlas.manage")} +
+
+
+
; +} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx new file mode 100644 index 00000000..57f48b8e --- /dev/null +++ b/src/features/atlas/MyAtlasPage.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getNodesPage } from "../../api/client"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { nodeSearchParams } from "../nodes/node-search"; +import { AtlasCard, type AtlasActions } from "./AtlasCard"; +import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; +import { useSavedAtlas } from "./useSavedAtlas"; + +export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { + const { t } = useTranslation(); + const { saved, update, storageFailed } = useSavedAtlas(); + const [linkedKey] = useState(() => { const key = new URLSearchParams(window.location.search).get("atlasAdd") ?? ""; return /^[a-f\d]{64}$/i.test(key) ? key.toLowerCase() : ""; }); + const [searchOpen, setSearchOpen] = useState(!!linkedKey || saved.nodes.length === 0); + const [text, setText] = useState(linkedKey); + const [field, setField] = useState(linkedKey ? "pubkey" : "name"); + const [query, setQuery] = useState({ text: "", field: "name" }); + const [heardKey, setHeardKey] = useState(null); + useEffect(() => { const timer = setTimeout(() => setQuery({ text: text.trim(), field }), 300); return () => clearTimeout(timer); }, [text, field]); + const valid = query.text.length >= 2 && (query.field !== "pubkey" || /^[a-f\d]{2,64}$/i.test(query.text)); + const ready = query.text === text.trim() && query.field === field && valid; + const search = useQuery({ queryKey: ["atlas-search", query.field, query.text], queryFn: ({ signal }) => getNodesPage(undefined, { ...nodeSearchParams(query.field, query.text), limit: 8 }, signal), enabled: active && searchOpen && ready, staleTime: 30_000, retry: false }); + const full = saved.nodes.length >= ATLAS_LIMIT; + const empty = saved.nodes.length === 0; + return
+
+
+
+ +

{t("tabs.MyAtlas")}

{t("atlas.subtitle")}

+
+
+ {(["24h", "3d"] as AtlasRange[]).map(range => )} +
+
+
setSearchOpen(event.currentTarget.open)} className="rounded-sm border border-border bg-bg-surface px-4 py-2"> + {t("atlas.addNodes")} {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} +
+
+ + setText(event.target.value)} placeholder={t(field === "pubkey" ? "atlas.keyPlaceholder" : "atlas.namePlaceholder")} className="min-h-11 min-w-0 flex-1 rounded-sm border border-border bg-bg-base px-3 text-sm text-text-bright placeholder:text-text-muted" /> +
+
+

{t(full ? "atlas.limitReached" : "atlas.searchHelp", { limit: ATLAS_LIMIT })}

+ {ready &&
+ {search.isPending ?

{t("common.loading")}

: search.isError ?

{t("common.loadFailed")}

: <> + {!search.data?.items.length &&

{t("atlas.noMatches")}

} + {!search.data?.items.length && query.field === "pubkey" && /^[a-f\d]{64}$/i.test(query.text) && } +
    {search.data?.items.map(node => { + const selected = saved.nodes.some(pin => pin.publicKey === node.publicKey.toLowerCase()); + const name = node.name || node.publicKey.slice(0, 12).toUpperCase(); + return
  • +

    {name}

    {node.publicKey.slice(0, 16).toUpperCase()} · {t(`atlas.roles.${node.nodeType}`)}

    + +
  • ; + })}
+ {search.data?.hasMore &&

{t("atlas.refine")}

} + } +
} +
+

{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}

+

{t("charts.gapLegend")}

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+ {saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { + const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; + if (from < 0 || to < 0 || to >= nodes.length) return value; + [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + return { ...value, nodes }; + })} {...actions} />)} +
} +
+
; +} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts new file mode 100644 index 00000000..2b302ce7 --- /dev/null +++ b/src/features/atlas/atlas.ts @@ -0,0 +1,70 @@ +import { getNode, getNodeObservations, getNodesPage } from "../../api/client"; +import type { Node, NodeObservation } from "../nodes/types"; + +export const ATLAS_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; +export const REPORT_LIMIT = 200; +export type AtlasRange = "24h" | "3d"; +export interface AtlasPin { id: string; publicKey: string; name: string } +export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } + +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; + const nodes: AtlasPin[] = []; + for (const pin of value.nodes) { + if (typeof pin?.id !== "string" || (pin.id !== "" && !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id)) || + typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; + const publicKey = pin.publicKey.toLowerCase(); + if (nodes.some(node => node.publicKey === publicKey)) continue; + nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); + if (nodes.length === ATLAS_LIMIT) break; + } + return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; + } catch { return empty; } +} + +// Resolve by full identity after a server database reset; never adopt a different node at an old ID. +export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { + let node: Node | undefined; + try { if (pin.id) node = await getNode(pin.id, signal); } + catch (error) { if (!(error instanceof Error && "status" in error && error.status === 404)) throw error; } + if (node?.publicKey.toLowerCase() !== pin.publicKey) { + const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); + const match = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey); + if (!match) { + if (!node) return { node: undefined, page: { items: [], hasMore: false, nextCursor: null } }; + throw new Error("Atlas identity unavailable"); + } + node = await getNode(match.id, signal); + } + if (node.publicKey.toLowerCase() !== pin.publicKey) throw new Error("Atlas identity changed"); + const page = await getNodeObservations(node.id, { limit: REPORT_LIMIT }, signal); + return { node, page }; +} + +export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number) { + const hours = range === "3d" ? 72 : 24; + const since = now - hours * 3_600_000; + const seen = new Set(); + const reports = rows.filter(row => { + if (seen.has(row.id) || row.heardAt <= since || row.heardAt > now) return false; + seen.add(row.id); + return Number.isFinite(row.heardAt); + }).sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); + const bins = Array(hours).fill(0); + for (const row of reports) { + const index = Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)); + bins[index] = (bins[index] ?? 0) + 1; + } + const signal = reports.filter(row => !(row.rssi === 0 && row.snr === 0)); + const mean = (values: (number | undefined)[]) => { + const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + }; + const hourly = (field: "snr"|"rssi") => bins.map((_,i) => mean(signal.filter(r=>r.heardAt >= since+i*3_600_000 && r.heardAt < since+(i+1)*3_600_000).map(r=>r[field]))); + return { reports, bins, since, snrBins:hourly("snr"), rssiBins:hourly("rssi"), snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; +} diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts new file mode 100644 index 00000000..f71dadbd --- /dev/null +++ b/src/features/atlas/useSavedAtlas.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; +import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; + +export function useSavedAtlas() { + const [initial] = useState(() => { + try { return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; } + catch { return { value: parseAtlas(null), failed: true }; } + }); + const [saved, setSaved] = useState(initial.value); + const [storageFailed, setStorageFailed] = useState(initial.failed); + const current = useRef(initial.value); + useEffect(() => { + const receive = (event: StorageEvent) => { + if (event.key !== ATLAS_KEY && event.key !== null) return; + current.current = parseAtlas(event.newValue); + setSaved(current.current); + }; + window.addEventListener("storage", receive); + return () => window.removeEventListener("storage", receive); + }, []); + const update = (change: (value: SavedAtlas) => SavedAtlas) => { + const next = change(current.current); + current.current = next; + setSaved(next); + try { localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); setStorageFailed(false); } + catch { setStorageFailed(true); } + }; + return { saved, update, storageFailed }; +} diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index cfc1a4bf..e639db26 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -26,6 +26,7 @@ export function channelDisplayName(ch: ChannelSummary): string { } export interface ChannelMessage { + isPublic?: boolean; // Live event: decryption used the documented default Public key. id: number; packetHash: string; channelHash: string; diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 432267b0..b4bb61e9 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -46,6 +46,8 @@ function NeighborLegend() { } interface MapSettingsPanelProps { + dimNodes?: boolean; + onDimNodesChange?: (on: boolean) => void; styleId: string; onStyleChange: (id: string) => void; typeFilter: string; @@ -72,6 +74,8 @@ export function MapSettingsPanel({ borders, onBordersChange, buildShareParams, + dimNodes = false, + onDimNodesChange, }: MapSettingsPanelProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); @@ -151,6 +155,9 @@ export function MapSettingsPanel({ /> {neighborLines === "selected" && } + {onDimNodesChange &&
+ +
}
urlView.flow ?? false); + const [dimNodes, setDimNodes] = useState(() => { try { return urlView.dimNodes ?? localStorage.getItem("beacon-map-dim-live-nodes") === "on"; } catch { return false; } }); + const changeDimNodes = (on: boolean) => { setDimNodes(on); try { localStorage.setItem("beacon-map-dim-live-nodes", on ? "on" : "off"); } catch { /* visit-local preference */ } }; + // 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"); @@ -205,12 +208,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp neighborLines, styleId, flow: packetFlow, + dimNodes, borders, }; return { tab: "Map", ...buildMapParams(snapshot) }; - }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); + }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, dimNodes, borders]); - useMapNodes(mapRef, nodeIconResolverRef, 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}`, dimNodes); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapBorders(mapRef, isReady, borderData, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); @@ -232,6 +236,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp onNeighborLinesChange={handleNeighborLinesChange} borders={borders} onBordersChange={handleBordersChange} + dimNodes={dimNodes} + onDimNodesChange={changeDimNodes} buildShareParams={buildShareParams} /> setPacketFlow((v) => !v)} /> diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index aa3890ec..943d4732 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -14,6 +14,7 @@ export interface ParsedMapView { styleId?: string; flow?: boolean; borders?: boolean; + dimNodes?: boolean; } // The live map state a copy-link snapshot is built from (every field concrete). @@ -26,6 +27,7 @@ export interface MapViewSnapshot { styleId: string; flow: boolean; borders: boolean; + dimNodes?: boolean; } const NEIGHBOR_MODES: NeighborLinesMode[] = ["on", "selected", "off"]; @@ -86,6 +88,9 @@ export function parseMapView(params: URLSearchParams): ParsedMapView { const flow = parseBool(params.get("flow")); if (flow !== undefined) view.flow = flow; + const dimNodes = parseBool(params.get("dim_nodes")); + if (dimNodes !== undefined) view.dimNodes = dimNodes; + const borders = parseBool(params.get("borders")); if (borders !== undefined) view.borders = borders; @@ -112,5 +117,6 @@ export function buildMapParams(view: MapViewSnapshot): Record(null); @@ -158,7 +159,7 @@ export function useMapNodes( // maplibre fixes `cluster` at source creation, so toggling clustering means recreating the // source. The spiderfy effect below also keys on `clustered` and re-applies itself around this. if (appliedClusteredRef.current !== clustered && map.getSource(NODES_SOURCE_ID)) { - for (const id of [NODES_SELECTED_LAYER_ID, NODES_CLUSTER_LAYER_ID, NODES_POINT_LAYER_ID]) { + for (const id of ["nodes-live-glow", NODES_SELECTED_LAYER_ID, NODES_CLUSTER_LAYER_ID, NODES_POINT_LAYER_ID]) { if (map.getLayer(id)) map.removeLayer(id); } map.removeSource(NODES_SOURCE_ID); @@ -234,6 +235,14 @@ export function useMapNodes( // Ring under the selected node's icon. Only matches an unclustered point (clusters carry no id); // color tracks --palette-primary. const primary = cssVar("--palette-primary", "#3B82F6"); + if (!map.getLayer("nodes-live-glow")) { + map.addLayer({ id: "nodes-live-glow", type: "circle", source: NODES_SOURCE_ID, + filter: ["!", ["has", "point_count"]], + paint: { "circle-radius": ["+", 7, ["*", 12, ["coalesce", ["feature-state", "glow"], 0]]], + "circle-color": primary, "circle-opacity": ["*", 0.4, ["coalesce", ["feature-state", "glow"], 0]], "circle-blur": 0.5 } + }, NODES_POINT_LAYER_ID); + } + map.setPaintProperty("nodes-live-glow", "circle-color", primary); if (!map.getLayer(NODES_SELECTED_LAYER_ID)) { map.addLayer( { @@ -339,9 +348,9 @@ export function useMapNodes( const focusCase = (lit: ExpressionSpecification | number, dim: ExpressionSpecification | number) => ["case", ["in", ["get", "id"], ["literal", focusIds ?? []]], lit, dim] as ExpressionSpecification; - const iconOpacity: ExpressionSpecification | number = live ? LIVE_ICON_OPACITY : focusIds ? focusCase(1, LIVE_DIM_OPACITY) : 1; - const labelOpacity: ExpressionSpecification | number = live ? 0 : focusIds ? focusCase(LABEL_OPACITY, 0) : LABEL_OPACITY; - const dimActive = live || Boolean(focusIds); + const iconOpacity: ExpressionSpecification | number = live ? dimIdleNodes ? LIVE_ICON_OPACITY : ["max", 0.8, ["coalesce", ["feature-state", "glow"], 0]] : focusIds ? focusCase(1, LIVE_DIM_OPACITY) : 1; + const labelOpacity: ExpressionSpecification | number = live && dimIdleNodes ? 0 : focusIds ? focusCase(LABEL_OPACITY, 0) : LABEL_OPACITY; + const dimActive = (live && dimIdleNodes) || Boolean(focusIds); if (map.getLayer(NODES_POINT_LAYER_ID)) { map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", iconOpacity); map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", labelOpacity); @@ -351,7 +360,7 @@ export function useMapNodes( map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", dimActive ? LIVE_CLUSTER_DIM_OPACITY : 1); map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", dimActive ? 0 : 1); } - }, [mapRef, isReady, live, focusIds, clustered, themeKey]); + }, [mapRef, isReady, live, dimIdleNodes, focusIds, clustered, themeKey]); // Push new node data into the source as it arrives; the source re-clusters automatically. useEffect(() => { diff --git a/src/features/nodes/CollectedNodeTelemetry.tsx b/src/features/nodes/CollectedNodeTelemetry.tsx new file mode 100644 index 00000000..56844f69 --- /dev/null +++ b/src/features/nodes/CollectedNodeTelemetry.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getCollectedNodeTelemetry } from "../../api/client"; +import { Sparkline } from "../../components/Sparkline"; +import { Timestamp } from "../../components/Timestamp"; + +const statusUnits: Record = { batteryMv: "V", uptimeSeconds: "s", lastRssiDbm: "dBm", lastSnrDb: "dB", noiseFloorDbm: "dBm", queueLength: "", receivedPackets: "", sentPackets: "" }; + +export function CollectedNodeTelemetry({ publicKey, active, compact = false }: { publicKey: string; active: boolean; compact?: boolean }) { + const { t, i18n } = useTranslation(); + const key = publicKey.toLowerCase(); + const data = useQuery({ queryKey: ["collected-node-telemetry", key], queryFn: ({ signal }) => getCollectedNodeTelemetry(key, signal), + enabled: active && /^[0-9a-f]{64}$/.test(key), staleTime: 300_000, retry: false, refetchInterval: 300_000, refetchIntervalInBackground: false }); + const samples = (data.data?.items ?? []).filter(sample => sample.nodeKey === key).sort((a,b) => a.receivedAt-b.receivedAt); + if (!samples.length) return data.isError ?
+

{t("nodeTelemetry.loadError")}

+
: compact ?

{t("nodeTelemetry.noData")}

: null; + const metrics = new Map(); + for (const sample of samples) { + const readings = [ + ...Object.entries(sample.values ?? {}).filter(([name,value]) => name in statusUnits && typeof value === "number" && Number.isFinite(value)).map(([name, value]) => ({ name, unit: statusUnits[name]!, value: name === "batteryMv" ? value / 1000 : value, channel: null })), + ...(sample.sensors ?? []).map(sensor => ({ name: sensor.kind === "voltage" && sensor.channel === 1 ? "batteryMv" : sensor.kind, value: sensor.value, unit: sensor.unit, channel: sensor.kind === "voltage" && sensor.channel === 1 ? null : sensor.channel })), + ]; + for (const reading of readings) { + if (!Number.isFinite(reading.value)) continue; + const id = `${sample.collectorKey}/${reading.channel ?? "status"}/${reading.name}`; + const metric = metrics.get(id) ?? { label: `${t(`nodeTelemetry.metrics.${reading.name}`, { defaultValue: reading.name.replaceAll("_", " ") })}${reading.channel != null ? ` · ${t("nodeTelemetry.channel", { channel: reading.channel })}` : ""}`, collector: sample.collectorKey, unit: reading.unit, color: reading.name === "batteryMv" ? "green" : reading.name === "temperature" ? "warn" : reading.name === "humidity" ? "secondary" : "primary", priority: reading.name === "batteryMv" ? 0 : reading.name === "temperature" && reading.channel === 2 ? 1 : 2, times: [], values: [], gapMs: 1.5 * Math.max(1, Math.min(72, sample.intervalHours ?? 1)) * 3_600_000 + 120_000 }; + if (metric.times.at(-1) !== sample.receivedAt) { metric.times.push(sample.receivedAt); metric.values.push(reading.value); } + metrics.set(id, metric); + } + } + const ordered = [...metrics].sort((a,b) => a[1].priority-b[1].priority || b[1].times.at(-1)!-a[1].times.at(-1)!).slice(0,24); + const renderMetric = ([id, metric]: typeof ordered[number]) =>
+

{compact ? metric.label.replace(t("nodeTelemetry.metrics.temperature"),t("nodeTelemetry.shortTemperature")) : metric.label}

{metric.values.at(-1)!.toLocaleString(i18n.language, { maximumFractionDigits: 2 })} {metric.unit}

+ + {!compact &&

{t("nodeTelemetry.collector", { key: metric.collector.slice(0,8) })} ·

} +
; + return
+
{t("nodeTelemetry.collected")}
+ {data.isError &&

{t("atlas.refreshFailed")}

} + {metrics.size > 24 &&

{t("nodeTelemetry.limit")}

} +
{ordered.slice(0,compact ? 2 : ordered.length).map(renderMetric)}
+ {compact && ordered.length>2 &&
{t("nodeTelemetry.more",{count:ordered.length-2})}
{ordered.slice(2).map(renderMetric)}
} +
; +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index dedc1838..2b6ec0e4 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client"; +import { NodeTypeBadge } from "../../components/NodeTypeBadge"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { CopyButton } from "../../components/CopyButton"; @@ -23,7 +24,7 @@ function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClic {neighbor.name ?? formatHex(neighbor.id)} - {neighbor.nodeTypeName} + {neighbor.iata}
@@ -101,13 +102,16 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o }); const hasLocation = hasMapLocation(node); + const fullLink = new URL(window.location.href); + fullLink.searchParams.set("tab", "Nodes"); fullLink.searchParams.set("nodePage", nodeId); + for (const key of ["node", "hash", "analyze", "path", "observation"]) fullLink.searchParams.delete(key); return ( } + headerAction={} isLoading={isLoading} notFound={!node} notFoundLabel={t("nodeDetail.notFound")} @@ -124,7 +128,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node.name ?? formatHex(node.id)} - {node.nodeTypeName} +
diff --git a/src/features/nodes/NodePage.tsx b/src/features/nodes/NodePage.tsx new file mode 100644 index 00000000..e489cec7 --- /dev/null +++ b/src/features/nodes/NodePage.tsx @@ -0,0 +1,69 @@ +import { NodeTypeBadge } from "../../components/NodeTypeBadge"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { getNode, getNodeNeighbors, getNodeObservations, isNotFound } from "../../api/client"; +import { Timestamp } from "../../components/Timestamp"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; +import { Sparkline } from "../../components/Sparkline"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { useTick } from "../../hooks/useTick"; +import { summarizeReports, REPORT_LIMIT, type AtlasRange } from "../atlas/atlas"; +import { NodeTelemetry } from "./NodeTelemetry"; +import { formatClockDrift } from "../../lib/formatters"; + +export function NodePage({ nodeId, onViewNode, onViewObserver, onAnalyzePacket }: { + nodeId: string; onViewNode: (id: string) => void; onViewObserver: (id: string) => void; + onAnalyzePacket: (hash: string, observationId?: number) => void; +}) { + const { t, i18n } = useTranslation(); + const [params, setParams] = useSearchParams(); + const range: AtlasRange = params.get("nodeRange") === "3d" ? "3d" : "24h"; + const valid = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(nodeId); + const now = useTick(60_000); + const detail = useQuery({ queryKey: ["node", nodeId], queryFn: ({ signal }) => getNode(nodeId, signal), enabled: valid, staleTime: 30_000, refetchInterval: 60_000, refetchIntervalInBackground: false }); + const reports = useQuery({ queryKey: ["node-page-reports", nodeId], queryFn: ({ signal }) => getNodeObservations(nodeId, { limit: REPORT_LIMIT }, signal), enabled: valid && !!detail.data, staleTime: 60_000, refetchInterval: 60_000, refetchIntervalInBackground: false }); + const neighbors = useQuery({ queryKey: ["node-neighbors", nodeId], queryFn: () => getNodeNeighbors(nodeId), enabled: valid && !!detail.data, staleTime: 120_000, refetchInterval: 120_000, refetchIntervalInBackground: false }); + const node = detail.data; + const sample = summarizeReports(reports.data?.items ?? [], range, now); + const ordered = [...sample.reports].reverse(); + const mix = new Map(); + const regions = new Map(); + for (const report of sample.reports) { mix.set(report.payloadTypeName, (mix.get(report.payloadTypeName) ?? 0) + 1); regions.set(report.iata, (regions.get(report.iata) ?? 0) + 1); } + const number = (value: number | null, unit = "") => value == null ? "—" : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })}${unit}`; + const box = "min-w-0 rounded-xl border border-border bg-bg-surface p-4"; + return
+
+
+ + +
+ {!valid || (detail.isError && !node) ?

{t(!valid || isNotFound(detail.error) ? "nodeDetail.notFound" : "nodePage.loadError")}{valid && !isNotFound(detail.error) && <> }

: detail.isPending ?

{t("common.loading")}

: node && <> + {detail.isError &&

{t("atlas.refreshFailed")}

} +
+

{node.name || node.publicKey.slice(0, 12)}

·

{node.publicKey}
+
{node.observerId && }
+
+
{t("atlas.sample", { limit: REPORT_LIMIT })}
+ {reports.isError &&

{t("atlas.refreshFailed")}

} +
+ {[ + { label: t("atlas.reports"), value: reports.data ? number(sample.reports.length) : "—", values: sample.bins }, + { label: t("atlas.snr"), value: number(sample.snr, " dB"), values: ordered.map(p => p.rssi === 0 && p.snr === 0 ? null : p.snr ?? null) }, + { label: t("atlas.rssi"), value: number(sample.rssi, " dBm"), values: ordered.map(p => p.rssi === 0 && p.snr === 0 ? null : p.rssi ?? null) }, + { label: t("atlas.hops"), value: number(sample.hops), values: ordered.map(p => p.hopCount ?? null) }, + ].map(metric =>

{metric.label}

{metric.value}

)} +
+
+

{t("nodePage.packetTypes")}

{[...mix].sort((a,b) => b[1]-a[1]).map(([type,count]) =>
{type}{number(count)}
)}
+

{t("nodePage.regions")}

{[...regions].sort((a,b) => b[1]-a[1]).map(([iata,count]) =>
{iata}{number(count)}
)}
+
{t("atlas.radio")}
{node.radio || "—"}
{t("atlas.scope")}
{node.defaultScope || "—"}
{t("nodePage.clock")}
{node.clockDriftSeconds != null ? formatClockDrift(node.clockDriftSeconds) : "—"}
{t("nodePage.firstSeen")}
+
+
+

{t("nodePage.neighbors", { count: node.knownNeighborCount })}

{neighbors.data?.map(neighbor => )}
+

{t("nodeDetail.recentPackets")}

{sample.reports.slice(0,50).map(report => )}
+
+ } +
+
; +} diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 4b29d603..9560cddd 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -12,7 +12,7 @@ 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"; +import { NodeTypeBadge } from "../../components/NodeTypeBadge"; import { DataTable, type Column } from "../../components/DataTable"; import { LoadingPill } from "../../components/LoadingPill"; import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; @@ -57,12 +57,7 @@ function renderNodeCard(node: NodeSummary, t: TFunction) { {node.name ?? formatHex(node.id)} - - {node.isObserver && ( - - )} - {node.nodeTypeName} - +
@@ -110,12 +105,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable sortValue: (node) => node.nodeTypeName, cell: (node) => (
- - {node.isObserver && ( - - )} - {node.nodeTypeName} - +
), diff --git a/src/features/nodes/NodeTelemetry.tsx b/src/features/nodes/NodeTelemetry.tsx new file mode 100644 index 00000000..14a6ca0d --- /dev/null +++ b/src/features/nodes/NodeTelemetry.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from "react-i18next"; +import { Sparkline } from "../../components/Sparkline"; +import { Timestamp } from "../../components/Timestamp"; +import { useObserver, useObserverTelemetry } from "../stats/useTelemetry"; +import { intervalToMs } from "../stats/transforms"; +import { formatUptime } from "../../lib/formatters"; +import type { Node } from "./types"; +import { CollectedNodeTelemetry } from "./CollectedNodeTelemetry"; + +export function NodeTelemetry({ node, active = true, compact = false }: { node: Node; active?: boolean; compact?: boolean }) { + if (compact) return node.observerId ? : ; + return <>; +} + +function ObserverNodeTelemetry({ node, active, compact = false }: { node: Node; active: boolean; compact?: boolean }) { + const { t } = useTranslation(); + const observer = useObserver(active ? node.observerId ?? null : null); + const exact = observer.data?.publicKey?.toLowerCase() === node.publicKey.toLowerCase(); + const telemetry = useObserverTelemetry(active && exact ? node.observerId ?? null : null, "24h"); + const points = telemetry.data?.points ?? []; + if (!exact || !points.length) return compact ?

{t(observer.isError || telemetry.isError ? "nodeTelemetry.loadError" : observer.isPending || (exact && telemetry.isPending) ? "common.loading" : "nodeTelemetry.noData")}

: null; + const metrics = [ + { label: t("observerPage.battery"), values: points.map(p => p.batteryMv != null && p.batteryMv > 0 ? p.batteryMv / 1000 : null), format: (n: number) => `${n.toFixed(2)} V` }, + { label: t("observerPage.uptime"), values: points.map(p => p.uptimeSeconds), format: formatUptime }, + { label: t("observerPage.noise"), values: points.map(p => p.noiseFloorDb), format: (n: number) => `${n.toFixed(0)} dBm` }, + ].filter(metric => metric.values.some(v => v != null && Number.isFinite(v))); + if (!metrics.length) return null; + const renderMetric = (metric: typeof metrics[number],index:number) => { + const latest = metric.values.at(-1); + return
+

{metric.label}

+

{latest != null && Number.isFinite(latest) ? metric.format(latest) : "—"}

+ p.t)} gapMs={(intervalToMs(telemetry.data?.interval ?? "1h") ?? 3_600_000) * 1.5} color={index ? "var(--color-secondary)" : "var(--color-green)"} /> +
; + }; + return
+
{t("nodePage.telemetry24h")}
+
+ {metrics.slice(0, compact ? 2 : metrics.length).map(renderMetric)} +
+ {compact && metrics.length>2 &&
{t("nodeTelemetry.more",{count:metrics.length-2})}
{metrics.slice(2).map(renderMetric)}
} +
; +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 40886a67..d95da86e 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -22,6 +22,7 @@ export interface NodeSummary { observerId?: string; // Server verdict against its configured local border union; absence is unknown/disabled. possiblyForeign?: boolean; + stale?: boolean; // server-configured freshness threshold; absent on older servers } export interface Node extends NodeSummary { diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index fad3d818..c055fcf9 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -1,3 +1,5 @@ +import { fillActivity, intervalToMs } from "../stats/transforms"; +import { Sparkline, PresenceStrip } from "../../components/Sparkline"; import { useId, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Timestamp } from "../../components/Timestamp"; @@ -19,7 +21,7 @@ function Measure({ text }: { text: string }) { 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 }) { +export function ObserverSummary({ observer, activity, points, telemetryInterval = "1h", pending = false, actions }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; telemetryInterval?: string; 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; @@ -38,6 +40,23 @@ export function ObserverSummary({ observer, activity, points, pending = false, a [t("observerPage.client"), client], [t("observerPage.radio"), radio], ] as const).filter((item): item is readonly [string, string] => !!item[1]); + const step = intervalToMs(activity?.interval ?? "1h") ?? 3_600_000; + const rawActivity = activity?.points ?? []; + const activityPoints = activity?.windowStart != null && activity.windowEnd != null ? fillActivity(rawActivity, step, { start: activity.windowStart, end: activity.windowEnd }, activity) : rawActivity; + const hourly = new Map(); + if (step <= 3_600_000) for (const point of activityPoints) { + if (summary && point.t >= summary.lastCompleteHourEnd) continue; + const hour = Math.floor(point.t / 3_600_000) * 3_600_000; + hourly.set(hour, point.observations === null || hourly.get(hour) === null ? null : (hourly.get(hour) ?? 0) + point.observations); + } + const packetSeries = activityPoints.map(p => p.observations); + const trends: Record = { + records: packetSeries, + lastHour: [...hourly.values()], + battery: points.map(p => p.batteryMv != null && p.batteryMv > 0 ? p.batteryMv / 1000 : null), + uptime: points.map(p => p.uptimeSeconds), + noise: points.map(p => p.noiseFloorDb), + }; const cards: { key: string; label: string; value: string; title?: string }[] = [ { key: "records", label: t("observerPage.records"), value: summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—" }, { @@ -109,6 +128,9 @@ export function ObserverSummary({ observer, activity, points, pending = false, a return
  • {label} {title ? {shown} : shown} +
    + {key === "lastPacket" ? n != null && n > 0)} color="var(--color-primary)" /> : p.t) : key === "lastHour" ? [...hourly.keys()] : points.map(p => p.t)} gapMs={(key === "records" ? step : key === "lastHour" ? 3_600_000 : intervalToMs(telemetryInterval) ?? 3_600_000) * 1.5} color={key === "battery" ? "var(--color-green)" : key === "noise" ? "var(--color-secondary)" : "var(--color-primary)"} />} +
  • ; })} diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index b086e0a2..1fd03b73 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24 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); + for (const key of ["statsTab", "observerId", "node", "nodePage", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "routeHashSize", "routePathBytes", "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); } diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx index 6f155f3d..575b5e0a 100644 --- a/src/features/packets/ObservationCard.tsx +++ b/src/features/packets/ObservationCard.tsx @@ -79,7 +79,7 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod )}
    - {obs.pathBytes && ( + {obs.pathBytes && obs.traceQuality?.status !== "suspect" && (
    {t("observation.path")} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 5eaba78b..c6a90648 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -13,6 +13,7 @@ import { formatHex, formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, transportCodeHex } from "./packet-structure"; import { PayloadBreakdown } from "./payload-renderers"; +import { TraceQualityNotice } from "../traces/TraceQualityNotice"; import { PathData } from "./PathData"; import { buildPacketPaths } from "../map/packet-path"; import { PacketInvestigation } from "./PacketInvestigation"; @@ -84,7 +85,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
    {t("packetAnalyzer.title")}
    - {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")} />} + {detail && ({ tab: "Packets", route: null, routeIata: null, routeRange: null, routeSince: null, routeUntil: null, routeHashSize: null, routePathBytes: 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")} />}
    @@ -197,7 +198,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o )} {/* Path length */} - {selectedObs && ( + {selectedObs && detail.header.payloadType !== PayloadType.TRACE && (
    {t("packetAnalyzer.pathLength")}
    @@ -208,9 +209,13 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o )} - {/* 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 && ( + {selectedObs?.wirePathBytes != null && ( + +
    {t("traces.quality.snrBytes")}
    + {selectedObs.wirePathBytes.toUpperCase() || "—"} +
    + )} + {selectedObs?.pathBytes && detail.header.payloadType !== PayloadType.TRACE && (
    {t("packetAnalyzer.pathData")}
    @@ -226,6 +231,8 @@ 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/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx index 892d4e13..d78e7712 100644 --- a/src/features/packets/PacketInvestigation.tsx +++ b/src/features/packets/PacketInvestigation.tsx @@ -37,7 +37,7 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv ▶ {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.hashes.length > 0 && detail.traceQuality?.status !== "suspect" && {group.hashes.join(" → ")}} ×{group.reports.length}
      {group.reports.map(report => { diff --git a/src/features/packets/packet-structure.tsx b/src/features/packets/packet-structure.tsx index d7706b7d..42eb5e43 100644 --- a/src/features/packets/packet-structure.tsx +++ b/src/features/packets/packet-structure.tsx @@ -35,11 +35,11 @@ export function buildObservationFrame(detail: PacketDetail, obs: Observation | n // rather than dropping a byte and shifting every field after it out of alignment. const pad = (b?: string) => (!b ? "00" : b.length === 1 ? "0" + b : b); const tc = detail.transportCodes; - const header = pad(detail.header.raw) + (tc ? transportCodeHex(tc.regionCode) + transportCodeHex(tc.subRegionCode) : ""); + const header = pad(detail.header.raw.slice(0, 2)) + (tc ? transportCodeHex(tc.regionCode) + transportCodeHex(tc.subRegionCode) : ""); const payload = detail.rawPayload ?? ""; if (!obs) return header + payload; const pathLen = pad(obs.pathLength.raw); - const path = obs.pathBytes ?? ""; + const path = obs.wirePathBytes ?? obs.pathBytes ?? ""; return header + pathLen + path + payload; } @@ -63,7 +63,7 @@ export function computeFieldRanges( ranges.pathLength = { start: offset, end: offset + 1 }; offset += 1; - const pathByteCount = obs.pathLength.hashSize * obs.pathLength.hopCount; + const pathByteCount = obs.wirePathBytes != null ? obs.wirePathBytes.length / 2 : obs.pathLength.hashSize * obs.pathLength.hopCount; if (pathByteCount > 0) { ranges.pathData = { start: offset, end: offset + pathByteCount }; } diff --git a/src/features/routes/RouteDetailPanel.tsx b/src/features/routes/RouteDetailPanel.tsx index e7f84c28..6104a5ae 100644 --- a/src/features/routes/RouteDetailPanel.tsx +++ b/src/features/routes/RouteDetailPanel.tsx @@ -1,9 +1,10 @@ import { useInfiniteQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; 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"; @@ -29,16 +30,28 @@ interface RouteDetailPanelProps extends RouteActions { onClose: () => void; } -// A shared link only carries iata + pathKey, so the route itself can arrive with the first packet page. +// The regular view follows retention. Copied links also pin the time window and +// representation, so a later hash-width update cannot silently change the evidence. export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnalyzePacket, onViewObserver, onViewNode }: RouteDetailPanelProps) { const { t } = useTranslation(); + const [params] = useSearchParams(); + const [openedAt] = useState(Date.now); + const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); + const rawWidth = params.get("routeHashSize"), rawPath = params.get("routePathBytes"); + const fixed = rawSince !== null || rawUntil !== null; + const since = Number(rawSince), until = Number(rawUntil), hashSize = Number(rawWidth); + const pinned = rawWidth !== null || rawPath !== null; + const invalidWindow = fixed && (params.getAll("routeSince").length !== 1 || params.getAll("routeUntil").length !== 1 || !rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 30 * 86_400_000); + const invalidPath = pinned && (params.getAll("routeHashSize").length !== 1 || params.getAll("routePathBytes").length !== 1 || !rawWidth || !/^[1-3]$/.test(rawWidth) || !rawPath || !/^[0-9a-f]+$/.test(rawPath) || rawPath.length < 4 * hashSize || rawPath.length > 126 * hashSize || rawPath.length % (2 * hashSize) !== 0); + const pathParams = pinned && !invalidPath ? { hashSize, pathBytes: rawPath! } : {}; + const invalid = invalidWindow || invalidPath; 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), + queryKey: ["route-evidence", iata, pathKey, rawSince, rawUntil, rawWidth, rawPath], + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata!, pathKey!, pageParam ? { pageCursor: pageParam, limit: PAGE_LIMIT } : fixed ? { since, until, ...pathParams, limit: PAGE_LIMIT } : { range: RECENT_RANGE, ...pathParams, limit: PAGE_LIMIT }, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (last, pages) => pages.length < MAX_PAGES && last.hasMore ? last.nextPageCursor : undefined, - enabled: keyed, + enabled: keyed && !invalid, staleTime: Infinity, refetchOnWindowFocus: false, retry: false, @@ -47,15 +60,15 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal 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 ( ({ tab: "Routes", route: pathKey!, routeIata: iata!, hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />} + isLoading={!route && query.isPending && keyed && !invalid} + headerAction={keyed && !invalid && ({ tab: "Routes", route: pathKey!, routeIata: iata!, routeRange: null, routeSince: first ? String(first.windowStart) : rawSince, routeUntil: first ? String(first.windowEnd) : rawUntil, routeHashSize: first ? first.matchAvailable && first.hashSize && first.pathBytes ? String(first.hashSize) : null : rawWidth, routePathBytes: first ? first.matchAvailable && first.hashSize && first.pathBytes ? first.pathBytes : null : rawPath, hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />} > + {invalid &&

      {t(invalidPath ? "routeEvidence.invalidPath" : "routeEvidence.invalid")}

      } {route && <>
      @@ -72,7 +85,7 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal return (
      #{i + 1} - + {hop.node?.name && {hop.node.name}}
      ); @@ -88,8 +101,8 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
      } - {keyed && (route || query.isError) && ( -
      {t("routeEvidence.recent")}}> + {keyed && !invalid && (route || query.isError) && ( +
      {query.isError && (

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

      diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 2b3c7485..4ad3297b 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -169,7 +169,7 @@ export function RouteTable(actions: RouteActions) { // 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 }); + setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil", "routeHashSize", "routePathBytes"]) next.delete(key); return next; }, { replace: true }); }, [pathKey, selectedKey, setParams]); @@ -258,7 +258,7 @@ export function RouteTable(actions: RouteActions) { 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 }); + setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); for (const key of ["routeRange", "routeSince", "routeUntil", "routeHashSize", "routePathBytes"]) next.delete(key); return next; }, { replace: true }); } else { closeRoute(); setSelectedKey(id); diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index d10e2379..920b4b60 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -1,3 +1,4 @@ +import { Sparkline, PresenceStrip } from "../../components/Sparkline"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { EChart } from "./EChart"; @@ -26,59 +27,6 @@ export function Card({ ); } -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 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" - - )} - - ); -} - -// 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, @@ -104,7 +52,7 @@ export function StatCard({ {sublabel && {sublabel}}
      {value}
      - {presence ? : spark ? : sparkSpacer} + {presence ? : spark ? :
      }
      ); } diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 42a3f0c2..7a99b283 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -327,8 +327,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs xAxis: timeAxis(c), yAxis: percentAxis(c), series: [ - { 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 } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: false, 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: false, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } @@ -351,6 +351,15 @@ function metricLineOption( c: ChartColors, o: { name: string; color: string; accessor: (p: TelemetryPoint) => number | null; delta?: boolean; area?: boolean }, ): EChartsOption { + const data = seriesData(points,o.accessor,o.delta ?? false); + // Only gauge readings are interpolated. Counter/delta/stacked gaps stay empty. + const bridges: (number|null)[][] = []; + let previous=-1; + if (!o.delta && !o.area) data.forEach((point,i)=>{ + if (point[1]==null) return; + if (previous>=0 && i>previous+1) bridges.push(data[previous]!,point,[point[0]!,null]); + previous=i; + }); return { animation: false, useUTC: true, @@ -363,14 +372,15 @@ function metricLineOption( { name: o.name, type: "line", - smooth: true, + smooth: false, symbol: "none", - connectNulls: true, - data: seriesData(points, o.accessor, o.delta ?? false), - lineStyle: { color: o.color, width: 1.8 }, + connectNulls: false, + data, + lineStyle: { color: o.color, width: 2, shadowColor: withAlpha(o.color,0.3), shadowBlur: 3 }, itemStyle: { color: o.color }, ...(o.area ? { areaStyle: { color: withAlpha(o.color, 0.16) } } : {}), }, + ...(bridges.length ? [{ type: "line" as const, data: bridges, connectNulls: false, smooth: false, symbol: "none", silent: true, tooltip: { show: false, trigger: "none" as const }, lineStyle: { color:o.color, width:1.4, type:"dashed" as const, opacity:0.5 }, emphasis:{disabled:true} }] : []), ], }; } diff --git a/src/features/topology/TopologyCanvas.tsx b/src/features/topology/TopologyCanvas.tsx new file mode 100644 index 00000000..59373217 --- /dev/null +++ b/src/features/topology/TopologyCanvas.tsx @@ -0,0 +1,59 @@ +import type { LiveChatter } from "./chatter"; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { ChartColors } from "../stats/chartTheme"; +import type { PathDisplay, LiveTraffic, Topology } from "./topology"; +import { createRenderer, type CameraView } from "./topology-renderer"; + +export function TopologyCanvas({ graph, traffic, chatter, colors, selected, onSelect, region, onRegion, heading, controls, settings, motion, active, tick }: { graph: Topology; traffic: LiveTraffic; chatter?: LiveChatter; colors: ChartColors; selected: string; onSelect: (id: string) => void; region: string; onRegion: (code: string) => void; heading: ReactNode; controls: ReactNode; settings: ReactNode; motion: boolean; active: boolean; tick: number }) { + const { t } = useTranslation(); + const canvas = useRef(null); + const panel = useRef(null); + const displayMenu = useRef(null); + const [expanded, setExpanded] = useState(false); + useEffect(() => { + const change = () => setExpanded(document.fullscreenElement === panel.current); + document.addEventListener("fullscreenchange", change); + return () => document.removeEventListener("fullscreenchange", change); + }, []); + const selectNode = useEffectEvent(onSelect); + const selectRegion = useEffectEvent(onRegion); + const [display, setDisplay] = useState("all"); + useEffect(() => { + const close = (event: PointerEvent) => { if (displayMenu.current && !displayMenu.current.contains(event.target as Node)) displayMenu.current.open = false; }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, []); + const renderer = useRef>(null); + const cameraView = useRef(undefined); + useEffect(() => { + if (!canvas.current) return; + renderer.current = createRenderer(canvas.current, graph, traffic, colors, id => selectNode(id), code => selectRegion(code), cameraView.current, chatter); + return () => { if (graph.nodes.length) cameraView.current = renderer.current?.view(); renderer.current?.dispose(); renderer.current = null; }; + }, [graph, traffic, colors, chatter]); + useEffect(() => { renderer.current?.update(selected, region, selected || display !== "selected" ? display : "all", motion, active); }, [selected, region, display, motion, active, tick, graph, colors]); + const button = "min-h-11 rounded border border-border bg-bg-surface px-3 text-xs text-text-bright hover:border-primary"; + const compactButton = "min-h-8 rounded border border-border bg-bg-surface px-2 text-[11px] text-text-bright hover:border-primary"; + return
      +
      + {heading} +
      + {controls} +
      { if (e.key === "Escape" && e.currentTarget.open) { e.preventDefault(); e.currentTarget.open = false; e.currentTarget.querySelector("summary")?.focus(); } }}> + {t("topology.display")} +
      + +
      + {settings} +

      {t("topology.controlsPan")}

      +
      +
      + {document.fullscreenEnabled && } +
      +
      +
      + +
      +
      +
      ; +} diff --git a/src/features/topology/TopologyPage.tsx b/src/features/topology/TopologyPage.tsx new file mode 100644 index 00000000..7ecc240e --- /dev/null +++ b/src/features/topology/TopologyPage.tsx @@ -0,0 +1,192 @@ +import { LiveChatter } from "./chatter"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { getScopeCatalogues } from "../../api/client"; +import type { WsManager } from "../../api/ws-manager"; +import { useRegion } from "../../hooks/useRegion"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useChartColors, nodeTypeColor } from "../stats/chartTheme"; +import { NODE_TYPES } from "../../lib/node-types"; +import { buildTopology, LiveTraffic, loadTopology, loadTopologyRoutes, ROUTE_WINDOWS, reportFromEvent, type LiveReport, type PacketKind, flowColor, linkContext } from "./topology"; +import { TopologyCanvas } from "./TopologyCanvas"; +import { BottomSheet } from "../../components/BottomSheet"; + +type Props = { wsManager: WsManager; active?: boolean; onViewNode: (id: string) => void; onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }; +const button = "min-h-11 rounded-sm border border-border bg-bg-surface px-3 text-xs text-text-bright hover:border-primary disabled:opacity-40"; + +export function TopologyPage(props: Props) { + const { iatas, regionKey, isResolved } = useRegion(); + return ; +} + +function TopologySession({ wsManager, active = true, onViewNode, onViewObserver, onAnalyzePacket, iatas, resolved }: Props & { iatas: string[] | undefined; resolved: boolean }) { + const { t } = useTranslation(); + const colors = useChartColors(); + const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const [animate, setAnimate] = useState(true); + const [showChatter,setShowChatter] = useState(true); + const [chatter] = useState(()=>new LiveChatter()); + const focus = iatas?.length === 1 ? iatas[0]! : ""; + const [search, setSearch] = useState(""); + const [copied, setCopied] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [activityOpen, setActivityOpen] = useState(false); + const mobile = useMediaQuery("(max-width: 767px)"); + const section = useRef(null); + const searchInput = useRef(null); + const [params, setParams] = useSearchParams(); + const selected = params.get("topoNode") || ""; + const showInspector = active && (searchOpen || !!selected); + const requestedWindow = params.get("topoWindow") || "15m"; + const routeWindow = Object.hasOwn(ROUTE_WINDOWS, requestedWindow) ? requestedWindow as keyof typeof ROUTE_WINDOWS : "15m"; + const data = useQuery({ queryKey: ["topology", iatas ?? "*"], queryFn: ({ signal }) => loadTopology(iatas, signal), enabled: active && resolved, staleTime: 120_000, refetchOnWindowFocus: false }); + const routes = useQuery({ queryKey: ["topology-routes", iatas ?? "*", routeWindow], queryFn: ({ signal }) => loadTopologyRoutes(iatas, signal, ROUTE_WINDOWS[routeWindow]), enabled: active && resolved, staleTime: 60_000, retry: false, refetchOnWindowFocus: false, placeholderData: keepPreviousData }); + const catalogues = useQuery({ queryKey: ["scope-catalogues"], queryFn: getScopeCatalogues, enabled: showInspector && resolved, staleTime: 300_000, refetchInterval: 300_000, refetchIntervalInBackground: false, refetchOnWindowFocus: false, retry: false }); + const graph = useMemo(() => buildTopology(data.data?.nodes ?? [], iatas, routes.data?.routes ?? []), [data.data, iatas, routes.data]); + const requestedCameraRegion = params.get("topoFocus") || focus; + const cameraRegion = graph.regions.some(r => r.code === requestedCameraRegion) ? requestedCameraRegion : ""; + const select = useCallback(async (id: string) => { + if (document.fullscreenElement && section.current?.contains(document.fullscreenElement)) await document.exitFullscreen().catch(() => {}); + setParams(prev => { const next = new URLSearchParams(prev); next.set("topoNode", id); const region = graph.byId.get(id)?.region; if (region) next.set("topoFocus", region); return next; }); + }, [setParams, graph]); + const focusCamera = (code: string) => setParams(prev => { const next = new URLSearchParams(prev); if (code) next.set("topoFocus", code); else next.delete("topoFocus"); next.delete("topoNode"); return next; }); + const [traffic] = useState(() => new LiveTraffic()); + const [live, setLive] = useState<{ reports: LiveReport[]; tick: number; now: number; gap: boolean; capped: boolean }>(() => ({ reports: [], tick: 0, now: Date.now(), gap: false, capped: false })); + const subscribe = useCallback((notify: () => void) => wsManager.onStatusChange(notify), [wsManager]); + const status = useSyncExternalStore(subscribe, () => wsManager.getStatus()); + const receive = useEffectEvent((event: Parameters[0]) => { + if (document.hidden || (iatas && !iatas.includes(event?.observation?.iata))) return; + if (showChatter) chatter.observe(event,graph,Date.now()); + const report = reportFromEvent(event, graph, Date.now()); + if (report) traffic.add(report); + }); + + const receiveChat = useEffectEvent((event: Parameters[0]) => { + if (!document.hidden && showChatter) chatter.message(event,Date.now()); + }); + useEffect(() => { if (!showChatter) chatter.clear(); },[showChatter,chatter]); + useEffect(() => { + if (!active || !resolved || !data.data) return; + const packet = wsManager.onPacketObservation(event => receive(event)); + const messages = wsManager.onChannelMessage(event=>receiveChat(event)); + const lagged = wsManager.onLagged(() => {traffic.markGap();chatter.clear();}); + const visibility = () => { wsManager.setResolvePath(!document.hidden); if (document.hidden) { traffic.markGap(); traffic.clearFlows(); chatter.clear(); } }; + visibility(); + document.addEventListener("visibilitychange", visibility); + const timer = window.setInterval(() => { + if (document.hidden) return; + const now = Date.now(); traffic.prune(now); + setLive(previous => ({ reports: [...traffic.reports], tick: previous.tick + 1, now, gap: traffic.gapUntil > now, capped: traffic.cappedUntil > now })); + }, 500); + return () => { packet(); messages(); lagged(); chatter.clear(); clearInterval(timer); document.removeEventListener("visibilitychange", visibility); wsManager.setResolvePath(false); traffic.clearFlows(); traffic.markGap(); }; + }, [active, resolved, data.data, iatas, traffic, wsManager, chatter]); + + const counts = useMemo(() => { + const observers = new Map(); + const kinds: Record = { advert: 0, text: 0, trace: 0, ack: 0, other: 0 }; + for (const report of live.reports) { const old = observers.get(report.observerId); observers.set(report.observerId, { name: report.observerName, count: (old?.count ?? 0) + 1, iata: report.iata }); kinds[report.kind]++; } + return { observers: [...observers].sort((a, b) => b[1].count - a[1].count), kinds, paths: live.reports.filter(r => r.segments.length).length }; + }, [live.reports]); + const matches = useMemo(() => search.trim() ? graph.nodes.filter(n => `${n.name ?? ""} ${n.publicKey}`.toLowerCase().includes(search.trim().toLowerCase())).slice(0, 30) : [], [graph.nodes, search]); + const node = graph.byId.get(selected); + const catalogue = catalogues.data?.find(c => c.iata === (node?.region || focus)); + const catalogueStale = catalogue && (!!catalogue.lastError || !catalogue.checkedAt || live.now > catalogue.freshUntil); + const visibleNeighbours = node ? graph.links.filter(([a, b]) => a === node.id || b === node.id).map(([a, b]) => graph.byId.get(a === node.id ? b : a)!) : []; + const neighbours = visibleNeighbours.slice(0, 12); + const share = async () => { try { await navigator.clipboard.writeText(window.location.href); setCopied(true); } catch { setCopied(false); } }; + const closeInspector = () => { + setSearchOpen(false); setSearch(""); + setParams(prev => { const next = new URLSearchParams(prev); next.delete("topoNode"); return next; }); + section.current?.querySelector("canvas")?.focus(); + }; + const openInspector = async () => { + if (document.fullscreenElement && section.current?.contains(document.fullscreenElement)) await document.exitFullscreen().catch(() => {}); + setSearchOpen(true); + }; + useEffect(() => { if (searchOpen && active) searchInput.current?.focus(); }, [searchOpen, active]); + const inspector =
      +
      +

      {t("topology.inspector")}

      + setSearch(e.target.value)} /> +
      {matches.map(n => )}{search.trim() && !matches.length &&

      {t("topology.noNodes")}

      }
      + {node &&

      {node.name || node.publicKey.slice(0, 12)}

      {t("topology.neighbours", { count: visibleNeighbours.length })}

      {node.observerId && }
      + {node.defaultScope &&

      {t("topology.defaultScope")}: {node.defaultScope}

      } + {visibleNeighbours.length > 12 &&

      {t("topology.neighbourLimit", { count: visibleNeighbours.length })}

      } +
      {neighbours.map(neighbour => { const context = linkContext(node, neighbour); const listed = context.sharedDefault && catalogues.data?.some(c => (c.iata === node.region || c.iata === neighbour.region) && c.scopes.some(scope => scope.name === context.sharedDefault)); return ; })}
      +
      } +
      + {(focus || node) &&

      MeshMapper · {node?.region || focus}

      + {catalogue ? <>

      {t(catalogueStale ? "topology.catalogueStale" : "topology.catalogueFresh")}

      {catalogue.generatedAt > 0 &&

      {t("topology.catalogueCounts", { count: catalogue.scoped, total: catalogue.repeaters })}

      }
      {catalogue.scopes.map(scope =>
      {scope.name}{scope.monitored ? " ◉" : ""}{scope.repeaters}
      )}

      {t("topology.catalogueChecked")}: {catalogue.checkedAt ? new Date(catalogue.checkedAt).toLocaleString() : "—"}

      :

      {t(catalogues.isLoading ? "topology.loading" : catalogues.isError ? "topology.catalogueError" : "topology.noCatalogue")}

      } +

      {t("topology.catalogueMeaning")}

      +
      } + +
      ; + const compactButton = "min-h-8 rounded border border-border bg-bg-surface px-2 text-[11px] text-text-bright hover:border-primary"; + const controls = <> + + + ; + const settings = <> + +

      {t("topology.chatterHint")}

      + +
      + {NODE_TYPES.map(type => {t(`topology.types.${type.name}`)})} + {t("topology.observerRing")}━ {t("topology.routeSegments")}┄ {t("topology.crossRegion")}━ {t("topology.sharedDefault")} +
      +
      + + +
      + ; + const heading =
      +

      {t("topology.title")}

      + {t(status === "connected" ? "topology.live" : "topology.reconnecting")} +
      +
      + {data.data ? graph.nodes.length.toLocaleString() : "—"} {t("topology.nodes")} + {data.data ? graph.links.length.toLocaleString() : "—"} {t("topology.links")} + {routes.isFetching && {t("topology.loadingRoutes")}} +
      +
      ; + return
      +
      + + {data.isError &&

      {t("topology.loadError")}

      } + {data.isLoading &&

      {t("topology.loading")}

      } + {routes.isError &&

      {t("topology.routesError")}

      } + {(data.data?.capped || routes.data?.capped || graph.linksCapped || live.capped) &&

      {t("topology.bounded")}

      } +
      + + {showInspector && !mobile && } +
      + {showInspector && mobile && {inspector}} +
      setActivityOpen(e.currentTarget.open)}> + + {t("topology.activity")} + {t("topology.activityCounts", { count: live.reports.length, observers: counts.observers.length })} + {live.gap && {t("topology.incomplete")}} + + {activityOpen &&
      + {live.gap &&

      {t("topology.gap")}

      } +
      +
      {t("topology.mix")}{t("topology.paths", { count: counts.paths })}
      +
      {Object.entries(counts.kinds).map(([kind, count]) =>
      )}
      +
      {Object.entries(counts.kinds).map(([kind, count]) => ● {t(`topology.kinds.${kind}`)} {count})}
      +
      + +
      +

      {t("topology.reporting")}

      {counts.observers.slice(0, 20).map(([id, observer]) => )}{!counts.observers.length &&

      {t("topology.listening")}

      }
      +

      {t("topology.latest")}

      {live.reports.slice(-10).reverse().map(report => )}
      + +
      +
      } +
      +
      +
      ; +} diff --git a/src/features/topology/chatter.ts b/src/features/topology/chatter.ts new file mode 100644 index 00000000..58d9b391 --- /dev/null +++ b/src/features/topology/chatter.ts @@ -0,0 +1,50 @@ +import type { ChannelMessage } from "../channels/types"; +import type { WsPacketObservation } from "../../types/ws"; +import type { Topology } from "./topology"; + +export const CHAT_MS = 8_000; +export type ChatBubble = { hash: string; sender: string; content: string; nodeId: string; at: number; heardAt: number }; +type Pending = { at: number; message?: ChannelMessage; nodeId?: string; heardAt?: number }; +// eslint-disable-next-line no-control-regex -- strip control bytes from untrusted display text +const clean = (value: string, limit: number) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, limit); + +// Join the two existing live events in either arrival order. Never fetch a packet +// per message, infer an identity from its sender text, or replay channel history. +export class LiveChatter { + private pending = new Map(); + private entry(hash: string, now: number) { + let entry = this.pending.get(hash); + if (!entry) { + entry = { at: now }; this.pending.set(hash, entry); + if (this.pending.size > 128) this.pending.delete(this.pending.keys().next().value!); + } + return entry; + } + message(message: ChannelMessage, now: number) { + // A server-confirmed decryption key, not the collidable one-byte channel hash/name. + if (message.isPublic !== true || !message.packetHash || typeof message.content !== "string" || !message.content.trim()) return; + this.entry(message.packetHash, now).message = message; + } + observe(event: WsPacketObservation["data"], graph: Topology, now: number) { + if (event?.packet?.payloadType !== 5 || !event.packetHash || !Number.isFinite(event.observation?.heardAt)) return; + const observer = graph.nodes.find(n => n.observerId === event.observation.observerId); + const first = event.observation.resolvedPath?.[0]; + const relay = (event.packet.routeType === 0 || event.packet.routeType === 1) && first?.confidence === "high" && first.nodes.length === 1 ? graph.byId.get(first.nodes[0]!.id) : undefined; + const node = observer ?? relay; + if (!node) return; + const entry = this.entry(event.packetHash, now); + if (entry.heardAt == null || event.observation.heardAt < entry.heardAt) { + entry.nodeId = node.id; entry.heardAt = event.observation.heardAt; + } + } + visible(now: number): ChatBubble[] { + const bubbles: ChatBubble[] = []; + for (const [hash, entry] of this.pending) { + if (now - entry.at > 30_000) { this.pending.delete(hash); continue; } + if (!entry.message || !entry.nodeId || now - entry.at < 600 || now - entry.at >= CHAT_MS) continue; + bubbles.push({ hash, sender: clean(entry.message.senderName || "?", 40), content: clean(entry.message.content, 180), nodeId: entry.nodeId, at: entry.at, heardAt: entry.heardAt! }); + } + return bubbles; + } + clear() { this.pending.clear(); } +} diff --git a/src/features/topology/topology-renderer.ts b/src/features/topology/topology-renderer.ts new file mode 100644 index 00000000..a307c8fc --- /dev/null +++ b/src/features/topology/topology-renderer.ts @@ -0,0 +1,304 @@ +import { CHAT_MS, type LiveChatter } from "./chatter"; +import { nodeTypeColor, type ChartColors } from "../stats/chartTheme"; +import { DEFAULT_CAMERA, FLOW_MS, project, fitCamera, panCamera, pathControls, visibleLinks, flowColor, linkContext, type Camera, type PathDisplay, type LiveTraffic, type Point3, type Topology } from "./topology"; + +export type CameraView = { camera: Camera; region: string; extent: number }; + +export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traffic: LiveTraffic, colors: ChartColors, select: (id: string) => void, focus: (code: string) => void, view?: CameraView, chatter?: LiveChatter) { + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + let camera = { ...(view?.camera ?? DEFAULT_CAMERA), target: { ...(view?.camera.target ?? DEFAULT_CAMERA.target) } }; + let transition: { from: Camera; to: Camera; at: number } | null = null; + const background = document.createElement("canvas"), backgroundCtx = background.getContext("2d"); + let width = 1, height = 1, frame = 0, previousFrame = 0; + let running = true, motion = true, selected = "", dirty = true, baseDirty = true, regionCode = view?.region ?? ""; + let fitted = !!view; + let display: PathDisplay = "all"; + let regionHits: { code: string; x: number; y: number; width: number }[] = []; + let lastTrailVersion = -1; + let projected: { id: string; x: number; y: number; depth: number; scale: number }[] = []; + let related = new Set(); + const pointers = new Map(); + let moved = false, start = { x: 0, y: 0 }, panning = false; + const regionByCode = new Map(graph.regions.map(r => [r.code, r])); + const extent = Math.max(220, ...graph.regions.map(r => Math.hypot(r.x, r.z) + r.radius)); + if (view) camera.zoom *= extent / view.extent; + const p = (point: Point3) => project(point, camera, width, height, extent); + const line = (a: Point3, b: Point3) => { const from = p(a), to = p(b); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); }; + const curve = (a: Point3, b: Point3, cross: boolean) => pathControls(a, b, cross).map(p); + const strokeCurve = (points: ReturnType) => { + const [a, c, d, b] = points; + ctx.beginPath(); ctx.moveTo(a!.x, a!.y); ctx.bezierCurveTo(c!.x, c!.y, d!.x, d!.y, b!.x, b!.y); ctx.stroke(); + }; + const wake = (changed = false) => { baseDirty ||= changed; dirty = true; if (!frame && running && !document.hidden) frame = requestAnimationFrame(draw); }; + function fit(smooth = true) { + const regions = regionByCode.has(regionCode) ? [regionByCode.get(regionCode)!] : graph.regions; + const points = regions.flatMap(r => [ + { x: r.x - r.radius - 20, y: 0, z: r.z - r.radius - 30 }, { x: r.x + r.radius + 20, y: 65, z: r.z + r.radius + 30 }, + { x: r.x - r.radius - 20, y: 0, z: r.z + r.radius + 30 }, { x: r.x + r.radius + 20, y: 0, z: r.z - r.radius - 30 }, + ]); + if (!regionCode) for (const bundle of graph.bundles) { + const a = regionByCode.get(bundle.from)!, b = regionByCode.get(bundle.to)!; + points.push({ x: (a.x + b.x) / 2, y: pathControls(a, b, true)[1].y * 0.75, z: (a.z + b.z) / 2 }); + } + const next = fitCamera(points, camera, width, height, extent); + if (smooth && motion) transition = { from: camera, to: next, at: performance.now() }; + else { camera = next; transition = null; } + wake(true); + } + + function draw(now: number) { + frame = 0; + if (!running || document.hidden) return; + const bubbles = chatter?.visible(Date.now()) ?? []; + const flows = motion ? traffic.flows.filter(r => Date.now() - r.at < FLOW_MS) : []; + // A fixed 30 fps ceiling and capped pixel ratio bound work on phones and large meshes. + if (!dirty && (flows.length || bubbles.length || transition) && now - previousFrame < 32) { frame = requestAnimationFrame(draw); return; } + previousFrame = now; + dirty = false; + if (transition) { + const t = Math.min(1, (now - transition.at) / 420), ease = 1 - (1 - t) ** 3; + const mix = (a: number, b: number) => a + (b - a) * ease, { from, to } = transition; + camera = { yaw: mix(from.yaw, to.yaw), pitch: mix(from.pitch, to.pitch), zoom: mix(from.zoom, to.zoom), target: { x: mix(from.target.x, to.target.x), y: mix(from.target.y, to.target.y), z: mix(from.target.z, to.target.z) } }; + baseDirty = true; if (t === 1) transition = null; + } + // Repaint static ink only for a changed camera, graph, style or selection. + if (baseDirty || !backgroundCtx) { + ctx!.clearRect(0, 0, width, height); + ctx!.fillStyle = colors.bgBase; + ctx!.fillRect(0, 0, width, height); + ctx!.lineWidth = 1; + ctx!.strokeStyle = colors.borderSubtle; + ctx!.globalAlpha = 0.65; + ctx!.beginPath(); + for (let i = -6; i <= 6; i++) { + const v = extent * i / 6; + line({ x: v, y: -16, z: -extent }, { x: v, y: -16, z: extent }); + line({ x: -extent, y: -16, z: v }, { x: extent, y: -16, z: v }); + } + ctx!.stroke(); + ctx!.globalAlpha = 0.32; + ctx!.strokeStyle = colors.primary; + for (const region of graph.regions) { + ctx!.beginPath(); + for (let i = 0; i <= 32; i++) { + const q = p({ x: region.x + Math.cos(i * Math.PI / 16) * region.radius, y: -12, z: region.z + Math.sin(i * Math.PI / 16) * region.radius }); + if (i === 0) ctx!.moveTo(q.x, q.y); else ctx!.lineTo(q.x, q.y); + } + ctx!.fillStyle = colors.bgSurface; ctx!.globalAlpha = 0.9; ctx!.fill(); + ctx!.globalAlpha = region.code === regionCode ? 0.9 : 0.35; ctx!.stroke(); + } + for (const [a, b] of visibleLinks(graph, display, regionCode, selected)) { + const lit = selected === a || selected === b; + const context = linkContext(graph.byId.get(a)!, graph.byId.get(b)!); + ctx!.globalAlpha = selected ? (lit ? 0.7 : 0.07) : 0.2; + ctx!.strokeStyle = lit ? colors.textBright : graph.routeLinks.has([a, b].sort().join("|")) ? colors.secondary : context.sharedDefault ? colors.green : context.crossRegion ? colors.primary : colors.textMuted; + ctx!.setLineDash(context.crossRegion ? [3, 4] : []); + ctx!.lineWidth = lit ? 1.6 : 0.7; + strokeCurve(curve(graph.byId.get(a)!, graph.byId.get(b)!, context.crossRegion)); + } + ctx!.setLineDash([]); + for (const trail of traffic.trails.values()) { + const from = graph.byId.get(trail.from), to = graph.byId.get(trail.to); if (!from || !to) continue; + if (display !== "all" && selected !== from.id && selected !== to.id && !(display === "bundled" && regionCode && from.region === regionCode && to.region === regionCode)) continue; + ctx!.globalAlpha = 0.35; ctx!.strokeStyle = flowColor(trail.kind, colors); ctx!.lineWidth = 1; + strokeCurve(curve(from, to, from.region !== to.region)); + } + if (display === "bundled") for (const bundle of graph.bundles) { + const points = curve(regionByCode.get(bundle.from)!, regionByCode.get(bundle.to)!, true); + ctx!.globalAlpha = 0.6; ctx!.strokeStyle = colors.primary; ctx!.lineWidth = Math.min(4, 1 + Math.log2(1 + bundle.count) * 0.3); strokeCurve(points); + const x = (points[0]!.x + 3 * points[1]!.x + 3 * points[2]!.x + points[3]!.x) / 8, y = (points[0]!.y + 3 * points[1]!.y + 3 * points[2]!.y + points[3]!.y) / 8; + ctx!.globalAlpha = 1; ctx!.font = "10px monospace"; ctx!.textAlign = "center"; + const w = ctx!.measureText(String(bundle.count)).width + 10; + ctx!.fillStyle = colors.bgRaised; ctx!.fillRect(x - w / 2, y - 8, w, 16); ctx!.fillStyle = colors.primary; ctx!.fillText(String(bundle.count), x, y + 3); + } + projected = graph.nodes.map(n => ({ id: n.id, ...p(n) })).filter(q => q.visible).sort((a, b) => b.depth - a.depth); + for (const q of projected) { + if (q.x < -10 || q.x > width + 10 || q.y < -10 || q.y > height + 10) continue; + const n = graph.byId.get(q.id)!; + const lit = related.has(n.id); + const radius = n.id === selected ? 6 : Math.max(0.8, Math.min(4.2, q.scale * (5 + Math.log2(1 + n.knownNeighborCount)))); + ctx!.globalAlpha = selected && !lit ? 0.16 : n.stale ? 0.4 : 0.9; + ctx!.fillStyle = nodeTypeColor(n.nodeTypeName, colors); + ctx!.beginPath(); + if (n.nodeTypeName === "companion") { ctx!.moveTo(q.x, q.y - radius); ctx!.lineTo(q.x + radius, q.y + radius); ctx!.lineTo(q.x - radius, q.y + radius); ctx!.closePath(); } + else if (n.nodeTypeName === "room_server" || n.nodeTypeName === "sensor") ctx!.rect(q.x - radius, q.y - radius, radius * 2, radius * 2); + else ctx!.arc(q.x, q.y, radius, 0, Math.PI * 2); + ctx!.fill(); + if (n.isObserver || n.id === selected) { ctx!.strokeStyle = n.id === selected ? colors.textBright : colors.primary; ctx!.lineWidth = 1.2; ctx!.beginPath(); ctx!.arc(q.x, q.y, radius + 3, 0, Math.PI * 2); ctx!.stroke(); } + } + ctx!.globalAlpha = 1; + backgroundCtx?.clearRect(0, 0, background.width, background.height); backgroundCtx?.drawImage(canvas, 0, 0); baseDirty = false; + } else ctx!.drawImage(background, 0, 0, width, height); + // All live flows remain visible in every link-display mode. Curves change + // presentation only: their endpoints still come from exact observed segments. + for (const report of flows) { + const progress = Math.min(1, (Date.now() - report.at) / FLOW_MS); + const segmentIndex = Math.min(report.segments.length - 1, Math.floor(progress * report.segments.length)); + const pair = report.segments[segmentIndex]; + if (!pair) continue; + const a = graph.byId.get(pair[0]), b = graph.byId.get(pair[1]); + if (!a || !b) continue; + const points = curve(a, b, a.region !== b.region); + const fraction = progress * report.segments.length - segmentIndex; + const inv = 1 - fraction; + const x = inv ** 3 * points[0]!.x + 3 * inv ** 2 * fraction * points[1]!.x + 3 * inv * fraction ** 2 * points[2]!.x + fraction ** 3 * points[3]!.x; + const y = inv ** 3 * points[0]!.y + 3 * inv ** 2 * fraction * points[1]!.y + 3 * inv * fraction ** 2 * points[2]!.y + fraction ** 3 * points[3]!.y; + ctx!.strokeStyle = flowColor(report.kind, colors); + ctx!.globalAlpha = 0.55; + ctx!.lineWidth = 1.5; + strokeCurve(points); + ctx!.globalAlpha = 0.2; ctx!.fillStyle = flowColor(report.kind, colors); + ctx!.beginPath(); ctx!.arc(x, y, 8, 0, Math.PI * 2); ctx!.fill(); + ctx!.globalAlpha = 1; ctx!.beginPath(); ctx!.arc(x, y, 2.8, 0, Math.PI * 2); ctx!.fill(); + } + ctx!.globalAlpha = 1; + ctx!.font = "600 11px Inter, sans-serif"; + ctx!.textAlign = "center"; + regionHits = []; + const recentByRegion = new Map(); + if (motion) for (let i = traffic.reports.length - 1; i >= 0; i--) { + const report = traffic.reports[i]!; if (Date.now() - report.at >= 1500) break; + if (!recentByRegion.has(report.iata)) recentByRegion.set(report.iata, report); + } + for (const region of [...graph.regions].sort((a, b) => Number(b.code === regionCode) - Number(a.code === regionCode) || b.count - a.count)) { + const q = p({ ...region, y: 6 }); + if (!q.visible) continue; + const size = Math.min(11, Math.max(8, region.radius * q.scale / 7)); + ctx!.font = `600 ${size}px Inter, sans-serif`; + ctx!.globalAlpha = region.code === regionCode ? 1 : Math.min(1, Math.max(0, (region.radius * q.scale - 8) / 30)); + if (ctx!.globalAlpha < 0.15) continue; + q.y -= Math.min(35, region.radius * q.scale * 0.6); + if (q.x < -100 || q.x > width + 100 || q.y < -30 || q.y > height + 30) continue; + const label = `${region.code} · ${region.count}`; + const w = ctx!.measureText(label).width + 16; + if (regionHits.some(r => Math.abs(r.x - q.x) < (r.width + w) / 2 + 5 && Math.abs(r.y - q.y) < 29)) continue; + ctx!.fillStyle = colors.bgRaised; ctx!.fillRect(q.x - w / 2, q.y - 13, w, 26); + ctx!.fillStyle = colors.textBright; ctx!.fillText(label, q.x, q.y + 4); + const report = recentByRegion.get(region.code); + if (report) { ctx!.fillStyle = flowColor(report.kind, colors); ctx!.beginPath(); ctx!.arc(q.x - w / 2 - 5, q.y, 3, 0, Math.PI * 2); ctx!.fill(); } + regionHits.push({ code: region.code, x: q.x, y: q.y, width: w }); + } + ctx!.globalAlpha = 1; + const selectedPoint = projected.find(n => n.id === selected); + if (selectedPoint) { + const label = (graph.byId.get(selected)!.name || selected.slice(0, 8)).slice(0, 40); + ctx!.font = "600 12px Inter, sans-serif"; + const w = ctx!.measureText(label).width + 16; + ctx!.fillStyle = colors.bgSurface; ctx!.fillRect(selectedPoint.x - w / 2, selectedPoint.y - 34, w, 22); + ctx!.fillStyle = colors.textBright; ctx!.fillText(label, selectedPoint.x, selectedPoint.y - 19); + } + const placed: {x:number;y:number;w:number;h:number}[] = []; + for (const bubble of bubbles) { + if (placed.length >= (width < 600 ? 1 : 2)) break; + const node = graph.byId.get(bubble.nodeId); + if (!node || (regionCode && node.region !== regionCode)) continue; + const q=p(node); + if (!q.visible || q.x < 8 || q.x > width-8 || q.y < 8 || q.y > height-8) continue; + const w=Math.min(240,width-24), h=76; + const positions=[{x:q.x-w/2,y:q.y-h-25},{x:q.x+20,y:q.y-h-15},{x:q.x-w-20,y:q.y-h-15},{x:q.x-w/2,y:q.y+25}]; + const box=positions.map(pos=>({x:Math.max(8,Math.min(width-w-8,pos.x)),y:Math.max(8,Math.min(height-h-8,pos.y)),w,h})).find(pos=>(q.xpos.x+pos.w+8 || q.ypos.y+pos.h+8) && !placed.some(old=>pos.xold.x && pos.yold.y)); + if (!box) continue; placed.push(box); + const alpha=motion ? Math.min(1,Math.max(0,(CHAT_MS-(Date.now()-bubble.at))/1600)) : 1; + ctx!.globalAlpha=alpha; + ctx!.strokeStyle=colors.secondary;ctx!.lineWidth=1; + const sx=Math.max(box.x,Math.min(box.x+w,q.x)),sy=Math.max(box.y,Math.min(box.y+h,q.y)); + ctx!.beginPath();ctx!.moveTo(sx,sy);ctx!.lineTo(q.x,q.y);ctx!.stroke(); + const angle=Math.atan2(q.y-sy,q.x-sx); + ctx!.beginPath();ctx!.moveTo(q.x,q.y);ctx!.lineTo(q.x-7*Math.cos(angle-.45),q.y-7*Math.sin(angle-.45));ctx!.lineTo(q.x-7*Math.cos(angle+.45),q.y-7*Math.sin(angle+.45));ctx!.closePath();ctx!.fillStyle=colors.secondary;ctx!.fill(); + ctx!.fillStyle=colors.bgSurface;ctx!.fillRect(box.x,box.y,w,h);ctx!.strokeRect(box.x,box.y,w,h); + ctx!.textAlign="left";ctx!.font="600 12px Inter, sans-serif";ctx!.fillStyle=colors.secondary; + ctx!.fillText(bubble.sender,box.x+9,box.y+18,w-18); + ctx!.font="12px Inter, sans-serif";ctx!.fillStyle=colors.textBright; + const chars=Array.from(bubble.content);const lines:string[]=[];let line=""; + for (const char of chars) { if(ctx!.measureText(line+char).width>w-20) { const space=line.lastIndexOf(" "); if(space>0){lines.push(line.slice(0,space));line=line.slice(space+1);} else {lines.push(line);line="";} } line+=char; } + if(line)lines.push(line); + for(let i=0;i2 ? -2 : undefined)+(i===1 && lines.length>2 ? "…" : ""),box.x+9,box.y+39+i*17,w-18); + } + ctx!.globalAlpha=1; + if (flows.length || transition || (bubbles.length && motion)) frame = requestAnimationFrame(draw); + } + + function resize() { + const box = canvas.getBoundingClientRect(); width = Math.max(1, box.width); height = Math.max(1, box.height); + const ratio = Math.min(window.devicePixelRatio || 1, width < 600 ? 1.25 : 1.5); + canvas.width = background.width = Math.round(width * ratio); canvas.height = background.height = Math.round(height * ratio); + ctx!.setTransform(ratio, 0, 0, ratio, 0, 0); + if (!fitted) { fit(false); fitted = true; } else wake(true); + } + function zoom(factor: number) { transition = null; camera = { ...camera, zoom: Math.max(0.2, Math.min(80, camera.zoom * factor)) }; wake(true); } + function pan(dx: number, dy: number) { transition = null; camera = panCamera(camera, dx, dy, width, height, extent); wake(true); } + function down(e: PointerEvent) { + if (e.button > 2) return; + e.preventDefault(); canvas.focus({ preventScroll: true }); + transition = null; start = { x: e.clientX, y: e.clientY }; moved = pointers.size > 0; + panning = e.button !== 2 && !e.shiftKey; + pointers.set(e.pointerId, start); canvas.setPointerCapture(e.pointerId); + } + function move(e: PointerEvent) { + const old = pointers.get(e.pointerId); if (!old) return; + const other = [...pointers].find(([id]) => id !== e.pointerId)?.[1]; + const dx = e.clientX - old.x, dy = e.clientY - old.y; + if (other) { const before = Math.hypot(old.x - other.x, old.y - other.y); const after = Math.hypot(e.clientX - other.x, e.clientY - other.y); if (before > 5) zoom(after / before); pan(dx / 2, dy / 2); moved = true; } + else if (panning) pan(dx, dy); + else { camera.yaw += dx * 0.006; camera.pitch = Math.max(0.35, Math.min(Math.PI / 2, camera.pitch + dy * 0.006)); } + if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 5) moved = true; + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); wake(true); + } + function up(e: PointerEvent) { + if (!pointers.has(e.pointerId)) return; + pointers.delete(e.pointerId); + if (moved || e.type !== "pointerup" || e.button !== 0) return; + const box = canvas.getBoundingClientRect(), x = e.clientX - box.left, y = e.clientY - box.top; + const region = regionHits.find(r => Math.abs(r.x - x) <= r.width / 2 + 6 && Math.abs(r.y - y) <= 20); + if (region) { focus(region.code); return; } + const hit = [...projected].reverse().find(n => Math.hypot(n.x - x, n.y - y) <= 9); + if (hit) select(hit.id); + } + function contextMenu(e: MouseEvent) { e.preventDefault(); } + function wheel(e: WheelEvent) { e.preventDefault(); zoom(Math.exp(-Math.max(-100, Math.min(100, e.deltaY)) * 0.002)); } + function key(e: KeyboardEvent) { + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "Home", "Escape"].includes(e.key)) return; + e.preventDefault(); transition = null; + if (e.key === "Home") fit(); + else if (e.key === "Escape") focus(""); + else if (e.key === "+" || e.key === "=") zoom(1.2); + else if (e.key === "-") zoom(1 / 1.2); + else { + const dx = e.key === "ArrowLeft" ? 30 : e.key === "ArrowRight" ? -30 : 0, dy = e.key === "ArrowUp" ? 30 : e.key === "ArrowDown" ? -30 : 0; + if (e.shiftKey) { camera.yaw -= dx * 0.005; camera.pitch = Math.max(0.35, Math.min(Math.PI / 2, camera.pitch - dy * 0.005)); } + else pan(dx, dy); + } + wake(true); + } + function visibility() { pointers.clear(); if (document.hidden) { cancelAnimationFrame(frame); frame = 0; } else wake(); } + const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); + canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move); + canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); canvas.addEventListener("lostpointercapture", up); + canvas.addEventListener("contextmenu", contextMenu); + canvas.addEventListener("wheel", wheel, { passive: false }); canvas.addEventListener("keydown", key); + document.addEventListener("visibilitychange", visibility); + return { + update(id: string, region: string, paths: PathDisplay, animate: boolean, active: boolean) { + const changeRegion = region !== regionCode; + baseDirty ||= selected !== id || changeRegion || paths !== display || lastTrailVersion !== traffic.trailVersion; + lastTrailVersion = traffic.trailVersion; + selected = id; regionCode = region; display = paths; motion = animate; running = active; + related = new Set([id]); + for (const [a, b] of graph.links) { if (a === id) related.add(b); if (b === id) related.add(a); } + if (!motion && transition) { camera = transition.to; transition = null; baseDirty = true; } + if (changeRegion) fit(); + if (!running) { cancelAnimationFrame(frame); frame = 0; } else wake(); + }, + zoom, fit, angle(top: boolean) { camera = { ...camera, yaw: 0, pitch: top ? Math.PI / 2 : DEFAULT_CAMERA.pitch }; fit(); }, + view(): CameraView { const current = transition?.to ?? camera; return { camera: { ...current, target: { ...current.target } }, region: regionCode, extent }; }, + dispose() { + running = false; cancelAnimationFrame(frame); observer.disconnect(); + canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move); + canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", up); canvas.removeEventListener("lostpointercapture", up); + canvas.removeEventListener("contextmenu", contextMenu); + canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("keydown", key); + document.removeEventListener("visibilitychange", visibility); + }, + }; +} diff --git a/src/features/topology/topology.ts b/src/features/topology/topology.ts new file mode 100644 index 00000000..5ed21dfd --- /dev/null +++ b/src/features/topology/topology.ts @@ -0,0 +1,277 @@ +import type { ChartColors } from "../stats/chartTheme"; +import { getTopologyLinks, getNodesPage } from "../../api/client"; +import type { NodeSummary } from "../nodes/types"; +import type { KnownRoute, ResolvedHop } from "../../types/api"; +import type { WsPacketObservation } from "../../types/ws"; + +export const NODE_CAP = 20_000; +export const LINK_CAP = 100_000; +export const REPORT_CAP = 10_000; +export const FLOW_CAP = 512; +export const ROUTE_CAP = 60_000; +export const ROUTE_WINDOWS = { "15m": 900_000, "1h": 3_600_000, "24h": 86_400_000 } as const; +export const LIVE_WINDOW = 60_000; +export const FLOW_MS = 4200; +export type Point3 = { x: number; y: number; z: number }; +export type TopologyNode = NodeSummary & Point3 & { region: string }; +export type TopologyRegion = Point3 & { code: string; count: number; radius: number }; +export type Topology = { nodes: TopologyNode[]; byId: Map; links: [string, string][]; routeLinks: Set; regions: TopologyRegion[]; bundles: { from: string; to: string; count: number }[]; linksCapped: boolean }; +export type PathDisplay = "bundled" | "all" | "selected"; +export type PacketKind = "advert" | "text" | "trace" | "ack" | "other"; +export type LiveReport = { key: string; hash: string; observerId: string; observerName: string; iata: string; at: number; heardAt: number; kind: PacketKind; scope: string; snr: number | null; segments: [string, string][]; partial: boolean }; + +// Bounded, sequential, cancellable pages; no query per node or live packet. +export async function loadTopology(iatas: string[] | undefined, signal: AbortSignal) { + const nodes = new Map(); + const cursors = new Set(); + let cursor: number | undefined; + for (let page = 0; page < NODE_CAP / 200; page++) { + signal.throwIfAborted(); + const result = await getNodesPage(iatas, { cursor, limit: 200, neighbors: true }, signal); + for (const node of result.items) if (nodes.size < NODE_CAP) nodes.set(node.id, node); + if (!result.hasMore) return { nodes: [...nodes.values()], capped: false }; + if (result.nextCursor == null || cursors.has(result.nextCursor)) break; + cursor = result.nextCursor; + cursors.add(cursor); + } + return { nodes: [...nodes.values()], capped: true }; +} + +export async function loadTopologyRoutes(iatas: string[] | undefined, signal: AbortSignal, window = ROUTE_WINDOWS["15m"] as number) { + signal.throwIfAborted(); + if (iatas?.length === 0) return { routes: [], capped: false }; + const key = Object.entries(ROUTE_WINDOWS).find(([,value])=>value===window)?.[0]; + if (!key) throw new Error("Unsupported topology window"); + const result = await getTopologyLinks(iatas,key,signal); + return {routes:result.links,capped:result.capped}; +} + +export function buildTopology(input: NodeSummary[], selectedIatas: string | readonly string[] = "", routes: (KnownRoute | [string,string])[] = []): Topology { + const allowed = typeof selectedIatas === "string" ? (selectedIatas ? [selectedIatas] : undefined) : selectedIatas; + const groups = new Map(); + const unique = [...new Map(input.slice(0, NODE_CAP).map(n => [n.id, n])).values()]; + for (const node of unique) { + const memberships = allowed ? node.iatas.filter(i => allowed.includes(i.iata)) : node.iatas; + const code = [...memberships].sort((a, b) => b.lastHeard - a.lastHeard || a.iata.localeCompare(b.iata))[0]?.iata || "?"; + if (!groups.has(code)) groups.set(code, []); + groups.get(code)!.push(node); + } + const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b)); + const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length))); + const radii = ordered.map(([, members]) => Math.max(85, 14 * Math.sqrt(members.length) + 28)); + const columnWidths = Array.from({ length: columns }, (_, c) => Math.max(0, ...radii.filter((_, i) => i % columns === c)) * 2 + 120); + const rowHeights = Array.from({ length: Math.ceil(ordered.length / columns) }, (_, r) => Math.max(...radii.slice(r * columns, (r + 1) * columns)) * 2 + 120); + const centerAt = (sizes: number[], index: number) => sizes.slice(0, index).reduce((a, b) => a + b, 0) + sizes[index]! / 2 - sizes.reduce((a, b) => a + b, 0) / 2; + const nodes: TopologyNode[] = []; + const regions: Topology["regions"] = []; + ordered.forEach(([code, members], regionIndex) => { + const center = { x: centerAt(columnWidths, regionIndex % columns), y: 0, z: centerAt(rowHeights, Math.floor(regionIndex / columns)) }; + regions.push({ ...center, radius: radii[regionIndex]!, code, count: members.length }); + members.sort((a, b) => b.knownNeighborCount - a.knownNeighborCount || a.id.localeCompare(b.id)); + members.forEach((node, i) => { + // A sunflower disc gives every identity space; shallow height keeps the 3D + // view readable instead of projecting a dense sphere onto itself. + const radius = 14 * Math.sqrt(i); + const angle = i * Math.PI * (3 - Math.sqrt(5)); + nodes.push({ ...node, region: code, x: center.x + Math.cos(angle) * radius, y: node.isObserver ? 28 : 4 + Math.min(16, node.knownNeighborCount * 2), z: center.z + Math.sin(angle) * radius }); + }); + }); + const byId = new Map(nodes.map(n => [n.id, n])); + const links: [string, string][] = []; + const routeLinks = new Set(); + const seen = new Set(); + let linksCapped = false; + for (const node of nodes) for (const other of node.neighborIds ?? []) { + if (node.id === other || !byId.has(other)) continue; + const pair = [node.id, other].sort() as [string, string]; + const key = pair.join("|"); + if (seen.has(key)) continue; + if (links.length >= LINK_CAP) { linksCapped = true; break; } + seen.add(key); + links.push(pair); + } + const routePairs: [string,string][] = routes.flatMap(route => Array.isArray(route) ? [route] : route.hops.slice(1).map((hop,i) => [route.hops[i]!.nodeId,hop.nodeId] as [string,string])); + for (const [a,b] of routePairs) { + // Never remove a missing hop and connect the identities on either side. + if (a === b || !byId.has(a) || !byId.has(b)) continue; + const pair = [a, b].sort() as [string, string], key = pair.join("|"); + if (!seen.has(key)) { + if (links.length >= LINK_CAP) { linksCapped = true; continue; } + seen.add(key); links.push(pair); + } + routeLinks.add(key); + } + const bundles = new Map(); + for (const [a, b] of links) { + const [from, to] = [byId.get(a)!.region, byId.get(b)!.region].sort(); + if (from === to) continue; + const key = `${from}|${to}`, old = bundles.get(key); + if (old) old.count++; else bundles.set(key, { from: from!, to: to!, count: 1 }); + } + // Layout runs only when the bounded snapshot changes, never on animation frames. + // Springs use actual neighbour edges; no scope or geographic inference creates links. + const regionIndex = new Map(regions.map((r, i) => [r.code, i])); + const oldCenters = regions.map(r => ({ x: r.x, z: r.z })); + relaxPositions(regions, regions.map(r => r.radius), [...bundles.values()].map(b => [regionIndex.get(b.from)!, regionIndex.get(b.to)!, b.count]), 90, 100); + for (const node of nodes) { + const index = regionIndex.get(node.region)!; + node.x += regions[index]!.x - oldCenters[index]!.x; + node.z += regions[index]!.z - oldCenters[index]!.z; + } + const local = new Map(regions.map(r => [r.code, { nodes: [] as TopologyNode[], edges: [] as [number, number, number][] }])); + const indices = new Map(); + for (const node of nodes) { const group = local.get(node.region)!; indices.set(node.id, group.nodes.length); group.nodes.push(node); } + for (const [a, b] of links) if (byId.get(a)!.region === byId.get(b)!.region) local.get(byId.get(a)!.region)!.edges.push([indices.get(a)!, indices.get(b)!, 1]); + for (const region of regions) { const group = local.get(region.code)!; relaxPositions(group.nodes, group.nodes.map(() => 7), group.edges, 5, 32, region); } + return { nodes, byId, links, routeLinks, regions, bundles: [...bundles.values()], linksCapped }; +} + +function relaxPositions(points: Point3[], radii: number[], edges: [number, number, number][], gap: number, iterations: number, boundary?: TopologyRegion) { + if (points.length < 2 || !edges.length) return; + const cellSize = Math.max(...radii) * 2 + gap; + for (let pass = 0; pass < iterations; pass++) { + if (!boundary && pass < iterations - 8) for (const p of points) { p.x *= 0.982; p.z *= 0.982; } + if (pass < iterations - 8) for (const [ai, bi, weight] of edges) { + const a = points[ai]!, b = points[bi]!, dx = b.x - a.x, dz = b.z - a.z; + const distance = Math.hypot(dx, dz) || 1; + const rest = radii[ai]! + radii[bi]! + gap * (1.2 + 1 / Math.sqrt(weight)); + const force = Math.max(-8, Math.min(8, (distance - rest) * 0.035)); + a.x += dx / distance * force; a.z += dz / distance * force; + b.x -= dx / distance * force; b.z -= dz / distance * force; + } + // Spatial buckets avoid an all-pairs collision pass on the expanded view. + const cells = new Map(); + points.forEach((p, i) => { + const cx = Math.floor(p.x / cellSize), cz = Math.floor(p.z / cellSize); + for (let x = cx - 1; x <= cx + 1; x++) for (let z = cz - 1; z <= cz + 1; z++) for (const j of cells.get(`${x}|${z}`) ?? []) { + const q = points[j]!, dx = p.x - q.x || 0.001, dz = p.z - q.z; + const distance = Math.hypot(dx, dz), minimum = radii[i]! + radii[j]! + gap; + if (distance >= minimum) continue; + const push = (minimum - distance) * 0.51; + p.x += dx / distance * push; p.z += dz / distance * push; + q.x -= dx / distance * push; q.z -= dz / distance * push; + } + const key = `${Math.floor(p.x / cellSize)}|${Math.floor(p.z / cellSize)}`; + if (!cells.has(key)) cells.set(key, []); + cells.get(key)!.push(i); + if (boundary) { + const dx = p.x - boundary.x, dz = p.z - boundary.z, distance = Math.hypot(dx, dz), limit = boundary.radius - 20; + if (distance > limit) { p.x = boundary.x + dx / distance * limit; p.z = boundary.z + dz / distance * limit; } + } + }); + } +} + +export function packetKind(type: number): PacketKind { + return type === 4 ? "advert" : type === 2 || type === 5 ? "text" : type === 9 ? "trace" : type === 3 ? "ack" : "other"; +} + +// Only adjacent, single-candidate, high-confidence hops become an animated segment. +// Keep gaps in place: filtering unknown hops first would fabricate a radio link. +export function reportFromEvent(event: WsPacketObservation["data"], graph: Topology, at: number): LiveReport | null { + const obs = event?.observation; + if (!obs || !event.packet || event.packet.isRepeat || typeof event.packetHash !== "string" || !event.packetHash || typeof obs.observerId !== "string" || !obs.observerId || typeof obs.iata !== "string") return null; + const path = obs.resolvedPath; + const hops: (ResolvedHop | null | undefined)[] = Array.isArray(path) ? [obs.resolvedSource, ...path.slice(0, 64), obs.resolvedDestination] : []; + const ids = hops.map(h => h?.confidence === "high" && h.nodes?.length === 1 && graph.byId.has(h.nodes[0]!.id) ? h.nodes[0]!.id : null); + const segments: [string, string][] = []; + for (let i = 1; i < ids.length; i++) { + const a = ids[i - 1], b = ids[i]; + if (a && b && a !== b) segments.push([a, b]); + } + return { key: `${event.packetHash}|${obs.observerId}`, hash: event.packetHash, observerId: obs.observerId, observerName: typeof obs.observerName === "string" ? obs.observerName : obs.observerId.slice(0, 8), iata: obs.iata, at, heardAt: obs.heardAt, kind: packetKind(event.packet.payloadType), scope: typeof event.packet.scope === "string" ? event.packet.scope : "", snr: Number.isFinite(obs.snr) ? obs.snr : null, segments, partial: !Array.isArray(path) || path.length > 64 || hops.some(h => h != null && (h.confidence !== "high" || h.nodes?.length !== 1 || !graph.byId.has(h.nodes[0]!.id))) }; +} + +export class LiveTraffic { + reports: LiveReport[] = []; + flows: LiveReport[] = []; + trails = new Map(); + trailVersion = 0; + cappedUntil = 0; + gapUntil = 0; + markGap() { this.gapUntil = Date.now() + LIVE_WINDOW; } + private seen = new Map(); + add(report: LiveReport) { + this.prune(report.at); + if (this.seen.has(report.key)) return false; + this.seen.set(report.key, report.at); + if (this.seen.size > REPORT_CAP * 2) this.seen.delete(this.seen.keys().next().value!); + this.reports.push(report); + for (const [from, to] of report.segments) { + const key = `${from}|${to}`, old = this.trails.get(key); + if (!old || old.kind !== report.kind) this.trailVersion++; + this.trails.delete(key); this.trails.set(key, { from, to, kind: report.kind, at: report.at }); + if (this.trails.size > LINK_CAP) { this.trails.delete(this.trails.keys().next().value!); this.cappedUntil = report.at + LIVE_WINDOW; this.trailVersion++; } + } + if (this.reports.length > REPORT_CAP) { this.reports.shift(); this.cappedUntil = report.at + LIVE_WINDOW; } + if (report.segments.length) { + if (this.flows.length >= FLOW_CAP) this.cappedUntil = report.at + LIVE_WINDOW; + this.flows = [...this.flows.slice(-(FLOW_CAP - 1)), report]; + } + return true; + } + clearFlows() { this.flows = []; } + prune(now: number) { + this.reports = this.reports.filter(r => now - r.at < LIVE_WINDOW); + this.flows = this.flows.filter(r => now - r.at < FLOW_MS); + for (const [key, at] of this.seen) { if (now - at < LIVE_WINDOW) break; this.seen.delete(key); } + for (const [key, trail] of this.trails) { if (now - trail.at < LIVE_WINDOW) break; this.trails.delete(key); this.trailVersion++; } + } +} + +export type Camera = { yaw: number; pitch: number; zoom: number; target: Point3 }; +export const DEFAULT_CAMERA: Camera = { yaw: 0, pitch: 1.05, zoom: 1, target: { x: 0, y: 0, z: 0 } }; +export function project(point: Point3, camera: Camera, width: number, height: number, extent: number) { + const local = { x: point.x - camera.target.x, y: point.y - camera.target.y, z: point.z - camera.target.z }; + const x = local.x * Math.cos(camera.yaw) - local.z * Math.sin(camera.yaw); + const z = local.x * Math.sin(camera.yaw) + local.z * Math.cos(camera.yaw); + // Positive pitch puts the camera above the ground: raised nodes are closer. + const y = local.y * Math.cos(camera.pitch) + z * Math.sin(camera.pitch); + const depth = -local.y * Math.sin(camera.pitch) + z * Math.cos(camera.pitch); + const perspective = 3 * extent / (3 * extent + depth); + const scale = Math.min(width, height) / (extent * 2.6) * camera.zoom * perspective; + return { x: width / 2 + x * scale, y: height / 2 - y * scale, depth, scale, visible: perspective > 0 }; +} + +export function fitCamera(points: Point3[], camera: Camera, width: number, height: number, extent: number): Camera { + if (!points.length) return { ...DEFAULT_CAMERA, target: { ...DEFAULT_CAMERA.target } }; + const target = { x: 0, y: 0, z: 0 }; + for (const axis of ["x", "y", "z"] as const) target[axis] = (Math.min(...points.map(p => p[axis])) + Math.max(...points.map(p => p[axis]))) / 2; + let base = { ...camera, target, zoom: 1 }; + // Perspective can shift the projected centre away from the world-space centre. + // Recentre before scaling so Fit does not leave the mesh against one edge. + for (let pass = 0; pass < 2; pass++) { + const view = points.map(point => project(point, base, width, height, extent)); + const cx = (Math.min(...view.map(p => p.x)) + Math.max(...view.map(p => p.x))) / 2; + const cy = (Math.min(...view.map(p => p.y)) + Math.max(...view.map(p => p.y))) / 2; + base = panCamera(base, width / 2 - cx, height / 2 - cy, width, height, extent); + } + const projected = points.map(point => project(point, base, width, height, extent)); + const dx = Math.max(1, ...projected.map(p => Math.abs(p.x - width / 2))); + const dy = Math.max(1, ...projected.map(p => Math.abs(p.y - height / 2))); + return { ...base, zoom: Math.max(0.2, Math.min(80, Math.max(60, width - 100) / (2 * dx), Math.max(60, height - 100) / (2 * dy))) }; +} + +export function panCamera(camera: Camera, dx: number, dy: number, width: number, height: number, extent: number): Camera { + const scale = Math.min(width, height) / (extent * 2.6) * camera.zoom; + const x = -dx / scale, z = dy / (scale * Math.sin(camera.pitch)); + return { ...camera, target: { x: camera.target.x + x * Math.cos(camera.yaw) + z * Math.sin(camera.yaw), y: camera.target.y, z: camera.target.z - x * Math.sin(camera.yaw) + z * Math.cos(camera.yaw) } }; +} + +export function pathControls(a: Point3, b: Point3, crossRegion: boolean): [Point3, Point3, Point3, Point3] { + const lift = Math.max(a.y, b.y) + (crossRegion ? 100 + Math.hypot(a.x - b.x, a.z - b.z) * 0.24 : 18); + return [a, { ...a, y: lift }, { ...b, y: lift }, b]; +} + +export function visibleLinks(graph: Topology, display: PathDisplay, region: string, selected: string) { + return graph.links.filter(([a, b]) => display === "all" || a === selected || b === selected || + (display === "bundled" && !!region && graph.byId.get(a)!.region === region && graph.byId.get(b)!.region === region)); +} + +export function flowColor(kind: PacketKind, c: ChartColors) { + return { advert: c.green, text: c.secondary, trace: c.warn, ack: c.primary, other: c.textNormal }[kind]; +} + +export function linkContext(a: TopologyNode, b: TopologyNode) { + return { crossRegion: a.region !== b.region, sharedDefault: a.defaultScope && a.defaultScope === b.defaultScope ? a.defaultScope : null }; +} diff --git a/src/features/traces/TraceDetailPanel.tsx b/src/features/traces/TraceDetailPanel.tsx index 114119b4..bc5d84f2 100644 --- a/src/features/traces/TraceDetailPanel.tsx +++ b/src/features/traces/TraceDetailPanel.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { TraceQualityNotice } from "./TraceQualityNotice"; import { getTraceDetail } from "../../api/client"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { Badge } from "../../components/Badge"; @@ -66,6 +67,7 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: { } /> } />
      +
      diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index b1d6765b..39ff75a9 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -13,9 +13,12 @@ import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters" import { TraceDetailPanel } from "./TraceDetailPanel"; import type { TraceTagSummary, TraceType } from "../../types/api"; -// Traces are modest in number and the list isn't streamed, so a single region-filtered fetch covers -// the card list (the /traces cursor is sound if pagination is ever needed). +import { TraceQualityNotice } from "./TraceQualityNotice"; +import { isQuestionable } from "./trace-quality"; + +// A bounded sample; the hidden count refers only to these loaded tags. const TRACE_LIST_LIMIT = 200; +const TRACE_PATH_PREVIEW_LIMIT = 6; interface TraceListProps { onAnalyze: (hash: string | null) => void; @@ -24,18 +27,20 @@ interface TraceListProps { // The list now carries the most complete observation's path, so we can show the hops (and the SNR we // heard on each) right on the card instead of making people open the detail panel for a quick look. -function TracePathPreview({ hashes, snrs }: { hashes: string[]; snrs: number[] }) { +function TracePathPreview({ hashes, snrs, expanded }: { hashes: string[]; snrs: number[]; expanded: boolean }) { + const shownHashes = expanded ? hashes : hashes.slice(0, TRACE_PATH_PREVIEW_LIMIT); + const hiddenCount = hashes.length - shownHashes.length; return ( -
      - {hashes.map((hash, i) => { +
      + {shownHashes.map((hash, i) => { const snr = snrs?.[i]; const level = snr != null ? snrLevel(snr) : null; const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; return ( - + {i > 0 && →} - - + + {hash.toUpperCase()} {/* keep a sub-line on every hop (SNR or a placeholder) so the badges across the row line up */} @@ -48,6 +53,9 @@ function TracePathPreview({ hashes, snrs }: { hashes: string[]; snrs: number[] } ); })} + {hiddenCount > 0 && ( + … +{hiddenCount} + )}
      ); } @@ -56,34 +64,39 @@ function TracePathPreview({ hashes, snrs }: { hashes: string[]; snrs: number[] } function TraceTagCard({ tag, selected, onSelect }: { tag: TraceTagSummary; selected: boolean; - onSelect: (tag: string) => void; + onSelect: (tag: string | null) => void; }) { const { t } = useTranslation(); return (
      onSelect(tag.traceTag)} + onClick={() => onSelect(selected ? null : tag.traceTag)} aria-pressed={selected} + aria-expanded={selected} tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onSelect(tag.traceTag); + onSelect(selected ? null : tag.traceTag); } }} > -
      +
      + + {selected ? "▾" : "▸"} + {tag.traceTag.toUpperCase()} {/* pings get the primary tint, traces the amber one, so the two read apart at a glance */} {tag.traceType && {tag.traceType}} + {t("traces.pkt", { count: tag.packetCount })} · {t("traces.iata", { count: tag.iataCount })} · {t("traces.hops", { count: tag.pathHashes?.length ?? 0 })} + +
      -
      - {t("traces.pkt", { count: tag.packetCount })} · {t("traces.iata", { count: tag.iataCount })} -
      - {tag.pathHashes?.length ? : null} + {tag.pathHashes?.length ? : null}
      ); } @@ -97,6 +110,8 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { { value: "TRACE", label: t("traces.trace") }, { value: "PING", label: t("traces.ping") }, ], [t]); + + const [showQuestionable, setShowQuestionable] = useState(false); const [selectedTag, setSelectedTag] = useState(null); const [typeFilter, setTypeFilter] = useState<"" | TraceType>(""); @@ -116,13 +131,22 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { enabled: isResolved !== false, }); + const questionableCount = tags?.filter((tag) => isQuestionable(tag.quality)).length ?? 0; + const visibleTags = tags?.filter((tag) => showQuestionable || !isQuestionable(tag.quality)); + const selectedVisible = visibleTags?.some((tag) => tag.traceTag === selectedTag); + return ( -
      +
      -
      +
      - {tags ? t("traces.tagCount", { count: tags.length }) : ""} + {tags ? t("traces.tagCount", { count: visibleTags?.length ?? 0 }) : ""} + {questionableCount > 0 && !showQuestionable && <> · {t("traces.quality.hidden", { count: questionableCount })}} +
      -
      +
      {isLoading || isResolved === false ? ( - ) : (tags?.length ?? 0) === 0 ? ( + ) : (visibleTags?.length ?? 0) === 0 ? ( ) : ( - tags!.map((tag) => ( + visibleTags!.map((tag) => ( )) )}
      - {selectedTag && ( + {selectedTag && selectedVisible && ( setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} /> )}
      diff --git a/src/features/traces/TraceQualityNotice.tsx b/src/features/traces/TraceQualityNotice.tsx new file mode 100644 index 00000000..a71ecb3f --- /dev/null +++ b/src/features/traces/TraceQualityNotice.tsx @@ -0,0 +1,13 @@ +import { isQuestionable } from "./trace-quality"; +import { useTranslation } from "react-i18next"; +import type { TraceQuality } from "../../types/api"; + +export function TraceQualityNotice({ quality, compact = false }: { quality?: TraceQuality; compact?: boolean }) { + const { t } = useTranslation(); + if (!isQuestionable(quality)) return null; + const reasons = quality!.reasons.map((reason) => t(`traces.quality.${reason}`, { defaultValue: reason })).join(" · "); + return
      + {t(`traces.quality.${quality!.status}`)} + {!compact && <>
      {reasons}
      {t("traces.quality.evidenceOnly")}
      } +
      ; +} diff --git a/src/features/traces/trace-quality.ts b/src/features/traces/trace-quality.ts new file mode 100644 index 00000000..ff203136 --- /dev/null +++ b/src/features/traces/trace-quality.ts @@ -0,0 +1,5 @@ +import type { TraceQuality } from "../../types/api"; + +export function isQuestionable(quality?: TraceQuality): boolean { + return quality?.status === "suspect" || quality?.status === "ambiguous"; +} diff --git a/src/hooks/region-selection.ts b/src/hooks/region-selection.ts index befefd19..17dbfe77 100644 --- a/src/hooks/region-selection.ts +++ b/src/hooks/region-selection.ts @@ -62,6 +62,11 @@ export function parseSelection(params: URLSearchParams): RegionSelection { const iatas = splitCsv(params.get("iata")).map((c) => c.toUpperCase()); const legacy = params.get("region")?.trim(); if (legacy) iatas.push(legacy.toUpperCase()); + // Old Topology links join the shared selector once; explicit shared filters win. + const topology = params.get("topoRegion"); + if (params.get("tab") === "Topology" && !["iata", "regions", "region"].some(key => params.has(key)) && topology && /^[A-Za-z0-9_-]{1,16}$/.test(topology)) { + iatas.push(topology.toUpperCase()); + } return { regions, iatas }; } @@ -74,6 +79,7 @@ export function selectionToParams(selection: RegionSelection, base: URLSearchPar if (selection.regions.length > 0) next.set("regions", selection.regions.join(",")); else next.delete("regions"); next.delete("region"); + next.delete("topoRegion"); return next; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 614c8e61..7e541298 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,9 @@ "Observers": "Observers", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analytics" + "Analytics": "Analytics", + "MyAtlas": "My Atlas", + "Topology": "Topology" }, "navigation": { "primary": "Primary", @@ -506,7 +508,9 @@ "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" + "inspect": "Inspect packet", + "invalidPath": "Invalid shared path. Open the route again to use its current representation.", + "invalid": "Invalid shared time window. Choose a range to continue." }, "channelMessages": { "select": "Select a channel", @@ -534,7 +538,8 @@ }, "header": { "settings": "Settings", - "github": "GitHub" + "github": "GitHub", + "changelog": "Changelog" }, "channels": { "noMatches": "No matching channels loaded.", @@ -593,7 +598,9 @@ "zoomIn": "Zoom in", "zoomOut": "Zoom out", "closePopup": "Close popup" - } + }, + "liveNodes": "Live node icons", + "dimLiveNodes": "Dim inactive nodes during live traffic" }, "nodeFilters": { "label": "Node filters", @@ -735,6 +742,23 @@ "lastSeen": "Last seen" }, "traces": { + "quality": { + "snrBytes": "Recorded SNR bytes", + "show": "Show possible collision related observations", + "hidden": "{{count}} hidden in loaded sample", + "suspect": "Suspect observation", + "ambiguous": "Possible prefix collision", + "evidenceOnly": "Raw evidence is retained. This does not establish a real route or identify a confirmed collision.", + "unsupported_version": "Unsupported packet version", + "non_direct_trace": "TRACE does not support flood routing", + "incomplete_payload": "Incomplete trace payload", + "payload_too_large": "Payload exceeds the protocol limit", + "unsupported_flags": "Unrecognized trace flags", + "misaligned_hashes": "Hash bytes do not match the declared width", + "too_many_hops": "Route exceeds the supported trace hop count", + "invalid_snr_path": "SNR path cannot match this trace", + "ambiguous_prefix": "A hop matches multiple known nodes" + }, "packets": "Packets", "packetCount_one": "{{count}} packet", "packetCount_other": "{{count}} packets", @@ -755,7 +779,8 @@ "all": "All", "trace": "Trace", "ping": "Ping", - "empty": "No traces" + "empty": "No traces", + "hops": "{{count}} hops" }, "loadingPill": { "nodes": { @@ -841,6 +866,7 @@ "scope": "Scope" }, "charts": { + "gapLegend": "Solid lines and dots show readings. Dashed lines estimate between missing samples.", "uniquePackets": "Unique packets", "busy": "Busy" }, @@ -903,6 +929,233 @@ }, "units": { "perDay": "{{value}}/d" + }, + "atlas": { + "neighborSource": "Neighbours inferred from received packets; not a polled repeater table.", + "subtitle": "Your nodes, at a glance.", + "range": "Activity window", + "ranges": { + "24h": "24 hours", + "3d": "3 days" + }, + "addNodes": "Add nodes", + "searchBy": "Search", + "name": "Name", + "publicKey": "Public key", + "search": "Find a node to save", + "namePlaceholder": "Search by node name…", + "keyPlaceholder": "Public key or hex prefix…", + "savedCount": "{{count}} / {{limit}} saved", + "searchHelp": "Search all regions. Enter at least two characters; a public key uses hexadecimal characters.", + "limitReached": "All {{limit}} card spaces are in use. Remove a card to add another.", + "noMatches": "No matching nodes. Try a different name or key.", + "add": "+ Add", + "added": "Saved", + "addName": "Add {{name}}", + "addedName": "{{name}} is saved", + "refine": "Showing the first eight matches. Refine your search to find more.", + "localOnly": "Saved in this browser · All regions", + "storageFailed": "Browser saving is unavailable. Your changes work for this visit but may not be kept.", + "emptyTitle": "Build your own view of the mesh", + "emptyDescription": "Find a repeater, companion or other node above and add a card. Your selection and card order will be here when you return.", + "roles": { + "0": "Unknown", + "1": "Companion", + "2": "Repeater", + "3": "Room", + "4": "Sensor" + }, + "unavailable": "Unavailable", + "recent": "Recently seen", + "stale": "Not seen recently", + "freshnessUnknown": "Freshness unknown", + "refreshFailed": "Refresh failed; showing the last loaded sample.", + "nodeUnavailable": "This saved node could not be loaded. It may have expired or the server may be unavailable.", + "retry": "Retry", + "activity": "Received activity", + "reports": "Received reports", + "activityLabel": "{{count}} received reports in the loaded {{range}} sample. Hourly values are available under Statistics.", + "now": "Now", + "sample": "Loaded sample · up to {{limit}} recent reports", + "snr": "Mean SNR", + "rssi": "Mean RSSI", + "noReports": "No retained reports in this window. This does not establish whether the node is offline.", + "heardBy": "Heard by", + "openNode": "Open node", + "latestAdvert": "Observers of the latest loaded packet", + "heardUnavailable": "The packet details are unavailable or have expired. Try another retained packet from the node profile.", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "openAdvert": "Open packet and observed paths", + "statistics": "Statistics & chart values", + "definition": "Each report is one stored packet reception originating from this full node key, using server deduplication. These are sampled reports, not a total of all node traffic. Blank bars mean no loaded reports; older raw history may have expired.", + "signalDefinition": "Signal meters average available last-hop measurements at the receiving observers. They do not measure the originating node’s radio health. The hourly bars use a rolling window, ending now.", + "capped": "More reports exist than this card loads. Counts and averages cover only the loaded sample.", + "hops": "Mean hops", + "neighbors": "Known neighbours", + "radio": "Radio settings", + "scope": "Default scope", + "chartValues": "Hourly values", + "hour": "Hour starting", + "updated": "Sample loaded", + "manage": "Manage card", + "earlier": "Move earlier", + "later": "Move later", + "remove": "Remove card" + }, + "topology": { + "publicChatter": "Public chatter", + "chatterHint": "Live Public messages point to the earliest mapped receiver reported in this view. Sender names are message text, not verified identities.", + "title": "Topology", + "eyebrow": "LIVE TOPOLOGY / 3D", + "canvas": "3D topology viewed from above. Left-drag pans; right-drag or Shift-left-drag rotates. Mouse wheel zooms. Arrow keys pan, Shift-arrows rotate, plus and minus zoom, Home fits the view and Escape returns to the mesh. Use Find for keyboard selection.", + "projection": "REGIONAL TOPOLOGY · 3D", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "fit": "Fit view", + "controls": "Click a region to focus · drag to rotate · Shift-drag to pan · pinch or scroll to zoom", + "cameraRegion": "Camera focus", + "overview": "Mesh overview", + "isolate": "Isolate {{region}}", + "showAllRegions": "Show all regions", + "linkView": "Path display", + "allPaths": "All paths", + "bundledPaths": "Region bundles", + "selectedPaths": "Selected node", + "orbit": "Rotate", + "pan": "Pan", + "topView": "Top view", + "paused": "Paused", + "live": "Live", + "reconnecting": "Connecting…", + "region": "Region", + "allRegions": "All regions", + "resume": "Resume", + "pause": "Pause", + "copy": "Copy link", + "copied": "Copied", + "nodes": "Loaded nodes", + "links": "Connections", + "reports": "Reports · last 60s in this view", + "observers": "Reporting observers · last 60s", + "loadError": "Topology could not be loaded.", + "refresh": "Refresh topology", + "loading": "Loading topology…", + "bounded": "A display limit was reached: 20,000 nodes, 60,000 recent routes, 100,000 links or live segments, 10,000 recent reports, or 512 simultaneous animations. Isolate a region for more local detail.", + "gap": "Live coverage has a gap after pausing, backgrounding or reconnecting.", + "observerRing": "Ring = observer", + "reducedMotion": "Reduced motion", + "motion": "Animate packets", + "mix": "Live packet mix · 60s", + "paths": "{{count}} with drawable segments", + "find": "Find", + "search": "Name or public key", + "noNodes": "No matching nodes in the loaded view.", + "neighbours": "{{count}} connections in this view", + "nodeDetails": "Node details", + "observerDetails": "Observer", + "clear": "Clear focus", + "reporting": "Reporting observers", + "listening": "Listening for packet reports…", + "latest": "Latest packet reports", + "partialPath": "Partial path", + "path": "Resolved path", + "noPath": "No drawable path", + "about": "View details", + "definition": "Regions are arranged by their neighbour and recent route connections, with more space for larger groups. Nodes use their most recently heard IATA; isolating an IATA includes all loaded nodes heard there. This is a connection diagram, not geography. All paths shows every loaded neighbour link, resolved route segment heard within the selected route window, and 60 seconds of observed path trails. Region bundles combines links between each pair of regions; its numbers count distinct loaded connections. Live animations remain enabled in every path display. Only adjacent hops resolved to one known identity form observed segments; gaps and ambiguous prefixes are never joined. Curves and motion illustrate connections, not physical altitude, radio travel time or delivery. Live counts are packet/observer pairs received in this browser during the last 60 seconds, deduplicated within that window; hidden or paused time is not backfilled. The finder searches loaded nodes. Refresh updates the snapshot.", + "types": { + "companion": "Companion", + "repeater": "Repeater", + "room_server": "Room", + "sensor": "Sensor" + }, + "kinds": { + "advert": "Advert", + "text": "Text", + "trace": "Trace", + "ack": "ACK", + "other": "Other" + }, + "crossRegion": "Between IATAs", + "sharedDefault": "Same default scope", + "defaultScope": "Advertised default scope", + "catalogueFresh": "Cached regional catalogue", + "catalogueStale": "Stale or unavailable source check", + "catalogueCounts": "{{count}} / {{total}} public repeaters carry a scope", + "catalogueChecked": "Last checked", + "catalogueError": "MeshMapper metadata is unavailable.", + "noCatalogue": "No MeshMapper source configured for this IATA.", + "catalogueMeaning": "Regional counts only; not proof that these neighbours forward each scope. ◉ = monitored, including zero-count names.", + "cataloguedLink": "Listed in a regional MeshMapper catalogue; not per-link forwarding evidence.", + "neighbourLimit": "Showing the first 12 of {{count}}.", + "loadingRoutes": "Loading observed routes…", + "routesError": "Observed routes could not be loaded. Neighbours and live traffic are still available.", + "routeSegments": "Observed routes", + "routeWindow": "Route history", + "windows": { + "15m": "Routes · 15m", + "1h": "Routes · 1h", + "24h": "Routes · 24h" + }, + "expand": "Full screen", + "collapse": "Exit full screen", + "display": "Display", + "threeDView": "3D view", + "dragAction": "Drag to", + "inspector": "Node details", + "close": "Close", + "hideInspector": "Close", + "activity": "Live activity", + "activityCounts": "Reports {{count}} · Observers {{observers}} · 60s", + "incomplete": "Gaps", + "controlsPan": "Left-drag to pan. Right-drag or Shift-drag to orbit. Scroll to zoom. Arrow keys pan; Shift+arrows orbit. Home fits the view." + }, + "nodePage": { + "loadError": "Node details could not be loaded.", + "title": "Node details", + "back": "Nodes", + "open": "Open full detail ↗", + "inspect": "Inspect node", + "observer": "Observer dashboard", + "telemetry": "Node telemetry", + "telemetry24h": "Reported telemetry · 24 hours", + "packetTypes": "Packet types", + "regions": "Heard in areas", + "clock": "Clock drift", + "firstSeen": "First seen", + "neighbors": "Neighbours · {{count}}" + }, + "nodeTelemetry": { + "shortTemperature": "Temp.", + "noData": "No telemetry received", + "more": "More telemetry ({{count}})", + "loadError": "Repeater telemetry could not be loaded.", + "collected": "Repeater telemetry · last 30 days", + "collector": "Collector {{key}}", + "channel": "Ch {{channel}}", + "limit": "Latest 24 measurement series shown.", + "metrics": { + "batteryMv": "Battery", + "uptimeSeconds": "Uptime", + "lastRssiDbm": "Last RSSI", + "lastSnrDb": "Last SNR", + "noiseFloorDbm": "Noise floor", + "queueLength": "Queue", + "receivedPackets": "Received packets", + "sentPackets": "Sent packets", + "voltage": "Voltage", + "temperature": "Temperature", + "humidity": "Humidity", + "relative_humidity": "Humidity", + "barometer": "Pressure", + "pressure": "Pressure", + "barometric_pressure": "Pressure", + "current": "Current", + "power": "Power", + "illuminance": "Light", + "luminosity": "Light" + }, + "addCard": "Add repeater telemetry card" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index fc293881..0600aaca 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -13,7 +13,9 @@ "Observers": "Observateurs", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analyses" + "Analytics": "Analyses", + "MyAtlas": "Mon Atlas", + "Topology": "Topologie" }, "navigation": { "primary": "Navigation principale", @@ -520,7 +522,9 @@ "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" + "inspect": "Examiner le paquet", + "invalidPath": "Chemin partagé invalide. Rouvrez le trajet pour utiliser sa représentation actuelle.", + "invalid": "Période partagée invalide. Choisissez une période pour continuer." }, "channelMessages": { "select": "Choisir un canal", @@ -549,7 +553,8 @@ }, "header": { "settings": "Réglages", - "github": "GitHub" + "github": "GitHub", + "changelog": "Journal des modifications" }, "channels": { "noMatches": "Aucun canal correspondant chargé.", @@ -608,7 +613,9 @@ "zoomIn": "Zoom avant", "zoomOut": "Zoom arrière", "closePopup": "Fermer la fenêtre" - } + }, + "liveNodes": "Icônes des nœuds en direct", + "dimLiveNodes": "Atténuer les nœuds inactifs pendant le trafic en direct" }, "nodeFilters": { "label": "Filtres des nœuds", @@ -753,6 +760,23 @@ "lastSeen": "Dernière réception" }, "traces": { + "quality": { + "snrBytes": "Octets SNR enregistrés", + "show": "Afficher les observations possiblement liées à une collision", + "hidden": "{{count}} masquées dans cet échantillon", + "suspect": "Observation suspecte", + "ambiguous": "Collision de préfixe possible", + "evidenceOnly": "Les données brutes sont conservées. Elles ne prouvent ni un trajet réel ni une collision confirmée.", + "unsupported_version": "Version de paquet non prise en charge", + "non_direct_trace": "TRACE ne prend pas en charge la diffusion", + "incomplete_payload": "Charge utile de trace incomplète", + "payload_too_large": "Charge utile supérieure à la limite du protocole", + "unsupported_flags": "Indicateurs de trace non reconnus", + "misaligned_hashes": "Octets incompatibles avec la largeur annoncée", + "too_many_hops": "Trajet dépassant le nombre de sauts pris en charge", + "invalid_snr_path": "Le chemin SNR ne correspond pas à cette trace", + "ambiguous_prefix": "Un saut correspond à plusieurs nœuds connus" + }, "packets": "Paquets", "packetCount_one": "{{count}} paquet", "packetCount_other": "{{count}} paquets", @@ -777,7 +801,8 @@ "all": "Tous", "trace": "Trace", "ping": "Ping", - "empty": "Aucune trace" + "empty": "Aucune trace", + "hops": "{{count}} sauts" }, "loadingPill": { "nodes": { @@ -875,6 +900,7 @@ "scope": "Portée" }, "charts": { + "gapLegend": "Traits pleins et points : mesures. Tirets : estimation entre des données manquantes.", "uniquePackets": "Paquets uniques", "busy": "Occupation" }, @@ -939,6 +965,233 @@ }, "units": { "perDay": "{{value}}/j" + }, + "atlas": { + "neighborSource": "Voisins déduits des paquets reçus ; pas une table interrogée sur le répéteur.", + "subtitle": "Vos nœuds en un coup d’œil.", + "range": "Période d’activité", + "ranges": { + "24h": "24 heures", + "3d": "3 jours" + }, + "addNodes": "Ajouter des nœuds", + "searchBy": "Recherche", + "name": "Nom", + "publicKey": "Clé publique", + "search": "Trouver un nœud à enregistrer", + "namePlaceholder": "Rechercher par nom de nœud…", + "keyPlaceholder": "Clé publique ou préfixe hexadécimal…", + "savedCount": "{{count}} / {{limit}} enregistrés", + "searchHelp": "Recherchez dans toutes les régions. Saisissez au moins deux caractères ; une clé publique utilise des caractères hexadécimaux.", + "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", + "noMatches": "Aucun nœud correspondant. Essayez un autre nom ou une autre clé.", + "add": "+ Ajouter", + "added": "Enregistré", + "addName": "Ajouter {{name}}", + "addedName": "{{name}} est enregistré", + "refine": "Les huit premiers résultats sont affichés. Précisez votre recherche pour en trouver d’autres.", + "localOnly": "Enregistré dans ce navigateur · Toutes les régions", + "storageFailed": "L’enregistrement dans le navigateur est indisponible. Vos modifications fonctionnent pour cette visite, mais risquent de ne pas être conservées.", + "emptyTitle": "Créez votre propre vue du réseau", + "emptyDescription": "Cherchez un répéteur, un compagnon ou un autre nœud ci-dessus et ajoutez une fiche. Votre sélection et l’ordre des fiches seront conservés pour votre prochaine visite.", + "roles": { + "0": "Inconnu", + "1": "Compagnon", + "2": "Répéteur", + "3": "Salon", + "4": "Capteur" + }, + "unavailable": "Indisponible", + "recent": "Vu récemment", + "stale": "Pas vu récemment", + "freshnessUnknown": "Actualité inconnue", + "refreshFailed": "Échec de l’actualisation ; affichage du dernier échantillon chargé.", + "nodeUnavailable": "Ce nœud enregistré n’a pas pu être chargé. Il a peut-être expiré ou le serveur est indisponible.", + "retry": "Réessayer", + "activity": "Activité reçue", + "reports": "Réceptions", + "activityLabel": "{{count}} réceptions dans l’échantillon chargé de {{range}}. Les valeurs horaires sont disponibles sous Statistiques.", + "now": "Maintenant", + "sample": "Échantillon chargé · jusqu’à {{limit}} réceptions récentes", + "snr": "SNR moyen", + "rssi": "RSSI moyen", + "noReports": "Aucune réception conservée pour cette période. Cela ne permet pas de conclure que le nœud est hors ligne.", + "heardBy": "Entendu par", + "openNode": "Ouvrir le nœud", + "latestAdvert": "Observateurs du dernier paquet chargé", + "heardUnavailable": "Les détails du paquet sont indisponibles ou ont expiré. Essayez un autre paquet conservé depuis le profil du nœud.", + "observers_one": "{{count}} observateur ayant signalé le paquet", + "observers_other": "{{count}} observateurs ayant signalé le paquet", + "openAdvert": "Ouvrir le paquet et les chemins observés", + "statistics": "Statistiques et valeurs du graphique", + "definition": "Chaque réception correspond à un paquet provenant de cette clé de nœud complète, après déduplication par le serveur. Il s’agit d’un échantillon, pas du trafic total du nœud. Les barres vides indiquent l’absence de réceptions chargées ; l’historique brut plus ancien peut avoir expiré.", + "signalDefinition": "Les indicateurs font la moyenne des mesures disponibles du dernier saut aux observateurs récepteurs. Ils ne mesurent pas l’état de la radio du nœud d’origine. Les barres horaires couvrent une période glissante se terminant maintenant.", + "capped": "D’autres réceptions existent au-delà de celles chargées. Les nombres et moyennes portent uniquement sur l’échantillon chargé.", + "hops": "Sauts moyens", + "neighbors": "Voisins connus", + "radio": "Réglages radio", + "scope": "Portée par défaut", + "chartValues": "Valeurs horaires", + "hour": "Début de l’heure", + "updated": "Échantillon chargé", + "manage": "Gérer la fiche", + "earlier": "Déplacer avant", + "later": "Déplacer après", + "remove": "Retirer la fiche" + }, + "topology": { + "publicChatter": "Messages Public", + "chatterHint": "Les messages Public en direct pointent vers le premier récepteur cartographié signalé dans cette vue. Les noms des expéditeurs ne sont pas des identités vérifiées.", + "title": "Topologie", + "eyebrow": "TOPOLOGIE EN DIRECT / 3D", + "canvas": "Topologie 3D vue du dessus. Glisser avec le bouton gauche déplace la vue ; le bouton droit ou Maj-gauche la fait tourner. La molette zoome. Les flèches déplacent, Maj-flèches tournent, plus et moins zooment, Début cadre la vue et Échap revient au mesh. Utilisez Chercher pour la sélection au clavier.", + "projection": "TOPOLOGIE RÉGIONALE · 3D", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "fit": "Cadrer la vue", + "controls": "Cliquer sur une région pour la centrer · glisser pour tourner · Maj-glisser pour déplacer · pincer ou défiler pour zoomer", + "cameraRegion": "Centrage de la caméra", + "overview": "Vue d’ensemble du mesh", + "isolate": "Isoler {{region}}", + "showAllRegions": "Afficher toutes les régions", + "linkView": "Affichage des chemins", + "allPaths": "Tous les chemins", + "bundledPaths": "Liens regroupés par région", + "selectedPaths": "Nœud sélectionné", + "orbit": "Tourner", + "pan": "Déplacer", + "topView": "Vue du dessus", + "paused": "En pause", + "live": "En direct", + "reconnecting": "Connexion…", + "region": "Région", + "allRegions": "Toutes les régions", + "resume": "Reprendre", + "pause": "Pause", + "copy": "Copier le lien", + "copied": "Copié", + "nodes": "Nœuds chargés", + "links": "Connexions", + "reports": "Rapports · dernières 60 s de cette vue", + "observers": "Observateurs · dernières 60 s", + "loadError": "Impossible de charger la topologie.", + "refresh": "Actualiser la topologie", + "loading": "Chargement de la topologie…", + "bounded": "Une limite d’affichage a été atteinte : 20 000 nœuds, 60 000 routes récentes, 100 000 liens ou segments en direct, 10 000 rapports récents, ou 512 animations simultanées. Isolez une région pour plus de détails locaux.", + "gap": "La couverture en direct comporte une interruption après une pause, une mise en arrière-plan ou une reconnexion.", + "observerRing": "Anneau = observateur", + "reducedMotion": "Mouvements réduits", + "motion": "Animer les paquets", + "mix": "Types de paquets en direct · 60 s", + "paths": "{{count}} avec des segments traçables", + "find": "Chercher", + "search": "Nom ou clé publique", + "noNodes": "Aucun nœud correspondant dans la vue chargée.", + "neighbours": "{{count}} connexions dans cette vue", + "nodeDetails": "Détails du nœud", + "observerDetails": "Observateur", + "clear": "Effacer la sélection", + "reporting": "Observateurs ayant reçu des paquets", + "listening": "En attente de rapports de paquets…", + "latest": "Derniers rapports de paquets", + "partialPath": "Chemin partiel", + "path": "Chemin résolu", + "noPath": "Aucun chemin traçable", + "about": "Détails de la vue", + "definition": "Les régions sont placées selon leurs liens de voisinage et routes récentes, avec plus d’espace pour les grands groupes. Les nœuds utilisent leur IATA de réception le plus récent ; isoler un IATA inclut les nœuds chargés entendus dans cette région. Ce schéma représente les connexions, pas la géographie. Tous les chemins affiche chaque lien chargé, les segments de routes résolus entendus pendant la période sélectionnée et les chemins observés pendant 60 secondes. Les liens regroupés par région portent le nombre de connexions distinctes correspondantes. Les animations restent actives dans chaque mode. Seuls les sauts adjacents résolus vers une identité connue forment des segments ; les lacunes et préfixes ambigus ne sont jamais reliés. Courbes et mouvements ne mesurent ni altitude, ni temps de transmission, ni livraison. Les compteurs représentent les paires paquet/observateur reçues dans ce navigateur pendant 60 secondes, dédupliquées sur cette période ; les pauses et périodes en arrière-plan ne sont pas rattrapées. La recherche porte sur les nœuds chargés. Actualiser met à jour l’instantané.", + "types": { + "companion": "Compagnon", + "repeater": "Répéteur", + "room_server": "Salon", + "sensor": "Capteur" + }, + "kinds": { + "advert": "Annonce", + "text": "Texte", + "trace": "Trace", + "ack": "ACK", + "other": "Autre" + }, + "crossRegion": "Entre IATA", + "sharedDefault": "Même scope par défaut", + "defaultScope": "Scope par défaut annoncé", + "catalogueFresh": "Catalogue régional en cache", + "catalogueStale": "Vérification de la source ancienne ou indisponible", + "catalogueCounts": "{{count}} / {{total}} répéteurs publics portent un scope", + "catalogueChecked": "Dernière vérification", + "catalogueError": "Les métadonnées MeshMapper sont indisponibles.", + "noCatalogue": "Aucune source MeshMapper configurée pour cet IATA.", + "catalogueMeaning": "Comptages régionaux uniquement ; ils ne prouvent pas que ces voisins transmettent chaque scope. ◉ = surveillé, même à zéro.", + "cataloguedLink": "Présent dans un catalogue régional MeshMapper ; ce n’est pas une preuve de transmission sur ce lien.", + "neighbourLimit": "Les 12 premiers sur {{count}} sont affichés.", + "loadingRoutes": "Chargement des routes observées…", + "routesError": "Impossible de charger les routes observées. Les voisins et le trafic en direct restent disponibles.", + "routeSegments": "Routes observées", + "routeWindow": "Historique des routes", + "windows": { + "15m": "Routes · 15 min", + "1h": "Routes · 1h", + "24h": "Routes · 24h" + }, + "expand": "Plein écran", + "collapse": "Quitter le plein écran", + "display": "Affichage", + "threeDView": "Vue 3D", + "dragAction": "Glisser pour", + "inspector": "Détails du nœud", + "close": "Fermer", + "hideInspector": "Fermer", + "activity": "Activité en direct", + "activityCounts": "Rapports {{count}} · Observateurs {{observers}} · 60 s", + "incomplete": "Interruptions", + "controlsPan": "Glissez à gauche pour déplacer, à droite ou avec Maj pour pivoter. Molette pour zoomer. Flèches pour déplacer, Maj+flèches pour pivoter. Début recadre." + }, + "nodePage": { + "loadError": "Impossible de charger les détails du nœud.", + "title": "Détails du nœud", + "back": "Nœuds", + "open": "Ouvrir la page complète ↗", + "inspect": "Inspecter le nœud", + "observer": "Tableau de l’observateur", + "telemetry": "Télémétrie du nœud", + "telemetry24h": "Télémétrie reçue · 24 heures", + "packetTypes": "Types de paquets", + "regions": "Zones de réception", + "clock": "Dérive de l’horloge", + "firstSeen": "Première réception", + "neighbors": "Voisins · {{count}}" + }, + "nodeTelemetry": { + "shortTemperature": "Temp.", + "noData": "Aucune télémétrie reçue", + "more": "Autres mesures ({{count}})", + "loadError": "Impossible de charger la télémétrie du répéteur.", + "collected": "Télémétrie du répéteur · 30 derniers jours", + "collector": "Collecteur {{key}}", + "channel": "Canal {{channel}}", + "limit": "Les 24 séries de mesures les plus récentes sont affichées.", + "metrics": { + "batteryMv": "Batterie", + "uptimeSeconds": "Durée de fonctionnement", + "lastRssiDbm": "Dernier RSSI", + "lastSnrDb": "Dernier SNR", + "noiseFloorDbm": "Bruit de fond", + "queueLength": "File", + "receivedPackets": "Paquets reçus", + "sentPackets": "Paquets émis", + "voltage": "Tension", + "temperature": "Température", + "humidity": "Humidité", + "relative_humidity": "Humidité", + "barometer": "Pression", + "pressure": "Pression", + "barometric_pressure": "Pression", + "current": "Courant", + "power": "Puissance", + "illuminance": "Luminosité", + "luminosity": "Luminosité" + }, + "addCard": "Ajouter la carte de télémétrie du répéteur" } } } diff --git a/src/index.css b/src/index.css index cf8b878a..1dd3dcd5 100644 --- a/src/index.css +++ b/src/index.css @@ -151,3 +151,11 @@ [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; } + +/* Shared, static accents: readable live states without motion or flashing. */ +.beacon-sparkline { filter: drop-shadow(0 0 2px color-mix(in srgb, currentColor 25%, transparent)); } +.beacon-live-dot { box-shadow: 0 0 7px color-mix(in srgb, var(--color-green) 65%, transparent); } +.beacon-observer-eye { filter: drop-shadow(0 0 3px #c79bff); } +@media (prefers-reduced-motion: reduce) { + .beacon-live-dot { animation: none; } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 317b56b4..0a512276 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,7 +23,7 @@ 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; +export const TABS = ["MyAtlas", "Packets", "Channels", "Map", "Nodes", "Observers", "Topology", "Routes", "Traces", "Analytics"] as const; // 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. diff --git a/src/types/api.ts b/src/types/api.ts index 2dc6a42d..a395ad8b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -60,6 +60,8 @@ export interface PathLength { } export interface Observation { + wirePathBytes?: string; // Exact on-air SNR bytes for TRACE; pathBytes is the decoded requested route. + traceQuality?: TraceQuality; id: number; observerId: string; observerName?: string; @@ -99,6 +101,7 @@ export interface TransportCodes { } export interface PacketDetail { + traceQuality?: TraceQuality; packetHash: string; header: PacketHeader; transportCodes?: TransportCodes; @@ -216,7 +219,13 @@ export interface CrossIATARoute { // Each tag is either a route TRACE or a PING (round-trip) — the backend tags which. export type TraceType = "TRACE" | "PING"; +export interface TraceQuality { + status: "supported" | "suspect" | "ambiguous"; + reasons: string[]; +} + export interface TraceTagSummary { + quality?: TraceQuality; traceTag: string; // hex-encoded 4-byte tag firstHeardAt: number; // epoch ms lastHeardAt: number; // epoch ms @@ -233,6 +242,7 @@ export interface RawHop { } export interface TracePacket { + quality?: TraceQuality; packetHash: string; routeType: number; routeTypeName: string; diff --git a/tests/App.selectionReset.test.tsx b/tests/App.selectionReset.test.tsx index a3311d65..99cb4173 100644 --- a/tests/App.selectionReset.test.tsx +++ b/tests/App.selectionReset.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi } from "vitest"; import { StrictMode } from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { SelectionResetOnRegion } from "../src/App"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { RegionUrlSync, SelectionResetOnRegion } from "../src/App"; import { RegionProvider, useRegionSelection } from "../src/hooks/useRegion"; function RegionChanger() { @@ -9,6 +10,27 @@ function RegionChanger() { return ; } +function CurrentUrl() { + return {useLocation().search}; +} + +it("preserves an initial Topology link but clears its node/camera focus when the shared region changes", async () => { + render( + + + + ); + expect(screen.getByTestId("current-url")).toHaveTextContent("topoNode=node-a"); + fireEvent.click(screen.getByText("change")); + await waitFor(() => { + const params = new URLSearchParams(screen.getByTestId("current-url").textContent!); + expect(params.get("iata")).toBe("YYZ"); + expect(params.get("topoWindow")).toBe("24h"); + expect(params.has("topoNode")).toBe(false); + expect(params.has("topoFocus")).toBe(false); + }); +}); + describe("SelectionResetOnRegion", () => { // Regression: a deep-linked ?node/?observer selection must survive load. The reset watches the raw // selection (not the resolved regionKey) so the async slug→IATA expansion doesn't count as a change, diff --git a/tests/api/route-evidence.test.ts b/tests/api/route-evidence.test.ts index 10de5813..8d46fc51 100644 --- a/tests/api/route-evidence.test.ts +++ b/tests/api/route-evidence.test.ts @@ -14,3 +14,14 @@ it("encodes route identity and preserves the opaque cursor without numeric trunc expect(url.searchParams.has("range")).toBe(false); expect(fetcher.mock.calls[0][1].signal).toBe(signal); }); + +it("sends the selected path and fixed window together", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [], hasMore: false }) }); + vi.stubGlobal("fetch", fetcher); + await getRouteEvidence("YOW", "a".repeat(32), { since: 1000, until: 2000, hashSize: 2, pathBytes: "aa01bb02" }); + const url = new URL(fetcher.mock.calls[0][0]); + expect(url.searchParams.get("hashSize")).toBe("2"); + expect(url.searchParams.get("pathBytes")).toBe("aa01bb02"); + expect(url.searchParams.get("since")).toBe("1000"); + expect(url.searchParams.get("until")).toBe("2000"); +}); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index ae2c86df..911ee96d 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -36,12 +36,30 @@ function renderShell(onTabChange: (tab: string) => void = () => {}) { } beforeEach(() => { + vi.stubEnv("VITE_CHANGELOG_URL", ""); + delete window.__BEACON_CONFIG__; vi.mocked(getIatas).mockReset(); vi.mocked(getRegions).mockReset().mockResolvedValue([]); vi.mocked(getRegion).mockReset(); }); +afterEach(() => { vi.unstubAllEnvs(); delete window.__BEACON_CONFIG__; }); + describe("AppShell", () => { + it("links to the configured preview changelog without requiring it on other instances", () => { + const initial = renderShell(); + expect(screen.queryByRole("link", { name: "Changelog" })).not.toBeInTheDocument(); + initial.unmount(); + window.__BEACON_CONFIG__ = { VITE_CHANGELOG_URL: "/beacon-dev/source.html" }; + const configured = renderShell(); + expect(screen.getByRole("link", { name: "Changelog" })).toHaveAttribute("href", "/beacon-dev/source.html"); + expect(screen.getByRole("link", { name: "Changelog" })).toHaveAttribute("target", "_blank"); + configured.unmount(); + window.__BEACON_CONFIG__ = { VITE_CHANGELOG_URL: "javascript:alert(1)" }; + renderShell(); + expect(screen.queryByRole("link", { name: "Changelog" })).not.toBeInTheDocument(); + delete window.__BEACON_CONFIG__; + }); it("switches navigation to French while preserving tab identifiers and the active view", async () => { vi.mocked(getIatas).mockResolvedValue([]); const onTabChange = vi.fn(); @@ -314,7 +332,9 @@ describe("AppShell on a phone", () => { 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([]); + window.__BEACON_CONFIG__ = { VITE_CHANGELOG_URL: "/beacon-dev/source.html" }; renderShell(); + expect(screen.getByRole("link", { name: "Changelog" })).toHaveAttribute("href", "/beacon-dev/source.html"); expect(screen.getByRole("button", { name: /Region/ })).toBeInTheDocument(); expect(screen.getByRole("status", { name: "LIVE" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^Language:/ })).not.toBeInTheDocument(); diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index 0c78aa5c..6fb104bc 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,6 +4,17 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { + it("puts My Atlas first and preserves its canonical identifier in French", async () => { + const onTabChange = vi.fn(); + render(); + expect(screen.getAllByRole("tab")[0]).toHaveTextContent("My Atlas"); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("tab", { name: "Mon Atlas" })); + expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); + fireEvent.click(screen.getByText("Plus")); + fireEvent.click(screen.getByRole("menuitem", { name: "Nœuds" })); + expect(onTabChange).toHaveBeenCalledWith("Nodes"); + }); it("translates an open More sheet without changing the selected tab identifier", async () => { const onTabChange = vi.fn(); render(); diff --git a/tests/components/Sparkline.test.tsx b/tests/components/Sparkline.test.tsx new file mode 100644 index 00000000..fbf13de2 --- /dev/null +++ b/tests/components/Sparkline.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { Sparkline } from "../../src/components/Sparkline"; + +describe("sparkline evidence", () => { + it("draws isolated measured zeros and dashed bridges, without changing the input", () => { + const values = [null, 0, null, 4, 5, null]; + const { container } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(values).toEqual([null, 0, null, 4, 5, null]); + }); + it("marks a long time gap and a single reading, without extrapolating measured points", () => { + const { container, rerender } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + rerender(); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(0); + }); +}); diff --git a/tests/deploy/docker-entrypoint.test.ts b/tests/deploy/docker-entrypoint.test.ts index 2b8d8779..23377fdf 100644 --- a/tests/deploy/docker-entrypoint.test.ts +++ b/tests/deploy/docker-entrypoint.test.ts @@ -29,7 +29,7 @@ describe("docker-entrypoint.sh", () => { 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_API_BASE", "VITE_APP_NAME", "VITE_BANNER", "VITE_CHANGELOG_URL", "VITE_DISABLED_TABS", "VITE_ENABLED_THEMES", "VITE_MAP_CENTER", "VITE_MAP_ZOOM", "VITE_SKIP_SPLASH", "VITE_WS_URL", ]); }); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx new file mode 100644 index 00000000..b462f38a --- /dev/null +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -0,0 +1,197 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; +import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import * as api from "../../../src/api/client"; +import type { Node } from "../../../src/features/nodes/types"; +import type { PacketDetail } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ getNodeNeighbors: vi.fn(), getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn(), getCollectedNodeTelemetry: vi.fn() })); +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; +const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Second" }; +const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; +const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 }; +const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; +const clients: QueryClient[] = []; +function mount(active = true) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + return render(); +} +function save(nodes = [pin]) { localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range: "24h" })); } +beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); + window.history.replaceState({}, "", "/"); + vi.mocked(api.getCollectedNodeTelemetry).mockResolvedValue({ items: [], limit: 500 }); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); + vi.mocked(api.getNode).mockImplementation(async id => id === second.id ? { ...node, ...second } : node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); + vi.mocked(api.getPacketDetail).mockResolvedValue({ packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ + { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, + { observerId: "observer-a", observerName: "Home listener", id: 2, heardAt: 2, iata: "YKF" }, + { observerId: "observer-b", observerName: "Hill listener", id: 3, heardAt: 3, iata: "YKF" }, + ] } as PacketDetail); +}); +afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); + +describe("My Atlas", () => { + it("opens a collector link and pins battery telemetry before the node has advertised", async () => { + window.history.replaceState({}, "", `/?tab=MyAtlas&atlasAdd=${pin.publicKey}`); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + vi.mocked(api.getCollectedNodeTelemetry).mockResolvedValue({ limit: 500, items: [{ nodeKey: pin.publicKey, collectorKey: "cd".repeat(32), radioKey: "ef".repeat(32), receivedAt: Date.now(), values: { batteryMv: 4150 }, sensors: [] }] }); + mount(); + expect(screen.getByRole("searchbox")).toHaveValue(pin.publicKey); + fireEvent.click(await screen.findByRole("button", { name: "Add repeater telemetry card" })); + await screen.findByText("4.15 V"); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0]).toEqual({ id: "", publicKey: pin.publicKey, name: "" }); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("does not scan nodes or packets before a search or selection", () => { + mount(); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("adds a search result once, persists it across visits, and displays its current name", async () => { + const view = mount(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "repeater" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + view.unmount(); mount(); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("loads heard-by details only on demand, deduplicates observers and links to the evidence", async () => { + save(); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(await screen.findByText("2 reporting observers")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(actions.onViewObserver).toHaveBeenCalledWith("observer-a"); + fireEvent.click(screen.getByRole("button", { name: "Open packet and observed paths" })); expect(actions.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + fireEvent.click(screen.getByRole("button", { name: "Open node" })); expect(actions.onViewNode).toHaveBeenCalledWith(pin.id); + expect(screen.getByRole("meter", { name: "Mean SNR: 0 dB" })).toHaveAttribute("aria-valuenow", "0"); + }); + it("keeps only one heard-by section open and saves reordering, removal and the raw-history range", async () => { + save([pin, second]); mount(); + const first = await screen.findByRole("article", { name: "Renamed repeater" }); + const next = await screen.findByRole("article", { name: "Second" }); + fireEvent.click(within(first).getByRole("button", { name: "Heard by" })); + fireEvent.click(within(next).getByRole("button", { name: "Heard by" })); + expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(within(first).getByText("Manage card")); + fireEvent.click(within(first).getByRole("button", { name: "Move later" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + fireEvent.click(screen.getByRole("button", { name: "3 days" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); + fireEvent.click(within(first).getByRole("button", { name: "Remove card" })); + expect(screen.queryByRole("article", { name: "Renamed repeater" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /7 days|30 days/ })).not.toBeInTheDocument(); + }); + it("uses French labels and keeps working when browser writes fail", async () => { + save(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + await act(() => i18n.changeLanguage("fr")); mount(); + await screen.findByRole("article", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3 jours" })); + expect(screen.getByRole("button", { name: "3 jours" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/L’enregistrement dans le navigateur est indisponible/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Entendu par" })).toBeInTheDocument(); + }); + it("honours changes made in another tab and keeps corrupt storage from crashing the page", async () => { + localStorage.setItem(ATLAS_KEY, "{"); mount(); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: ATLAS_KEY, newValue: JSON.stringify({ version: 1, nodes: [pin], range: "3d" }) }))); + await screen.findByRole("article", { name: "Renamed repeater" }); + expect(screen.getByRole("button", { name: "3 days" })).toHaveAttribute("aria-pressed", "true"); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not query a retained but inactive page", () => { + save(); mount(false); + expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("explains expired packet evidence", async () => { + save(); vi.mocked(api.getPacketDetail).mockRejectedValue(new Error("expired")); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + }); + it("keeps an unavailable node removable without rendering zeros as real readings", async () => { + save(); vi.mocked(api.getNode).mockRejectedValue(new Error("unavailable")); mount(); + await screen.findByText(/This saved node could not be loaded/); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Manage card")); + fireEvent.click(screen.getByRole("button", { name: "Remove card" })); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not expose observer links for a different full origin identity", async () => { + save(); vi.mocked(api.getPacketDetail).mockResolvedValue({ originPubkey: second.publicKey, observations: [] } as unknown as PacketDetail); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + expect(screen.queryByRole("button", { name: "Open packet and observed paths" })).not.toBeInTheDocument(); + }); + it("aborts in-flight requests when leaving the page", () => { + save(); vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); + const view = mount(); const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; + expect(signal.aborted).toBe(false); + view.unmount(); expect(signal.aborted).toBe(true); + }); + it("shows missing signal values without meters for an empty retained sample", async () => { + save(); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(screen.getByRole("img", { name: "Mean SNR: Unavailable" })).toBeInTheDocument(); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("never queries an invalid public-key search as an unfiltered node list", async () => { + mount(); fireEvent.click(screen.getByRole("button", { name: /Search.*Name/ })); + fireEvent.click(screen.getByRole("option", { name: "Public key" })); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "not-a-key" } }); + await act(() => new Promise(resolve => setTimeout(resolve, 350))); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); + +describe("identity recovery", () => { + it("keeps a saved card available for telemetry until its full identity advertises again", async () => { + vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + const result = await loadAtlasNode(pin); + expect(result.node).toBeUndefined(); + expect(result.page.items).toEqual([]); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("recovers a changed server ID using the exact full public key", async () => { + vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, id: second.id }); + const result = await loadAtlasNode(pin); + expect(result.node.id).toBe(second.id); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, undefined); + }); + it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + await expect(loadAtlasNode(pin)).rejects.toThrow("Atlas identity unavailable"); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + vi.mocked(api.getNodesPage).mockClear(); vi.mocked(api.getNode).mockRejectedValueOnce(new Error("network")); + await expect(loadAtlasNode(pin)).rejects.toThrow("network"); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); + +it("loads neighbour evidence only when a compact card is expanded", async () => { + save();vi.mocked(api.getNodeNeighbors).mockResolvedValue([{id:second.id,name:"Nearby relay",publicKey:second.publicKey,nodeType:2,nodeTypeName:"repeater",iata:"YKF",observationCount:4,firstSeen:1,lastSeen:2}]); + mount();await screen.findByRole("button",{name:node.name!}); + expect(api.getNodeNeighbors).not.toHaveBeenCalled(); + const summary=screen.getAllByText(/Known neighbours/).map(el=>el.closest("summary")).find(Boolean)!; + const details=summary.closest("details")!;details.open=true;fireEvent(details,new Event("toggle")); + await screen.findByRole("button",{name:/Nearby relay/}); + expect(api.getNodeNeighbors).toHaveBeenCalledWith(node.id); + expect(screen.getByText(/not a polled repeater table/)).toBeInTheDocument(); +}); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts new file mode 100644 index 00000000..35e1c013 --- /dev/null +++ b/tests/features/atlas/atlas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import type { NodeObservation } from "../../../src/features/nodes/types"; + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const now = Date.UTC(2026, 8, 29, 12); +const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); + +describe("saved Atlas", () => { + it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + }); + it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { + for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; + const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); + expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.range).toBe("24h"); + }); +}); + +describe("origin report sample", () => { + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { + const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; + const sample = summarizeReports(rows, "24h", now); + expect(sample.reports.map(r => r.id)).toEqual([1, 5, 2]); + expect(sample.bins.reduce((a, b) => a + b, 0)).toBe(3); + expect(sample.bins[23]).toBe(2); + expect(sample.bins[0]).toBe(1); + expect(summarizeReports(rows, "3d", now).reports).toHaveLength(4); + }); + it("does not turn missing signal into zero and averages real zero SNR samples", () => { + const sample = summarizeReports([report(1, 1), report(2, 2, { rssi: 0, snr: 0 }), report(3, 3, { rssi: -110, snr: 0, hopCount: 0 }), report(4, 4, { rssi: -90, snr: 10, hopCount: 4 }), report(5, 5, { rssi: NaN, snr: Infinity })], "24h", now); + expect(sample.snr).toBe(5); + expect(sample.rssi).toBe(-100); + expect(sample.hops).toBe(2); + expect(summarizeReports([], "24h", now).snr).toBeNull(); + }); +}); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index ec0a7aff..3ae1e1e5 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -25,7 +25,8 @@ describe("buildPacketPaths", () => { 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)); + const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 2 }, + parsedPayload: { snrValues: [1, 2] }, 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]]); @@ -128,11 +129,15 @@ describe("buildPacketPaths", () => { const d = detail( [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], { - header: { payloadType: PayloadType.TRACE, routeType: 1 }, + header: { payloadType: PayloadType.TRACE, routeType: 2 }, + parsedPayload: { snrValues: [1, 2] }, resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)], } as unknown as Partial, ); expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]); + expect(buildPacketPaths({ ...d, traceQuality: { status: "suspect", reasons: ["unsupported_flags"] } })).toEqual([]); + expect(buildPacketPaths({ ...d, traceQuality: { status: "ambiguous", reasons: ["ambiguous_prefix"] } })).toEqual([]); + expect(buildPacketPaths({ ...d, parsedPayload: { snrValues: [1] } })).toEqual([]); }); it("draws nothing for a TRACE with no resolved route", () => { diff --git a/tests/features/nodes/CollectedNodeTelemetry.test.tsx b/tests/features/nodes/CollectedNodeTelemetry.test.tsx new file mode 100644 index 00000000..799a71a7 --- /dev/null +++ b/tests/features/nodes/CollectedNodeTelemetry.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CollectedNodeTelemetry } from "../../../src/features/nodes/CollectedNodeTelemetry"; +import { getCollectedNodeTelemetry } from "../../../src/api/client"; + +vi.mock("../../../src/api/client", () => ({ getCollectedNodeTelemetry: vi.fn() })); +const key = "ab".repeat(32); +const collector = "cd".repeat(32); +function mount() { return render(); } + +describe("collector telemetry", () => { + it("renders status-only and sensor-only reports with absent or null optional collections", async () => { + vi.mocked(getCollectedNodeTelemetry).mockResolvedValue({ limit: 500, items: [ + { nodeKey: key, collectorKey: collector, radioKey: "ef".repeat(32), receivedAt: Date.now(), values: { batteryMv: 4120 }, sensors: null }, + { nodeKey: key, collectorKey: collector, radioKey: "ef".repeat(32), receivedAt: Date.now(), values: null, sensors: [{ channel: 2, kind: "temperature", value: 0, unit: "C" }] }, + ] }); + mount(); + await screen.findByText("4.12 V"); + expect(screen.getByText("0 C")).toBeInTheDocument(); + }); + it("shows an initial service failure and lets a retry load actual readings", async () => { + vi.mocked(getCollectedNodeTelemetry).mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue({ limit: 500, items: [ + { nodeKey: key, collectorKey: collector, radioKey: "ef".repeat(32), receivedAt: Date.now(), values: { batteryMv: 4120 }, sensors: [] }, + ] }); + mount(); + expect(await screen.findByRole("status")).toHaveTextContent("Repeater telemetry could not be loaded."); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("4.12 V"); + expect(screen.queryByText("Repeater telemetry could not be loaded.")).not.toBeInTheDocument(); + }); + it("keeps environmental channels and collector attribution without inventing battery percent", async () => { + vi.mocked(getCollectedNodeTelemetry).mockResolvedValue({ limit: 500, items: [ + { nodeKey: key, collectorKey: collector, radioKey: "ef".repeat(32), receivedAt: Date.now(), intervalHours: 1, + values: { batteryMv: 4120 }, sensors: [{ channel: 2, kind: "temperature", value: 21.5, unit: "C" }] }, + { nodeKey: "12".repeat(32), collectorKey: collector, radioKey: "ef".repeat(32), receivedAt: Date.now(), values: { batteryMv: 9900 }, sensors: [] }, + ] }); + mount(); + await screen.findByText("21.5 C"); + expect(screen.getByText("Temperature · Ch 2")).toBeInTheDocument(); + expect(screen.getByText("4.12 V")).toBeInTheDocument(); + expect(screen.queryByText("9.9 V")).not.toBeInTheDocument(); + expect(screen.getAllByTitle(collector)).toHaveLength(2); + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/nodes/NodePage.test.tsx b/tests/features/nodes/NodePage.test.tsx new file mode 100644 index 00000000..53aed07a --- /dev/null +++ b/tests/features/nodes/NodePage.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { NodePage } from "../../../src/features/nodes/NodePage"; +import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; +import type { Node } from "../../../src/features/nodes/types"; + +vi.mock("../../../src/api/client", async () => ({ ...await vi.importActual("../../../src/api/client"), getNode: vi.fn(), getNodeObservations: vi.fn(), getNodeNeighbors: vi.fn(), getObserver: vi.fn(), getObserverTelemetry: vi.fn(), getCollectedNodeTelemetry: vi.fn(async () => ({ items: [], limit: 500 })) })); +const id = "11111111-1111-4111-8111-111111111111"; +const now = Date.now(); +const node = { id, publicKey: "ab".repeat(32), name: "Roof repeater", nodeTypeName: "repeater", iatas: [], knownNeighborCount: 1, firstSeen: now - 100_000, lastSeen: now, observerId: undefined } as Node; +function Location() { return {useLocation().search}; } +function mount(nodeId = id) { + const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; + render(); + return actions; +} +describe("full node page", () => { + it("distinguishes a server failure from a missing node and supports retry", async () => { + vi.mocked(getNode).mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 })).mockResolvedValue(node); + vi.mocked(getNodeNeighbors).mockResolvedValue([]); + vi.mocked(getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + mount(); + expect(await screen.findByRole("alert")).toHaveTextContent("Node details could not be loaded."); + expect(screen.queryByText("Node not found")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("heading", { name: "Roof repeater" }); + }); + it("loads one bounded report sample and preserves inspection and back context", async () => { + vi.mocked(getNode).mockResolvedValue(node); + vi.mocked(getNodeNeighbors).mockResolvedValue([]); + vi.mocked(getNodeObservations).mockResolvedValue({ items: [{ id: 9, packetHash: "0123456789abcdef", payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - 1000, rssi: -90, snr: 0, hopCount: 2 }], hasMore: true, nextCursor: 9 }); + const actions = mount(); + await screen.findByRole("heading", { name: "Roof repeater" }); + fireEvent.click(await screen.findByText("01234567")); + expect(actions.onAnalyzePacket).toHaveBeenCalledWith("0123456789abcdef", 9); + expect(getNodeObservations).toHaveBeenCalledWith(id, { limit: 200 }, expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Inspect node" })); + expect(actions.onViewNode).toHaveBeenCalledWith(id); + fireEvent.click(screen.getByRole("button", { name: /← Nodes/ })); + expect(screen.getByTestId("location")).toHaveTextContent("?tab=Nodes&iata=YKF"); + }); + it("does not request data for an invalid node identity", async () => { + vi.mocked(getNode).mockClear(); + mount("not-a-node"); + await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + expect(getNode).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/nodes/NodeTelemetry.test.tsx b/tests/features/nodes/NodeTelemetry.test.tsx new file mode 100644 index 00000000..a70a866e --- /dev/null +++ b/tests/features/nodes/NodeTelemetry.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NodeTelemetry } from "../../../src/features/nodes/NodeTelemetry"; +import { getObserver, getObserverTelemetry } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { Node } from "../../../src/features/nodes/types"; +vi.mock("../../../src/api/client", () => ({ getObserver: vi.fn(), getObserverTelemetry: vi.fn(), getCollectedNodeTelemetry: vi.fn().mockResolvedValue({ items: [], limit: 500 }) })); +const node = { publicKey: "ab".repeat(32), observerId: "observer-a" } as Node; +function mount() { return render(); } +describe("node telemetry identity", () => { + it("rejects an observer with only a matching short prefix", async () => { + vi.mocked(getObserverTelemetry).mockClear(); + vi.mocked(getObserver).mockResolvedValue({ publicKey: "ab" + "cd".repeat(31) } as Observer); + const { container } = mount(); + await waitFor(() => expect(getObserver).toHaveBeenCalled()); + expect(getObserverTelemetry).not.toHaveBeenCalled(); + expect(container).toBeEmptyDOMElement(); + }); + it("shows volts only from the exact full-key identity", async () => { + vi.mocked(getObserver).mockResolvedValue({ publicKey: node.publicKey.toUpperCase() } as Observer); + vi.mocked(getObserverTelemetry).mockResolvedValue({ range: "24h", interval: "1h", points: [ + { t: Date.now()-3_600_000, batteryMv: 4000, uptimeSeconds: 100, noiseFloorDb: -110, airtimeRxSecs: null, airtimeTxSecs: null, queueLength: null, receiveErrors: null }, + { t: Date.now(), batteryMv: 3900, uptimeSeconds: 3700, noiseFloorDb: -109, airtimeRxSecs: null, airtimeTxSecs: null, queueLength: null, receiveErrors: null }, + ] }); + mount(); + await screen.findByText("3.90 V"); + expect(getObserverTelemetry).toHaveBeenCalledWith("observer-a", "24h", "1h"); + }); +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx index 1d11f7ec..af5cbe1c 100644 --- a/tests/features/observers/ObserverSummary.compact.test.tsx +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -22,6 +22,18 @@ describe("compact observer header", () => { }); describe("observer summary card notes", () => { + it("leaves unread activity gaps in both packet sparklines", () => { + const hour = 3_600_000; + const activity = { + interval: "1h", windowStart: 0, windowEnd: 5 * hour, rolledUntil: 2 * hour, rawFrom: 4 * hour, + points: [0, 1, 4, 5].map(h => ({ t: h * hour, observations: h + 1, packets: h + 1 })), + summary: { recordedPackets: 14, lastCompleteHour: 6, lastCompleteHourEnd: 6 * hour }, + } as unknown as ObserverActivity; + render(); + for (const label of ["Packets heard", "Packets last hour"]) { + expect(screen.getByText(label).closest("li")!.querySelectorAll("polyline")).toHaveLength(2); + } + }); it("keeps the status icon and IATA chip on the name row", () => { render(); const row = screen.getByRole("heading", { level: 1 }).parentElement!; diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index b426ef64..5a658853 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -234,9 +234,7 @@ describe("PacketAnalyzerDrawer view-path button", () => { }); 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. + // TRACE wire path carries SNR bytes; requested hashes belong to its payload. function traceDetail(): PacketDetail { return { packetHash: "abcdef12", @@ -246,21 +244,21 @@ describe("PacketAnalyzerDrawer TRACE path data", () => { observations: [{ id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, - pathBytes: "abcd", + pathBytes: "abcd", wirePathBytes: "fc00", 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'", () => { + it("shows trace wire SNR separately from requested payload hashes", () => { 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); + expect(screen.queryByText("Path Data")).not.toBeInTheDocument(); + expect(screen.getByText("Recorded SNR bytes")).toBeInTheDocument(); + expect(screen.getByText("FC00")).toBeInTheDocument(); }); }); diff --git a/tests/features/packets/packet-structure.test.ts b/tests/features/packets/packet-structure.test.ts index 9c4da0d8..f12d37a3 100644 --- a/tests/features/packets/packet-structure.test.ts +++ b/tests/features/packets/packet-structure.test.ts @@ -171,3 +171,13 @@ describe("transport frames", () => { expect(frame.slice(ranges.payload!.start * 2, ranges.payload!.end * 2)).toBe("3b5c22"); }); }); + +it("uses exact trace SNR bytes in the wire frame instead of decoded requested hashes", () => { + const d = makeDetail({payloadType:PayloadType.TRACE,rawPayload:"010203040000000001aabbccdd"}); + d.header.raw="26"; + const o = {...makeObs({raw:"01",hashSize:2,hopCount:2,pathBytes:"aabbccdd"}),wirePathBytes:"fc"}; + const frame=buildObservationFrame(d,o); + expect(frame).toBe("2601fc010203040000000001aabbccdd"); + expect(computeFieldRanges(d,o,frame.length/2).pathData).toEqual({start:2,end:3}); + expect(computeFieldRanges(d,o,frame.length/2).payload?.start).toBe(3); +}); diff --git a/tests/features/routes/RouteDetailPanel.test.tsx b/tests/features/routes/RouteDetailPanel.test.tsx index aa28c859..c846b9dc 100644 --- a/tests/features/routes/RouteDetailPanel.test.tsx +++ b/tests/features/routes/RouteDetailPanel.test.tsx @@ -20,6 +20,7 @@ const inspect = vi.fn(); const observer = vi.fn(); function Harness({ listed }: { listed?: KnownRoute }) { const [params, set] = useSearchParams(); return <> + {}} onAnalyzePacket={inspect} onViewObserver={observer} />; } function mount({ url = "/?tab=Routes", listed }: { url?: string; listed?: KnownRoute } = {}) { @@ -29,13 +30,41 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedV 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" }); + mount({ url: "/?routeRange=7d" }); 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("requests and shares the pinned window and prefix representation", async () => { + Object.defineProperty(navigator, "clipboard", { value: { writeText: vi.fn().mockResolvedValue(undefined) }, configurable: true }); + mount({ url: "/?routeSince=1700000000000&routeUntil=1700086400000&routeHashSize=1&routePathBytes=abcd" }); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700086400000, hashSize: 1, pathBytes: "abcd", limit: 50 }, expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Copy link" })); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled()); + const link = new URL(vi.mocked(navigator.clipboard.writeText).mock.calls[0]![0]); + expect(link.searchParams.get("routeSince")).toBe(String(page.windowStart)); + expect(link.searchParams.get("routeUntil")).toBe(String(page.windowEnd)); + expect(link.searchParams.get("routeHashSize")).toBe("1"); + expect(link.searchParams.get("routePathBytes")).toBe("abcd"); + }); + + it("replaces reports when the pinned bytes change without changing the route key", async () => { + mount({ url: "/?routeHashSize=1&routePathBytes=abcd" }); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByText("Other width")); + expect(screen.queryByText("Garden")).not.toBeInTheDocument(); + expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "720h", hashSize: 2, pathBytes: "ab01cd02", limit: 50 }, expect.anything()); + }); + + it.each(["routeHashSize=1", "routePathBytes=abcd", "routeHashSize=1&routeHashSize=2&routePathBytes=abcd", "routeHashSize=4&routePathBytes=abcd", "routeHashSize=01&routePathBytes=abcd", "routeHashSize=1&routePathBytes=ab", "routeHashSize=2&routePathBytes=abcde", "routeHashSize=1&routePathBytes=ABCD", "routeSince=bad", "routeSince=1&routeUntil=2678400001", "routeSince=1&routeSince=2&routeUntil=3"])("rejects invalid pinned selectors: %s", async selector => { + mount({ url: "/?" + selector }); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); + it("shows the route's lifetime summary from the listed route before packets load", () => { vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); mount({ listed: route }); @@ -49,11 +78,6 @@ describe("route detail", () => { 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(); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 0526f413..14c78c75 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -117,13 +117,14 @@ it("draws sparklines from complete hours only", async () => { expect(lines).toEqual([2, 2, 2, 2]); }); -it("breaks sparklines at missing hours instead of joining across them", async () => { +it("keeps measured sparkline runs separate and marks gaps with dashed estimates", 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"); + expect(lines[1]![0]!.split(",")[0]).toBe("89"); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(4); }); it("plots observations and unique packets from the series, leaving gaps for unrolled hours", async () => { diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 6255e2f4..2800bf6b 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -5,7 +5,7 @@ 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"); + expect(Number(dot.getAttribute("x1"))).toBeCloseTo(40.6667,3); }); it("draws a presence strip with one bar per run of reporting hours", () => { diff --git a/tests/features/topology/TopologyCanvas.test.ts b/tests/features/topology/TopologyCanvas.test.ts new file mode 100644 index 00000000..e534711e --- /dev/null +++ b/tests/features/topology/TopologyCanvas.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRenderer } from "../../../src/features/topology/topology-renderer"; +import { buildTopology, LiveTraffic } from "../../../src/features/topology/topology"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { ChartColors } from "../../../src/features/stats/chartTheme"; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe("topology camera continuity", () => { + it("keeps pan, angle, zoom and regional focus through a data refresh and resize", () => { + const callbacks: ResizeObserverCallback[] = []; + vi.stubGlobal("ResizeObserver", class { constructor(cb: ResizeObserverCallback) { callbacks.push(cb); } observe() {} disconnect() {} }); + vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1)); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ setTransform: vi.fn() } as unknown as CanvasRenderingContext2D); + const canvas = document.createElement("canvas"); + vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({ width: 1000, height: 700 } as DOMRect); + const node = { id: "a", publicKey: "ab".repeat(32), name: "Repeater", nodeType: 2, iatas: [{ iata: "YKF", lastHeard: 1 }], knownNeighborCount: 0, neighborIds: [] } as unknown as NodeSummary; + const graph = buildTopology([node]); + const first = createRenderer(canvas, graph, new LiveTraffic(), {} as ChartColors, vi.fn(), vi.fn())!; + first.update("", "YKF", "all", false, true); + first.zoom(2); + canvas.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true })); + canvas.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft" })); + const chosen = first.view(); + first.dispose(); + const refreshed = createRenderer(canvas, buildTopology([{ ...node, name: "Fresh name" }]), new LiveTraffic(), {} as ChartColors, vi.fn(), vi.fn(), chosen)!; + refreshed.update("", "YKF", "all", false, true); + expect(refreshed.view()).toEqual(chosen); + callbacks.at(-1)!([], {} as ResizeObserver); + expect(refreshed.view()).toEqual(chosen); + refreshed.fit(false); + expect(refreshed.view().camera.zoom).not.toBe(chosen.camera.zoom); + refreshed.dispose(); + }); +}); diff --git a/tests/features/topology/TopologyPage.test.tsx b/tests/features/topology/TopologyPage.test.tsx new file mode 100644 index 00000000..a7e54b88 --- /dev/null +++ b/tests/features/topology/TopologyPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import type { ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TopologyPage } from "../../../src/features/topology/TopologyPage"; +import type { WsManager } from "../../../src/api/ws-manager"; +import type { WsPacketObservation } from "../../../src/types/ws"; +import { getScopeCatalogues, getNodesPage, getTopologyLinks } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/features/topology/TopologyCanvas", () => ({ TopologyCanvas: ({ heading, controls, settings }: { heading: ReactNode; controls: ReactNode; settings: ReactNode }) =>
      {heading}{controls}
      {settings}canvas
      })); +const region = vi.hoisted(() => ({ iatas: ["YOW"] as string[] | undefined, regionKey: "YOW", isResolved: true })); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/features/stats/chartTheme", () => ({ useChartColors: () => ({}), nodeTypeColor: () => "#fff" })); +vi.mock("../../../src/api/client", () => ({ getIatas: vi.fn(async () => [{ iata: "YOW" }]), getScopeCatalogues: vi.fn(async () => []), getNodesPage: vi.fn(async () => ({ items: [], nextCursor: null, hasMore: false })), getTopologyLinks: vi.fn(async () => ({ links: [], capped: false, since: 0, until: 1 })) })); + +beforeEach(() => { region.iatas = ["YOW"]; region.regionKey = "YOW"; region.isResolved = true; }); +afterEach(() => vi.useRealTimers()); +describe("Topology live lifecycle", () => { + it("keeps traffic live, filters regions, cleans subscriptions, and degrades without catalogue support", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] }); + let receive: ((event: WsPacketObservation["data"]) => void) | undefined; + const unsubscribe = vi.fn(), resolve = vi.fn(); + const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: resolve, onChannelMessage: () => vi.fn(), onPacketObservation: vi.fn(fn => { receive = fn; return unsubscribe; }) } as unknown as WsManager; + vi.mocked(getScopeCatalogues).mockRejectedValueOnce(new Error("older server")); + const props = { wsManager: manager, onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; + const { unmount } = render(); + expect(screen.queryByRole("complementary")).not.toBeInTheDocument(); + expect(getScopeCatalogues).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Find" })); + await screen.findByText("MeshMapper metadata is unavailable."); + expect(resolve).toHaveBeenCalledWith(true); + const event = { packetHash: "abcdef012345", packet: { payloadType: 99 }, observation: { observerId: "reporter", observerName: "Live observer", iata: "YKF" } } as WsPacketObservation["data"]; + act(() => { receive!(event); vi.advanceTimersByTime(600); }); + expect(screen.queryByText("abcdef01")).not.toBeInTheDocument(); + act(() => { receive!({ ...event, observation: { ...event.observation, iata: "YOW" } }); vi.advanceTimersByTime(600); }); + expect(screen.queryByRole("button", { name: /abcdef01/ })).not.toBeInTheDocument(); + const activity = screen.getByText("Live activity").closest("details")!; + activity.open = true; fireEvent(activity, new Event("toggle")); + fireEvent.click(screen.getByRole("button", { name: /abcdef01/ })); expect(props.onAnalyzePacket).toHaveBeenCalledWith("abcdef012345"); + expect(screen.queryByRole("button", { name: /Pause|Resume/ })).not.toBeInTheDocument(); + expect(unsubscribe).not.toHaveBeenCalled(); + unmount(); expect(unsubscribe).toHaveBeenCalledTimes(1); expect(resolve).toHaveBeenLastCalledWith(false); + }); + it("refreshes route evidence when switching back to a cached window", async () => { + vi.mocked(getTopologyLinks).mockClear(); + const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: () => vi.fn() } as unknown as WsManager; + render(); + await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(1)); + const range = screen.getByRole("combobox", { name: "Route history" }); + fireEvent.change(range, { target: { value: "24h" } }); + await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(2)); + fireEvent.change(range, { target: { value: "15m" } }); + await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(2)); + }); + it("follows the shared region selector instead of a stale Topology-only filter", async () => { + vi.mocked(getNodesPage).mockClear(); + region.iatas = ["YKF"]; region.regionKey = "YKF"; + const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: () => vi.fn() } as unknown as WsManager; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const page = () => ; + const view = render(page()); + await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(["YKF"], expect.anything(), expect.any(AbortSignal))); + expect(screen.queryByRole("combobox", { name: "Region", exact: true })).not.toBeInTheDocument(); + region.iatas = ["YVR", "YYJ"]; region.regionKey = "YVR,YYJ"; view.rerender(page()); + await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(["YVR", "YYJ"], expect.anything(), expect.any(AbortSignal))); + region.iatas = undefined; region.regionKey = "*"; view.rerender(page()); + await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(undefined, expect.anything(), expect.any(AbortSignal))); + }); + it("does not fetch a retained inactive view and translates the controls", async () => { + await i18n.changeLanguage("fr"); vi.mocked(getNodesPage).mockClear(); + const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: vi.fn() } as unknown as WsManager; + render(); + expect(screen.getByRole("heading", { name: "Topologie" })).toBeInTheDocument(); expect(getNodesPage).not.toHaveBeenCalled(); expect(manager.onPacketObservation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/topology/chatter.test.ts b/tests/features/topology/chatter.test.ts new file mode 100644 index 00000000..73ef3915 --- /dev/null +++ b/tests/features/topology/chatter.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { LiveChatter, CHAT_MS } from "../../../src/features/topology/chatter"; +import { buildTopology } from "../../../src/features/topology/topology"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { WsPacketObservation } from "../../../src/types/ws"; + +const nodes = ["a", "b"].map(id => ({ id, publicKey:id.repeat(64), observerId:`obs-${id}`, isObserver:true, nodeTypeName:"repeater", nodeType:2, iatas:[{iata:"YKF",lastHeard:1}], knownNeighborCount:0, name:id, lat:null,lng:null } satisfies NodeSummary)); +const graph = buildTopology(nodes); +const message = {id:1,packetHash:"hash",isPublic:true,channelHash:"11",senderName:"Alice",content:"hello",sentAt:1}; +const event = (id: string, heardAt: number) => ({packetHash:"hash",packet:{payloadType:5,routeType:1},observation:{observerId:`obs-${id}`,heardAt}} as WsPacketObservation["data"]); + +describe("Public topology chatter", () => { + it("joins either arrival order, anchors to the earliest mapped reception, deduplicates and expires", () => { + const chatter = new LiveChatter(); + chatter.observe(event("b",200),graph,1_000); + chatter.message(message,1_100); + chatter.observe(event("a",100),graph,1_200); + expect(chatter.visible(1_300)).toEqual([]); + expect(chatter.visible(1_700)).toMatchObject([{sender:"Alice",content:"hello",nodeId:"a"}]); + chatter.message(message,1_800); + expect(chatter.visible(1_900)).toHaveLength(1); + expect(chatter.visible(1_000+CHAT_MS)).toEqual([]); + }); + it("does not trust colliding channel hashes, unknown keys, sender identities or ambiguous relays", () => { + const chatter = new LiveChatter(); + chatter.message({...message,isPublic:false},100); + chatter.observe(event("a",1),graph,100); + expect(chatter.visible(800)).toEqual([]); + chatter.clear();chatter.message(message,100); + const unknown=event("missing",1);unknown.observation.resolvedPath=[{confidence:"ambiguous",nodes:[{id:"a",publicKey:"a"}]}]; + chatter.observe(unknown,graph,100); + expect(chatter.visible(800)).toEqual([]); + unknown.observation.resolvedPath[0]!.confidence="high"; + chatter.observe(unknown,graph,100); + expect(chatter.visible(800)[0]?.nodeId).toBe("a"); + }); +}); diff --git a/tests/features/topology/topology.test.ts b/tests/features/topology/topology.test.ts new file mode 100644 index 00000000..be1f84cc --- /dev/null +++ b/tests/features/topology/topology.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildTopology, reportFromEvent, LiveTraffic, project, fitCamera, panCamera, pathControls, visibleLinks, DEFAULT_CAMERA, linkContext, loadTopology, loadTopologyRoutes, NODE_CAP, FLOW_CAP, REPORT_CAP } from "../../../src/features/topology/topology"; +import { getTopologyLinks, getNodesPage } from "../../../src/api/client"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { WsPacketObservation } from "../../../src/types/ws"; +import type { KnownRoute, ResolvedHop } from "../../../src/types/api"; + +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getTopologyLinks: vi.fn() })); +const node = (id: string, region = "YOW", scope = "#on"): NodeSummary => ({ id, publicKey: id.repeat(64), name: id, nodeType: 2, nodeTypeName: "repeater", lat: null, lng: null, iatas: [{ iata: region, lastHeard: 1 }], knownNeighborCount: 1, neighborIds: [], defaultScope: scope }); +const a = { ...node("a"), neighborIds: ["b", "b", "a", "missing"] }, b = { ...node("b", "YKF"), neighborIds: ["a"] }, c = node("c"); +const graph = buildTopology([a, b, c]); +const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, publicKey: id }] }); +const event = (path: ResolvedHop[] = [hop("a"), hop("b"), hop("c")]): WsPacketObservation["data"] => ({ packetHash: "hash", packet: { payloadType: 5, payloadTypeName: "GRP_TXT", routeType: 1, routeTypeName: "FLOOD", isFirstObservation: true, observationCount: 1, scope: "#on" }, observation: { observerId: "obs", observerName: "Observer", iata: "YOW", heardAt: 1, rssi: -90, snr: 5, sourceBroker: "fixture", resolvedPath: path } }); + +describe("3D topology evidence and bounds", () => { + it("groups nodes within the selected shared regions despite a newer outside reception", () => { + const multi = { ...a, iatas: [{ iata: "YYZ", lastHeard: 30 }, { iata: "YOW", lastHeard: 20 }, { iata: "YKF", lastHeard: 10 }] }; + expect(buildTopology([multi], ["YOW", "YKF"]).nodes[0]!.region).toBe("YOW"); + expect(buildTopology([multi], ["YKF"]).nodes[0]!.region).toBe("YKF"); + expect(buildTopology([multi]).nodes[0]!.region).toBe("YYZ"); + }); + it("keeps unlocated identities and only supplied neighbour edges, with stable positions", () => { + expect(graph.nodes).toHaveLength(3); + expect(graph.links).toEqual([["a", "b"]]); + expect(buildTopology([c, b, a]).nodes).toEqual(graph.nodes); + expect(graph.nodes.every(n => n.y >= 0 && n.y <= 28)).toBe(true); + expect(linkContext(graph.byId.get("a")!, graph.byId.get("b")!)).toEqual({ crossRegion: true, sharedDefault: "#on" }); + expect(linkContext(graph.byId.get("a")!, { ...graph.byId.get("b")!, defaultScope: "#ON" }).sharedDefault).toBeNull(); + }); + it("does not bridge ambiguous, unloaded, or missing hops, or append reporting observers", () => { + expect(reportFromEvent(event(), graph, 100)?.segments).toEqual([["a", "b"], ["b", "c"]]); + for (const middle of [{ confidence: "ambiguous", nodes: [hop("b").nodes[0]!, hop("c").nodes[0]!] } as ResolvedHop, hop("missing"), { confidence: "none", nodes: [] } as ResolvedHop]) { + const report = reportFromEvent(event([hop("a"), middle, hop("c")]), graph, 100)!; + expect(report.segments).toEqual([]); expect(report.partial).toBe(true); + } + const missing = event(); delete missing.observation.resolvedPath; + expect(reportFromEvent(missing, graph, 100)?.segments).toEqual([]); + const repeat = event(); repeat.packet.isRepeat = true; + expect(reportFromEvent(repeat, graph, 100)).toBeNull(); + const unknown = event(); unknown.packet.payloadType = 99; + expect(reportFromEvent(unknown, graph, 100)?.kind).toBe("other"); + }); + it("deduplicates packet/observer pairs in the live window and bounds report/animation memory", () => { + const traffic = new LiveTraffic(); + const report = reportFromEvent(event(), graph, 100)!; + expect(traffic.add(report)).toBe(true); expect(traffic.add({ ...report, at: 200 })).toBe(false); + expect(traffic.add({ ...report, key: "hash|other", observerId: "other", at: 200 })).toBe(true); + for (let i = 0; i < REPORT_CAP + 10; i++) traffic.add({ ...report, key: `event${i}`, at: 300 }); + expect(traffic.reports).toHaveLength(REPORT_CAP); expect(traffic.flows).toHaveLength(FLOW_CAP); expect(traffic.cappedUntil).toBeGreaterThan(300); + expect(traffic.trails.size).toBe(2); + traffic.prune(5_000); expect(traffic.flows).toEqual([]); expect(traffic.trails.size).toBe(2); + traffic.prune(60_301); expect(traffic.reports).toEqual([]); expect(traffic.flows).toEqual([]); + expect(traffic.trails.size).toBe(0); + expect(traffic.add({ ...report, at: 60_302 })).toBe(true); + }); + it("uses depth perspective and a real orbit without invalid coordinates", () => { + const point = { x: 100, y: 20, z: 50 }; + const front = project(point, DEFAULT_CAMERA, 1000, 700, 500); + const side = project(point, { ...DEFAULT_CAMERA, yaw: 1 }, 1000, 700, 500); + expect(front.x).not.toBe(side.x); expect(front.depth).not.toBe(side.depth); + expect([front.x, front.y, front.scale, side.x, side.y].every(Number.isFinite)).toBe(true); + }); + it("looks down from above: raised nodes are closer and above their ground position", () => { + const point = { x: 50, y: 0, z: 0 }; + const ground = project(point, DEFAULT_CAMERA, 1000, 700, 500); + const raised = project({ ...point, y: 70 }, DEFAULT_CAMERA, 1000, 700, 500); + expect(raised.depth).toBeLessThan(ground.depth); + expect(raised.scale).toBeGreaterThan(ground.scale); + expect(raised.y).toBeLessThan(ground.y); + const top = { ...DEFAULT_CAMERA, pitch: Math.PI / 2 }; + expect(project({ ...point, y: 70 }, top, 1000, 700, 500).scale).toBeGreaterThan(project(point, top, 1000, 700, 500).scale); + }); + it("stops on repeating cursors, passes cancellation, and bounds the expanded snapshot", async () => { + vi.mocked(getNodesPage).mockResolvedValue({ items: [a], nextCursor: 1, hasMore: true }); + const controller = new AbortController(); + const result = await loadTopology(undefined, controller.signal); + expect(result).toEqual({ nodes: [a], capped: true }); expect(getNodesPage).toHaveBeenCalledTimes(2); + vi.mocked(getNodesPage).mockClear().mockImplementation(async (_iatas, params) => ({ items: [a], nextCursor: (params?.cursor ?? 0) + 1, hasMore: true })); + await loadTopology(["YOW"], controller.signal); expect(getNodesPage).toHaveBeenCalledTimes(NODE_CAP / 200); + controller.abort(); await expect(loadTopology(undefined, controller.signal)).rejects.toThrow(); + expect(getNodesPage).toHaveBeenCalledTimes(NODE_CAP / 200); + }); + it("moves linked regions closer while keeping their islands apart", () => { + const input = Array.from({ length: 9 }, (_, i) => node(String(i), `R${i}`)); + const before = buildTopology(input), after = buildTopology(input.map(n => n.id === "0" ? { ...n, neighborIds: ["8"] } : n)); + const distance = (g: typeof graph) => Math.hypot(g.regions[0]!.x - g.regions[8]!.x, g.regions[0]!.z - g.regions[8]!.z); + expect(distance(after)).toBeLessThan(distance(before) * 0.7); + for (let i = 0; i < after.regions.length; i++) for (let j = 0; j < i; j++) { + const a = after.regions[i]!, b = after.regions[j]!; + expect(Math.hypot(a.x - b.x, a.z - b.z)).toBeGreaterThan(a.radius + b.radius + 40); + } + }); + it("keeps more than 2,000 nodes and every supplied connection visible in All paths", () => { + const input = Array.from({ length: 2200 }, (_, i) => ({ ...node(String(i), `R${i % 11}`), neighborIds: Array.from({ length: 3 }, (_, j) => String((i + j + 1) % 2200)) })); + const expanded = buildTopology(input); + expect(expanded.nodes).toHaveLength(2200); expect(expanded.links).toHaveLength(6600); expect(expanded.linksCapped).toBe(false); + expect(visibleLinks(expanded, "all", "R0", "0")).toHaveLength(6600); + const bundled = buildTopology([a, b, c]); expect(bundled.bundles).toEqual([{ from: "YKF", to: "YOW", count: 1 }]); + expect(visibleLinks(bundled, "selected", "", "a")).toEqual([["a", "b"]]); + }); + it("fits distant regions in portrait and landscape and pans around the fitted target", () => { + const points = [{ x: 3000, y: 0, z: -120 }, { x: 3300, y: 40, z: 140 }]; + for (const [width, height] of [[390, 450], [1200, 600]]) { + const camera = fitCamera(points, DEFAULT_CAMERA, width!, height!, 4000); + for (const point of points) { const q = project(point, camera, width!, height!, 4000); expect(q.x).toBeGreaterThanOrEqual(49); expect(q.x).toBeLessThanOrEqual(width! - 49); expect(q.y).toBeGreaterThanOrEqual(49); expect(q.y).toBeLessThanOrEqual(height! - 49); } + const q = project(camera.target, camera, width!, height!, 4000); expect(q.x).toBe(width! / 2); expect(q.y).toBe(height! / 2); + const moved = project(camera.target, panCamera(camera, 30, 20, width!, height!, 4000), width!, height!, 4000); + expect(moved.x).toBeGreaterThan(q.x); expect(moved.y).toBeGreaterThan(q.y); + } + const curve = pathControls(points[0]!, points[1]!, true); expect(curve[0]).toEqual(points[0]); expect(curve[3]).toEqual(points[1]); expect(curve[1].y).toBeGreaterThan(100); + }); + it("adds adjacent saved-route segments without bridging missing identities", async () => { + const route = (id: number, ids: string[], lastSeen: number): KnownRoute => ({ id, iata: "YOW", hopCount: ids.length, hops: ids.map(nodeId => ({ nodeId, hashBytes: "aa" })), firstSeen: 0, lastSeen, observationCount: 1 }); + const routes = [route(1, ["b", "c"], 100_000_000), route(2, ["a", "missing", "c"], 100_000_000)]; + const withRoutes = buildTopology([a, b, c], "", routes); + expect(withRoutes.links).toEqual([["a", "b"], ["b", "c"]]); expect([...withRoutes.routeLinks]).toEqual(["b|c"]); + vi.mocked(getTopologyLinks).mockResolvedValue({ links:[["b","c"]],capped:false,since:0,until:1 }); + const controller = new AbortController(); + expect(await loadTopologyRoutes(["YOW"],controller.signal)).toEqual({routes:[["b","c"]],capped:false}); + expect(getTopologyLinks).toHaveBeenCalledTimes(1); + expect(getTopologyLinks).toHaveBeenCalledWith(["YOW"],"15m",controller.signal); + controller.abort(); await expect(loadTopologyRoutes(undefined, controller.signal)).rejects.toThrow(); + }); + it("isolates multi-region nodes into the requested region", () => { + const multi = { ...a, iatas: [{ iata: "YOW", lastHeard: 1 }, { iata: "YKF", lastHeard: 100 }] }; + expect(buildTopology([multi], "YOW").nodes[0]!.region).toBe("YOW"); + }); + it("loads a complete 24h snapshot in one cancellable request and preserves its cap warning", async () => { + vi.mocked(getTopologyLinks).mockClear().mockResolvedValue({links:[["a","b"]],capped:true,since:0,until:1}); + const signal=new AbortController().signal; + expect(await loadTopologyRoutes(undefined,signal,86_400_000)).toEqual({routes:[["a","b"]],capped:true}); + expect(getTopologyLinks).toHaveBeenCalledExactlyOnceWith(undefined,"24h",signal); + }); + it("does not scan global routes for an empty region", async () => { + vi.mocked(getTopologyLinks).mockClear(); + expect(await loadTopologyRoutes([], new AbortController().signal)).toEqual({ routes: [], capped: false }); + expect(getTopologyLinks).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index f1f2343d..4abc8e9d 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { TraceList } from "../../../src/features/traces/TraceList"; @@ -40,8 +40,8 @@ function renderTraces(onAnalyze = vi.fn(), selection: RegionSelection = ALL_REGI {children} ); - render(, { wrapper }); - return { onAnalyze }; + const { container } = render(, { wrapper }); + return { onAnalyze, container }; } beforeEach(() => { @@ -63,6 +63,90 @@ describe("TraceList", () => { expect(screen.queryByText("Packets")).not.toBeInTheDocument(); }); + it("keeps TRACE and PING cards from shrinking in the scrollable list", async () => { + mockGetTraces.mockResolvedValue([ + tag("3f2a11c0", 2, { traceType: "TRACE", pathHashes: ["a1", "b2"], snrValues: [-7.5, 0] }), + tag("9b40de22", 1, { traceType: "PING", pathHashes: ["c3"], snrValues: [] }), + ]); + + renderTraces(); + + // jsdom cannot measure layout; guard the CSS contract on each row root. + for (const traceTag of ["3F2A11C0", "9B40DE22"]) { + const card = (await screen.findByText(traceTag)).closest('[role="button"]'); + expect(card).toHaveClass("shrink-0"); + } + }); + + it("contains a 145-hop preview and expands/collapses it with the report", async () => { + const hashes = Array.from({ length: 145 }, (_, i) => i.toString(16).padStart(8, "0")); + mockGetTraces.mockResolvedValue([ + tag("3f2a11c0", 2, { pathHashes: hashes, snrValues: [0, -7.5] }), + ]); + mockGetTraceDetail.mockResolvedValue(detail); + + const { container, onAnalyze } = renderTraces(); + const card = await screen.findByRole("button", { name: /3F2A11C0/ }); + const preview = within(card).getByText("00000000").closest("div"); + + // jsdom checks the width/wrapping contract, not rendered pixel geometry. + expect(container.firstElementChild).toHaveClass("min-w-0", "max-w-full"); + expect(card).toHaveClass("shrink-0", "min-w-0", "max-w-full"); + expect(preview).toHaveClass("min-w-0", "max-w-full", "flex-wrap"); + expect(preview).not.toHaveClass("whitespace-nowrap"); + expect(card).toHaveAttribute("aria-expanded", "false"); + expect(within(card).getByText(/145 hops/)).toBeInTheDocument(); + expect(within(card).getByText("00000005")).toBeInTheDocument(); + expect(within(card).queryByText("00000006")).not.toBeInTheDocument(); + expect(within(card).getByText("… +139")).toBeInTheDocument(); + expect(within(card).getByText("0.00 dB")).toBeInTheDocument(); + expect(mockGetTraceDetail).not.toHaveBeenCalled(); + + fireEvent.click(within(card).getByText("▸")); + expect(card).toHaveAttribute("aria-expanded", "true"); + expect(within(card).getByText(hashes[144].toUpperCase())).toBeInTheDocument(); + expect(within(card).queryByText("… +139")).not.toBeInTheDocument(); + expect(await screen.findByText("Packets")).toBeInTheDocument(); + expect(mockGetTraceDetail).toHaveBeenCalledWith("3f2a11c0"); + expect(onAnalyze).not.toHaveBeenCalled(); + + fireEvent.click(within(card).getByText("▾")); + expect(card).toHaveAttribute("aria-expanded", "false"); + expect(within(card).queryByText(hashes[144].toUpperCase())).not.toBeInTheDocument(); + expect(within(card).getByText("… +139")).toBeInTheDocument(); + expect(screen.queryByText("Packets")).not.toBeInTheDocument(); + + fireEvent.keyDown(card, { key: "Enter" }); + expect(card).toHaveAttribute("aria-expanded", "true"); + expect(await screen.findByText("Packets")).toBeInTheDocument(); + fireEvent.keyDown(card, { key: " " }); + expect(card).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Packets")).not.toBeInTheDocument(); + }); + + it("expands only the selected row and collapses it when its report closes", async () => { + mockGetTraces.mockResolvedValue([ + tag("3f2a11c0"), + tag("9b40de22", 1, { traceType: "PING", pathHashes: ["a1"], snrValues: [] }), + ]); + mockGetTraceDetail.mockImplementation(async (traceTag) => ({ ...detail, traceTag })); + + renderTraces(); + const first = await screen.findByRole("button", { name: /3F2A11C0/ }); + const second = screen.getByRole("button", { name: /9B40DE22/ }); + fireEvent.click(first); + expect(await screen.findByText("Packets")).toBeInTheDocument(); + expect(first).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(second); + expect(first).toHaveAttribute("aria-expanded", "false"); + expect(second).toHaveAttribute("aria-expanded", "true"); + await waitFor(() => expect(mockGetTraceDetail).toHaveBeenCalledWith("9b40de22")); + fireEvent.click(screen.getByRole("button", { name: "Close detail panel" })); + expect(second).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Packets")).not.toBeInTheDocument(); + }); + it("opens the detail panel with a Packets section listing the trace's packets when a card is clicked", async () => { mockGetTraces.mockResolvedValue([tag("3f2a11c0", 2)]); mockGetTraceDetail.mockResolvedValue(detail); @@ -198,7 +282,7 @@ describe("TraceList", () => { mockGetTraceDetail.mockResolvedValue(detail); renderTraces(); expect(await screen.findByText("1 étiquette")).toBeInTheDocument(); - expect(screen.getByText("2 paq. · 1 iata")).toBeInTheDocument(); + expect(screen.getByText("2 paq. · 1 iata · 0 sauts")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Tous" })).toBeInTheDocument(); fireEvent.click(screen.getByText("3F2A11C0")); expect(await screen.findByText("Paquets")).toBeInTheDocument(); @@ -231,3 +315,23 @@ it("stops waiting once the region list loads without the selected region", async await waitFor(() => expect(mockGetTraces).toHaveBeenCalled()); }); + +it("hides suspect and ambiguous tags by default, with an explicit evidence toggle", async () => { + mockGetTraces.mockResolvedValue([ + tag("11111111",1,{traceType:"PING", quality:{status:"supported",reasons:[]}}), + tag("a0595008",2,{quality:{status:"suspect",reasons:["unsupported_version","too_many_hops"]}}), + tag("33333333",1,{quality:{status:"ambiguous",reasons:["ambiguous_prefix"]}}), + ]); + renderTraces(); + expect(await screen.findByText("11111111")).toBeInTheDocument(); + expect(screen.queryByText("A0595008")).not.toBeInTheDocument(); + expect(screen.queryByText("33333333")).not.toBeInTheDocument(); + expect(screen.getByText(/2 hidden in loaded sample/)).toBeInTheDocument(); + const toggle=screen.getByRole("checkbox",{name:"Show possible collision related observations"}); + fireEvent.click(toggle); + expect(screen.getByText("A0595008")).toBeInTheDocument(); + expect(screen.getByText("Suspect observation")).toHaveAttribute("class"); + expect(screen.getByText("Possible prefix collision")).toBeInTheDocument(); + fireEvent.click(toggle); + expect(screen.queryByText("A0595008")).not.toBeInTheDocument(); +}); diff --git a/tests/hooks/region-selection.test.ts b/tests/hooks/region-selection.test.ts index 8b66ace5..23e905c0 100644 --- a/tests/hooks/region-selection.test.ts +++ b/tests/hooks/region-selection.test.ts @@ -66,6 +66,13 @@ describe("regionKey", () => { }); describe("parseSelection", () => { + it("migrates old Topology links while giving the shared filter precedence", () => { + expect(parseSelection(new URLSearchParams("tab=Topology&topoRegion=yow"))).toEqual({ regions: [], iatas: ["YOW"] }); + expect(parseSelection(new URLSearchParams("tab=Topology&topoRegion=YOW&iata=YKF"))).toEqual({ regions: [], iatas: ["YKF"] }); + expect(parseSelection(new URLSearchParams("tab=Topology&topoRegion=YOW®ions=west"))).toEqual({ regions: ["west"], iatas: [] }); + expect(selectionToParams(ALL_REGIONS, new URLSearchParams("tab=Topology&topoRegion=YOW")).has("topoRegion")).toBe(false); + }); + it("reads ?iata as a comma-separated, upper-cased IATA list", () => { expect(parseSelection(new URLSearchParams("iata=YVR,yyj"))).toEqual({ regions: [], iatas: ["YVR", "YYJ"] }); }); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 328fdd6e..020e81ca 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Topology", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Topology", "Routes", "Traces", "Analytics", ]); });