diff --git a/package-lock.json b/package-lock.json index 9df24e4..dab63ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.1.0-dev1.4", + "version": "2.1.0-dev1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.1.0-dev1.4", + "version": "2.1.0-dev1.5", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index e8f1223..cb49daf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.1.0-dev1.4", + "version": "2.1.0-dev1.5", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index b648d4b..8c1f67c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import type { PacketDetail } from "./types/api"; // Map (maplibre-gl, ~1MB) and My Atlas are lazy-loaded so their chunks are fetched on first visit // instead of bloating the initial bundle. 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. @@ -89,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]); @@ -304,6 +311,7 @@ function AppInner() { const tabContent: Record = { MyAtlas: , + Topology: , Packets: ( { + 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/BottomNav.tsx b/src/components/BottomNav.tsx index 36fb5a7..b913423 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -6,7 +6,7 @@ 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", "MyAtlas"] as const; +const OVERFLOW_TABS = ["Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas"] 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 }; diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index cfc1a4b..f9d19a2 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; // Confirmed default Public decryption key, never inferred from the hash. id: number; packetHash: string; channelHash: string; diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 8e2e0e8..fc95f67 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -1,5 +1,7 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; + +import { isQuestionable } from "../traces/trace-quality"; import { PayloadType } from "../../types/enums"; import { packetChain, locatedHopNode } from "./packet-flow"; @@ -73,8 +75,12 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); } } - if (isTrace && detail.resolvedRoute) { - add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute)); + + if (isTrace && detail.resolvedRoute && !isQuestionable(detail.traceQuality)) { + // Payload hashes describe the requested route; only consumed SNR hops are observed. + const payload = typeof detail.parsedPayload === "object" ? detail.parsedPayload : undefined; + const consumed = Array.isArray(payload?.snrValues) ? payload.snrValues.length : 0; + add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute.slice(0, consumed))); } // fastest first; missing propagation (incl. the trace route) sorts last diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx index 6f155f3..575b5e0 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 5eaba78..57d15be 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"; @@ -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 892d4e1..d78e771 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 d7706b7..42eb5e4 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/topology/TopologyCanvas.tsx b/src/features/topology/TopologyCanvas.tsx new file mode 100644 index 0000000..5937321 --- /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 0000000..7ecc240 --- /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 0000000..58d9b39 --- /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 0000000..a307c8f --- /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 0000000..5ed21df --- /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 114119b..bc5d84f 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 b1d6765..306e280 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -13,8 +13,10 @@ 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; interface TraceListProps { @@ -78,6 +80,7 @@ function TraceTagCard({ tag, selected, onSelect }: { {tag.traceTag.toUpperCase()} {/* pings get the primary tint, traces the amber one, so the two read apart at a glance */} {tag.traceType && {tag.traceType}} +
@@ -97,6 +100,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 +121,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 0000000..7f4fa0f --- /dev/null +++ b/src/features/traces/TraceQualityNotice.tsx @@ -0,0 +1,14 @@ +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 0000000..ff20313 --- /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/i18n/locales/en.json b/src/i18n/locales/en.json index 42488bb..906f42b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,7 +14,8 @@ "Routes": "Routes", "Traces": "Traces", "Analytics": "Analytics", - "MyAtlas": "My Atlas" + "MyAtlas": "My Atlas", + "Topology": "Topology" }, "navigation": { "primary": "Primary", @@ -787,6 +788,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", @@ -1014,6 +1032,113 @@ "dragToReorder": "Drag to reorder", "remove": "Remove", "observerPartialCoverage": "Partial history" + }, + "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." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f9281d5..7e3eeb9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -14,7 +14,8 @@ "Routes": "Routes", "Traces": "Traces", "Analytics": "Analyses", - "MyAtlas": "Mon Atlas" + "MyAtlas": "Mon Atlas", + "Topology": "Topologie" }, "navigation": { "primary": "Navigation principale", @@ -770,6 +771,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", @@ -1017,6 +1035,113 @@ "dragToReorder": "Glisser pour réordonner", "remove": "Retirer", "observerPartialCoverage": "Historique partiel" + }, + "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." } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ff13ca8..3a6528f 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", "MyAtlas"] as const; +export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas"] 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 2dc6a42..a395ad8 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 a3311d6..99cb417 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/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index ec0a7af..3ae1e1e 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/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index b426ef6..5a65885 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 9c4da0d..f12d37a 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/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 5ca6433..beaa751 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -39,3 +39,18 @@ it("draws isolated samples and dashed gap connectors without filling missing rea expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); }); + +it("keeps isolated readings visible without connecting across missing hours", () => { + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(0); + const points = [...container.querySelectorAll("[data-sample]")]; + expect(points.map(point => point.getAttribute("x1"))).toEqual(["0", "120"]); +}); + +it("draws a single known value at a finite position, including zero", () => { + const { container } = render(); + const point = container.querySelector("[data-sample]"); + expect(point).not.toBeNull(); + expect(point?.getAttribute("x1")).toBe("60"); + expect(Number.isFinite(Number(point?.getAttribute("y1")))).toBe(true); +}); diff --git a/tests/features/topology/TopologyCanvas.test.ts b/tests/features/topology/TopologyCanvas.test.ts new file mode 100644 index 0000000..e534711 --- /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 0000000..a7e54b8 --- /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 0000000..73ef391 --- /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 0000000..be1f84c --- /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 f1f2343..0115b04 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -231,3 +231,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/lib/constants.test.ts b/tests/lib/constants.test.ts index 505a500..d0ce789 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", "MyAtlas", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas", + "Packets", "Channels", "Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas", ]); });