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