diff --git a/docs/translations.md b/docs/translations.md index 0e55c96..9a5dfd3 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -9,7 +9,9 @@ region/theme controls, connection/retry labels, lazy-page loading, shared analytics section/range controls and chart states, and Traffic, RF / Signal, Paths & Hashes, Scopes and Clock Drift (headings, legends, descriptive chart labels, explanations and exact tables). Shared Timestamp labels and their relative tooltips also follow the selected -language. Other feature pages and general dialogs remain follow-up work. Measurement units +language. Packet reception-evidence controls and path-map controls explain retained +reports, intended TRACE routes, unavailable selections and ambiguous/missing map +locations in both languages. Packet bytes, identifiers and routing values stay unchanged. Other feature pages and general dialogs remain follow-up work. Measurement units and existing UTC/date/number formatting, including automatic chart time labels, are unchanged; selecting French does not mean every screen is translated yet. diff --git a/src/App.tsx b/src/App.tsx index 28fcb79..a664b7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,10 @@ import { PacketPathMapModal } from "./features/map/PacketPathMapModal"; import { NodeTable } from "./features/nodes/NodeTable"; import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel"; import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay"; -import { ObserverTable } from "./features/observers/ObserverTable"; +import { ObserverPage } from "./features/observers/ObserverPage"; +import { ObserverDetailPanel } from "./features/observers/ObserverDetailPanel"; +import { observerDestination } from "./features/observers/observer-navigation"; +import { ModalOverlay } from "./components/ModalOverlay"; import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; @@ -114,11 +117,11 @@ export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore } const handledRef = useRef(false); useEffect(() => { - const detail = analyzerDetail ?? pathLinkDetail; + const detail = analyzerDetail?.packetHash.toLowerCase() === hash?.toLowerCase() ? analyzerDetail : pathLinkDetail; if (!initialPath || !detail || handledRef.current) return; handledRef.current = true; onRestore(detail, initialPath); - }, [initialPath, analyzerDetail, pathLinkDetail, onRestore]); + }, [initialPath, hash, analyzerDetail, pathLinkDetail, onRestore]); return null; } @@ -149,13 +152,15 @@ function TabLoading({ tab }: { tab: string }) { } function AppInner() { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); // The URL is the single source of truth for the active tab — back/forward just work, and an // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); + const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; + const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -165,7 +170,10 @@ function AppInner() { 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 - const [selectedObserverId, setSelectedObserverId] = useState(() => searchParams.get("observer")); + const [quickObserverId, setQuickObserverId] = useState(null); + useEffect(() => { + if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true }); + }, [legacyObserver, searchParams, setSearchParams]); // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) const [overlayNodeId, setOverlayNodeId] = useState(null); // packet analyzer shown as a modal over the node panel (clicking a node's observation row) @@ -185,17 +193,26 @@ function AppInner() { setPathMapInitialKey(key); }, []); - // "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own - const handleViewPath = useCallback((detail: PacketDetail) => { + // Reuse the fetched detail; a report action can isolate its observer's path. + const handleViewPath = useCallback((detail: PacketDetail, key?: string) => { setPathMapDetail(detail); - setPathMapInitialKey(null); + setPathMapInitialKey(key ?? null); }, []); - const handleAnalyze = useCallback((hash: string | null) => { + const selectObservation = useCallback((id: number | null) => { + setSelectedObservationId(id); + setSearchParams(previous => { + const next = new URLSearchParams(previous); + if (id == null) next.delete("observation"); else next.set("observation", String(id)); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const handleAnalyze = useCallback((hash: string | null, observationId?: number) => { // 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"); } + if (hash) { if (n.get("hash") !== hash) n.delete("observation"); n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); if (observationId != null) n.set("observation", String(observationId)); } else n.delete("analyze"); return n; }, { replace: true }); @@ -210,15 +227,16 @@ function AppInner() { if (isMobile) { setSelectedObservationId(null); setSelectedNodeId(null); - setSelectedObserverId(null); + setQuickObserverId(null); } 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"); + if (tab !== "Observers") next.delete("observer"); // stats sub-state shouldn't haunt the URL on other tabs - if (tab !== "Analytics") { + if (tab !== "Analytics" && tab !== "Observers") { next.delete("statsTab"); next.delete("observerId"); next.delete("range"); @@ -231,14 +249,14 @@ function AppInner() { setSelectedNodeId(null); setOverlayNodeId(null); setOverlayPacketHash(null); - setSelectedObserverId(null); + setQuickObserverId(null); setPathMapDetail(null); }, []); // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet // 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") => { + const dropSelectionParam = useCallback((key: "node") => { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete(key); @@ -251,22 +269,15 @@ function AppInner() { dropSelectionParam("node"); }, [dropSelectionParam]); - const handleSelectObserver = useCallback((id: string | null) => { - setSelectedObserverId(id); - if (id === null) dropSelectionParam("observer"); - }, [dropSelectionParam]); - // Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected). const handleViewObserverStats = useCallback( (id: string) => { setOverlayNodeId(null); setOverlayPacketHash(null); + setQuickObserverId(null); setSearchParams((prev) => { const next = new URLSearchParams(prev); - next.set("tab", "Analytics"); - next.set("statsTab", "observer"); - next.set("observerId", id); - return next; + return observerDestination(next, id); }); }, [setSearchParams], @@ -291,7 +302,7 @@ function AppInner() { /> ), Nodes: , - Observers: , + Observers: , Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses @@ -324,10 +335,11 @@ function AppInner() { detail={analyzerDetail} loading={analyzerLoading} selectedObservationId={selectedObservationId} - onSelectObservation={setSelectedObservationId} + onSelectObservation={selectObservation} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} + onViewObserver={setQuickObserverId} + onViewPath={(key) => { if (analyzerDetail) handleViewPath(analyzerDetail, key); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -335,8 +347,7 @@ function AppInner() { nodeId={selectedNodeId} onClose={handleCloseNode} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setSelectedNodeId} onAnalyzePacket={setOverlayPacketHash} @@ -345,10 +356,10 @@ function AppInner() { {overlayNodeId && ( setOverlayNodeId(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setOverlayNodeId} /> @@ -359,13 +370,15 @@ function AppInner() { loading={overlayPacketLoading} onClose={() => setOverlayPacketHash(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} - onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} - inactive={!!pathMapDetail} + onViewPath={(key) => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail, key); }} + inactive={!!pathMapDetail || !!quickObserverId} /> )} + {quickObserverId && setQuickObserverId(null)}> + setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={hash => { setQuickObserverId(null); setOverlayPacketHash(hash); }} /> + } {pathMapDetail && ( { return request(`/traces/${tag}`); } -export function getObserver(observerId: string): Promise { - return request(`/observers/${observerId}`); +export async function getObserver(observerId: string): Promise { + const observer = await request(`/observers/${observerId}`); + // Older servers marshal the stored JSON byte slice as base64 instead of an object. + let metadata: unknown = observer.statusMetadata; + if (typeof metadata === "string") { + try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } + catch { metadata = undefined; } + } + return { ...observer, statusMetadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata as Record : undefined }; } export function getObserverAdverts( @@ -368,8 +375,8 @@ export function getObserverTelemetry( return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); } -export function getObserverActivity(observerId: string, range: string, interval: string): Promise { - return request(`/observers/${observerId}/activity`, { range, interval }); +export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise { + return request(`/observers/${observerId}/activity`, { range, interval, until }); } // Lets a caller hide a feature the server doesn't have rather than show it as failed. diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index b421868..ef41217 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -111,7 +111,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { if (!map.getLayer(NODE_LABEL_LAYER)) { map.addLayer({ id: NODE_LABEL_LAYER, type: "symbol", source: NODE_SOURCE, - layout: { "text-field": ["get", "label"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, + layout: { "text-field": ["get", "label"], "text-font": ["Noto Sans Regular"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, paint: { "text-color": paletteVar("--palette-text-bright", "#e5e7eb"), "text-halo-color": paletteVar("--palette-bg-base", "#0a0a0a"), diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index e9c5d4b..27fcec0 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { PacketDetail } from "../../types/api"; import { ModalOverlay } from "../../components/ModalOverlay"; import { CloseButton } from "../../components/CloseButton"; @@ -34,11 +35,12 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { onClose: () => void; initialSelectedKey?: string | null; }) { + const { t } = useTranslation(); const paths = useMemo(() => buildPacketPaths(detail), [detail]); const [selectedKey, setSelectedKey] = useState( - // deep-link value that matches a known path isolates it; anything else (incl. "all") shows All - () => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null), + () => initialSelectedKey && initialSelectedKey !== "all" ? initialSelectedKey : null, ); + const unavailable = selectedKey != null && !paths.some(p => p.key === selectedKey); const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []); useEffect(() => { @@ -50,33 +52,36 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { }, [onClose]); return ( - +
- Packet Path + {t("investigation.mapTitle")}
({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })} - ariaLabel="Copy path link" + params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null, observation: null })} + label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPath")} /> - +
+

{t("investigation.mapNote")}

+ {unavailable &&

{t("investigation.unavailableMap")}

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

{t("investigation.unmappable")}

}
- setSelectedKey(null)} /> + setSelectedKey(null)} />
{paths.map((p) => ( setSelectedKey(p.key)} /> diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index a4384df..6aa4789 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -8,8 +8,8 @@ import { hasMapLocation } from "./location"; // The full chain for one observation: source → relay hops → destination. Both maps plot one marker // per hop, so an ambiguous endpoint (a 1-byte prefix matching several candidate nodes) would force // us to guess which node actually sent or received the packet — only plot endpoints the backend -// resolved unambiguously. Relay hops carry no such gate; they fall back to their first located -// candidate. WS types the endpoints nullable where REST leaves them optional, hence both here. +// resolved unambiguously. Rendering also checks relay confidence and location. WS types the +// endpoints nullable where REST leaves them optional, hence both here. export function packetChain( source: ResolvedHop | null | undefined, path: ResolvedHop[], @@ -19,14 +19,20 @@ export function packetChain( return [confident(source), ...path, confident(destination)].filter((hop): hop is ResolvedHop => hop != null); } -// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. The dot -// rides these coords and flashes each node as it crosses. +export function locatedHopNode(hop: ResolvedHop) { + if (hop.confidence !== "high" || hop.nodes.length !== 1) return undefined; + const node = hop.nodes[0]!; + return hasMapLocation({ lat: node.latitude, lng: node.longitude }) ? node : undefined; +} + +// Animate only a completely located, unambiguous chain; skipping a hop would invent a link. export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] { const seen = new Set(); const out: { id: string; lng: number; lat: number }[] = []; for (const hop of resolvedPath) { - const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); - if (node && !seen.has(node.id)) { + const node = locatedHopNode(hop); + if (!node) return []; + if (!seen.has(node.id)) { seen.add(node.id); out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); } diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 2b39775..8e2e0e8 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -1,14 +1,14 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; import { PayloadType } from "../../types/enums"; -import { packetChain } from "./packet-flow"; -import { hasMapLocation } from "./location"; +import { packetChain, locatedHopNode } from "./packet-flow"; export interface PathPoint { id: string; name?: string; lng: number; lat: number; + breakBefore?: boolean; } export interface PacketPath { @@ -32,17 +32,19 @@ export const PATH_COLORS: string[] = [ "#a78bfa", // violet ]; -// The located nodes on a resolved path — first candidate per hop that has coords, deduped by id, in -// order. Modelled on resolvedPathNodes() in packet-flow.ts, but keeps each node's name for labels. +// Keep known locations, but break lines at ambiguous/unlocated hops instead of inventing a link. function pathPoints(hops: ResolvedHop[]): PathPoint[] { const seen = new Set(); const out: PathPoint[] = []; + let gap = false; for (const hop of hops) { - const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); - if (node && !seen.has(node.id)) { + const node = locatedHopNode(hop); + if (!node) { gap = true; continue; } + if (!seen.has(node.id)) { seen.add(node.id); - out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude! }); - } + out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude!, ...(gap && out.length ? { breakBefore: true } : {}) }); + gap = false; + } else gap = out.at(-1)?.id !== node.id; } return out; } @@ -66,7 +68,7 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { // lines would just duplicate the single "Trace route" below — draw only that one for traces. if (!isTrace) { for (const obs of detail.observations) { - // full chain: source → relay hops → destination; missing/unlocated hops drop out in pathPoints. + // Full chain: source → relay hops → destination, with gaps retained by pathPoints. const chain = packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination); add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); } @@ -105,11 +107,16 @@ export function packetPathsToFeatures( const bounds: [number, number][] = []; for (const path of shown) { - lines.push({ - type: "Feature", - properties: { key: path.key, color: path.color }, - geometry: { type: "LineString", coordinates: path.points.map((p) => [p.lng, p.lat]) }, - }); + let segment: [number, number][] = []; + const finishSegment = () => { + if (segment.length > 1) lines.push({ type: "Feature", properties: { key: path.key, color: path.color }, geometry: { type: "LineString", coordinates: segment } }); + segment = []; + }; + for (const point of path.points) { + if (point.breakBefore) finishSegment(); + segment.push([point.lng, point.lat]); + } + finishSegment(); path.points.forEach((pt, i) => { const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid"; points.push({ diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx index 514c949..30bad75 100644 --- a/src/features/nodes/NodeDetailOverlay.tsx +++ b/src/features/nodes/NodeDetailOverlay.tsx @@ -4,22 +4,23 @@ import { ModalOverlay } from "../../components/ModalOverlay"; // Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is // (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet. -export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: { +export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode, inactive = false }: { nodeId: string; + inactive?: boolean; onClose: () => void; onViewObserver: (observerId: string) => void; onViewNode?: (nodeId: string) => void; }) { useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + if (!inactive && e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose]); + }, [onClose, inactive]); return ( - + ); diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx new file mode 100644 index 0000000..7d27770 --- /dev/null +++ b/src/features/observers/ObserverComparison.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverComparison } from "../../api/client"; +import { formatBattery, formatUptime } from "../../lib/formatters"; +import { ObserverPicker } from "./ObserverPicker"; +import { Card, ChartCard } from "../stats/cards"; +import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; +import { useChartColors } from "../stats/chartTheme"; +import { heardOption } from "../stats/chartOptions"; +import { fillActivity, intervalToMs } from "../stats/transforms"; +import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; +import type { Observer } from "./types"; + +const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +function statusNoise(observer: Observer) { + const stats = observer.statusMetadata?.stats; + return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—"; +} + +export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { + observerA: Observer; activityA?: ObserverActivity; range: StatsRange; observerBId: string; until: number | null; + onSelect: (id: string) => void; onRefresh: () => number; +}) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); + const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const b = useObserver(valid ? observerBId : null); + const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); + const dataB = activityB.isError ? undefined : activityB.data; + const intervalMs = intervalToMs(activityParamsFor(range).interval)!; + const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs; + const start = end - RANGE_MS[range]; + const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs); + const overlap = useQuery({ + queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end], + queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal), + enabled: valid && aligned && !!b.data && !b.isError, + staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, + }); + const option = useMemo(() => { + const window = { start, end }; + const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window) : []; + const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window) : []; + return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, + legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, + series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({ + name: name as string, type: "line" as const, symbol: "none", connectNulls: false, + data: (points as typeof aPoints).map(p => [p.t, p.observations]), + lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, + })), + }; + }, [activityA, dataB, aligned, intervalMs, start, end, colors]); + const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + const groups = overlap.data && !overlap.isError ? [ + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, + ] : []; + const refresh = () => { + const nextUntil = onRefresh(); + if (valid) { + void b.refetch(); + if (nextUntil === until) { + void activityB.refetch(); + if (aligned) void overlap.refetch(); + } + } + }; + const rows = aligned && b.data ? [ + [t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)], + [t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], + [t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)], + [t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)], + [t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)], + [t("observerPage.noise"), statusNoise(observerA), statusNoise(b.data)], + ] : []; + return
+

{t("observerCompare.title")}

+
+

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

+ + {until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

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

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

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

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> +

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

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

{t("observerCompare.retention")}

+ {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <> +

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

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

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} +
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+

{t("observerCompare.definition")}

+ } + + } +
; +} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 1dd9c43..afe0f82 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Observer, AdvertObservation } from "./types"; import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverAdverts } from "../../api/client"; @@ -106,6 +107,7 @@ interface ObserverDetailPanelProps { } export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) { + const { t } = useTranslation(); const { data: observer, isLoading } = useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId), @@ -155,7 +157,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
- +
{observer.observerType && {observer.observerType}} @@ -173,7 +175,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi - Statistics + {t("observerPage.open")} )} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx new file mode 100644 index 0000000..9a449f5 --- /dev/null +++ b/src/features/observers/ObserverPage.tsx @@ -0,0 +1,72 @@ +import { lazy, Suspense, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { CopyButton } from "../../components/CopyButton"; +import { useTick } from "../../hooks/useTick"; +import { ObserverTable } from "./ObserverTable"; +import { observerDestination, observerRange } from "./observer-navigation"; +import type { WsManager } from "../../api/ws-manager"; +const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); + +export function ObserverPage({ wsManager }: { wsManager: WsManager }) { + const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); + const now = useTick(60_000); + const id = params.get("observer"); + const [directoryVisited, setDirectoryVisited] = useState(!id); + const [actionTime, setActionTime] = useState(() => Date.now()); + const hourAt = (time: number) => Math.floor(time / 3_600_000) * 3_600_000; + const comparing = params.has("compareWith"); + const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : hourAt(actionTime); + const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= Math.max(now, actionTime); + const until = validAnchor ? anchor : null; + if (!id && !directoryVisited) setDirectoryVisited(true); + const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); + if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); + const range = observerRange(params.get("range")); + const select = (observer: string | null) => setParams(observerDestination(params, observer, range)); + const compare = (observer: string) => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareWith", observer); + next.set("compareUntil", String(comparing ? until ?? hourAt(clickedAt) : hourAt(clickedAt))); + return next; + }); + }; + const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }); + const refreshCompare = () => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareUntil", String(hourAt(clickedAt))); + return next; + }, { replace: true }); + return hourAt(clickedAt); + }; + return
+ {/* Keep the directory mounted so Back restores filters, sorting and scroll. */} + {directoryVisited &&
+ +
} + {id &&
+
+ + + + +
+
+ {t("common.loading")}

}> + +
+
+
} +
; +} diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx new file mode 100644 index 0000000..39b5fbf --- /dev/null +++ b/src/features/observers/ObserverPicker.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; + +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const { iatas, regionKey } = useRegion(); + const [text, setText] = useState(""); + const [query, setQuery] = useState(""); + useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]); + const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 }); + const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); + return
+
+ setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" /> + +
+ {options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

} +
; +} diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx new file mode 100644 index 0000000..fb4ee1b --- /dev/null +++ b/src/features/observers/ObserverSummary.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import { CopyButton } from "../../components/CopyButton"; +import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters"; +import { useTick } from "../../hooks/useTick"; +import type { Observer } from "./types"; +import type { ObserverActivity, TelemetryPoint } from "../stats/types"; + +export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) { + const { t, i18n } = useTranslation(); const now = useTick(); + const summary = activity?.summary; + const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; + const lastArrival = Math.max(summary?.latestRecordedAt ?? 0, ...observer.brokers.map(b => b.lastPacketAt || 0)); + const trafficKnown = !!summary && lastArrival > 0; + const trafficFresh = trafficKnown && now - lastArrival < 300_000; + const stats = observer.statusMetadata?.stats; + const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; + const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; + const cards = [ + ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastHour", summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastPacket", summary?.latestRecordedAt != null ? : "—"], + ["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"], + ["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"], + ["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"], + ] as const; + return <> +
+

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

+
+ {t(`observerPage.${!trafficKnown ? "trafficUnknown" : trafficFresh ? "trafficRecent" : "trafficQuiet"}`)} + {t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} + {observer.iata} +
+
+
+
    + {cards.map(([key, value]) =>
  • +
    {t(`observerPage.${key}`)}
    +
    {value}
    + {key === "lastHour" && summary &&
    {new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC
    } + {key === "noise" && noise != null &&
    {t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}
    } +
  • )} +
+ {!summary && !pending &&

{t("observerPage.summaryMissing")}

} + ; +} + +export function ObserverDeviceDetails({ observer }: { observer: Observer }) { + const { t } = useTranslation(); + const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); + return
+ {t("observerPage.details")} +
+ {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) =>
{t(`observerPage.${key}`)}
{value ?? "—"}
)} +
{t("observerPage.publicKey")}
{observer.publicKey}
+
{t("observerPage.firstSeen")}
+
+
    {observer.brokers.map(b =>
  • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
  • )}
+
; +} diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts new file mode 100644 index 0000000..ca9d5f3 --- /dev/null +++ b/src/features/observers/observer-navigation.ts @@ -0,0 +1,12 @@ +import type { StatsRange } from "../stats/types"; + +export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; + +export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { + const next = new URLSearchParams(params); + next.set("tab", "Observers"); + for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation"]) next.delete(key); + if (id) { next.set("observer", id); next.set("range", range); } + else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); } + return next; +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 18a7fb3..4cb66d8 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import type { PacketDetail } from "../../types/api"; @@ -15,6 +16,8 @@ import { PayloadBreakdown } from "./payload-renderers"; import { ObservationCard } from "./ObservationCard"; import { PathData } from "./PathData"; import { buildPacketPaths } from "../map/packet-path"; +import { PacketInvestigation } from "./PacketInvestigation"; +import { reportSelection } from "./packet-investigation"; function decodePayloadHex(encoded: string): string | null { try { @@ -33,16 +36,19 @@ interface PacketAnalyzerDrawerProps { onClose: () => void; onSelectObservation?: (id: number) => void; onViewNode?: (nodeId: string) => void; - onViewPath?: () => void; + onViewPath?: (key?: string) => void; + onViewObserver?: (id: string) => void; loading?: boolean; } // side panel (full-screen on mobile) showing packet structure and payload breakdown -export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, loading }: PacketAnalyzerDrawerProps) { - const [, setSearchParams] = useSearchParams(); +export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, onViewObserver, loading }: PacketAnalyzerDrawerProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const { t } = useTranslation(); const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); + const observerCount = new Set(detail?.observations.map(o => o.observerId)).size; // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { @@ -54,11 +60,14 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o onClose(); }, [setSearchParams, onClose]); - const selectedObs = detail?.observations.find((o) => o.id === selectedObservationId) - ?? detail?.observations[0] - ?? null; + const urlMatches = detail != null && searchParams.get("hash")?.toLowerCase() === detail.packetHash.toLowerCase(); + const { selected: selectedObs, unavailable } = reportSelection(detail?.observations ?? [], urlMatches ? searchParams : new URLSearchParams(), selectedObservationId); + const selectReport = (id: number) => { + if (urlMatches) setSearchParams(prev => { const next = new URLSearchParams(prev); next.set("observation", String(id)); return next; }, { replace: true }); + onSelectObservation?.(id); + }; - const rawHex = detail ? buildObservationFrame(detail, selectedObs) : ""; + const rawHex = detail && !unavailable ? buildObservationFrame(detail, selectedObs) : ""; const totalBytes = rawHex.length / 2; const fieldRanges = detail @@ -72,7 +81,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
Packet Analyzer
- {detail && } + {detail && ({ tab: "Packets", hash: detail.packetHash, analyze: "1", path: null, node: null, observer: null, observerId: null, statsTab: null, compareWith: null, compareUntil: null, observation: selectedObs ? String(selectedObs.id) : null })} label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPacket")} />}
@@ -99,39 +108,42 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.scope && {detail.scope}} - ×{detail.observations.length} + ×{observerCount}
- First + {t("investigation.first")} · - Last + {t("investigation.last")} · - Propagation {formatPropagation(detail.firstToLastMs)} + {t("investigation.span")} {formatPropagation(detail.firstToLastMs)}
+ {unavailable &&

{t("investigation.unavailableReport")}

} + +
@@ -149,7 +161,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o key={obs.id} observation={obs} selected={selectedObs?.id === obs.id} - onClick={onSelectObservation ? () => onSelectObservation(obs.id) : undefined} + onClick={() => selectReport(obs.id)} onViewNode={onViewNode} isTrace={detail.header.payloadType === PayloadType.TRACE} /> diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx index ec10bf8..036512a 100644 --- a/src/features/packets/PacketAnalyzerOverlay.tsx +++ b/src/features/packets/PacketAnalyzerOverlay.tsx @@ -11,7 +11,7 @@ export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver loading?: boolean; onClose: () => void; onViewObserver: (observerId: string) => void; - onViewPath?: () => void; + onViewPath?: (key?: string) => void; inactive?: boolean; }) { const [selectedObservationId, setSelectedObservationId] = useState(null); @@ -37,6 +37,7 @@ export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver onSelectObservation={setSelectedObservationId} onClose={onClose} onViewNode={setViewNodeId} + onViewObserver={onViewObserver} onViewPath={onViewPath} /> diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 6978839..cd9e5df 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,6 +1,6 @@ -import type { PacketSummary } from "../../types/api"; +import type { PacketSummary, ResolvedHop } from "../../types/api"; import { PayloadType, type PathConfidence } from "../../types/enums"; -import { buildPathSummary, type PathChip } from "./path-summary"; +import { HopPopover } from "./PathData"; // Same three-state vocabulary PathData uses in the analyzer. const CONFIDENCE_CLASSES: Record = { @@ -9,35 +9,31 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; -function Chip({ chip }: { chip: PathChip }) { - if (chip.kind === "hex") { - return {chip.label}; - } - if (chip.kind === "unresolved-run") { - return ( - - {chip.count === 1 ? "?" : `?×${chip.count}`} - - ); - } +function Chip({ hop }: { hop: ResolvedHop }) { + const node = hop.nodes[0]; + const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; return ( - - {chip.label} - + + + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} + + ); } 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; there the server's summary (advert name, trace tag, ack checksum) stands in. +// Keep every candidate: a short endpoint hash can match several nodes. export function PacketEndpoints({ packet }: { packet: PacketSummary }) { - const { source, destination } = buildPathSummary(packet); + const source = packet.latestObserver?.resolvedSource; + const destination = packet.latestObserver?.resolvedDestination; // An advert is the node announcing itself, so its name is the whole story — no destination. if (packet.payloadType === PayloadType.ADVERT) { - const self = source ?? (packet.summary ? { kind: "node" as const, label: packet.summary, confidence: "high" as const } : null); - return self ? : ; + if (source) return ; + return packet.summary + ? {packet.summary} + : ; } if (!source && !destination) { @@ -48,9 +44,9 @@ export function PacketEndpoints({ packet }: { packet: PacketSummary }) { return ( - {source ? : } + {source ? : } → - {destination ? : } + {destination ? : } ); } diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index 17374f2..cb15a00 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -18,7 +18,7 @@ const ACTION_BUTTON_CLASS = interface Props { packet: PacketSummary; - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -43,7 +43,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb const handleSelectObservation = useCallback( (id: number) => { onSelectObservation(id); - onOpenAnalyzer(); + onOpenAnalyzer(id); }, [onSelectObservation, onOpenAnalyzer], ); diff --git a/src/features/packets/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx new file mode 100644 index 0000000..6ab5d39 --- /dev/null +++ b/src/features/packets/PacketInvestigation.tsx @@ -0,0 +1,49 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import type { PacketDetail } from "../../types/api"; +import { PayloadType } from "../../types/enums"; +import { groupPacketReports } from "./packet-investigation"; +import { buildPacketPaths } from "../map/packet-path"; + +const actionClass = "min-h-9 rounded border border-border px-2 text-xs text-primary hover:bg-bg-raised disabled:opacity-40 disabled:cursor-not-allowed"; + +export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserver, onViewPath }: { + detail: PacketDetail; selectedId: number | null; onSelect: (id: number) => void; + onViewObserver?: (id: string) => void; onViewPath?: (key?: string) => void; +}) { + const { t } = useTranslation(); + const groups = useMemo(() => groupPacketReports(detail.observations), [detail.observations]); + const mapped = useMemo(() => new Set(buildPacketPaths(detail).map(p => p.key)), [detail]); + const observerCount = new Set(detail.observations.map(o => o.observerId)).size; + const isTrace = detail.header.payloadType === PayloadType.TRACE; + const [showAll, setShowAll] = useState(false); + const visible = showAll ? groups : groups.filter((group, index) => index < 3 || group.reports.some(o => o.id === selectedId)); + return
+

{t("investigation.title")}

+

{t("investigation.reports", { count: detail.observations.length })} · {t("investigation.observers", { count: observerCount })}

+

{t(isTrace ? "investigation.traceNote" : "investigation.pathNote")}

+ {groups.length === 0 ?

{t("investigation.empty")}

: visible.map(group =>
o.id === selectedId)} className="rounded border border-border bg-bg-base p-2"> + + {t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })} + ({t("investigation.reports", { count: group.reports.length })}) + {group.hashes.length > 0 && {group.hashes.join(" → ")}} + +
    {group.reports.map(report => { + const key = isTrace ? "trace" : report.observerId; + const canMap = mapped.has(key); + return
  • +

    {report.observerName ?? report.observerId.slice(0, 8)} · {report.iata || "—"} ·

    +
    + + {onViewObserver && } + +
    + {!canMap &&

    {t("investigation.unmappable")}

    } +
  • ; + })}
+
)} + {groups.length > 3 && } +

{t("investigation.retention")}

+
; +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 2321faa..3d179b5 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -32,7 +32,7 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; - onAnalyze: (hash: string | null) => void; + onAnalyze: (hash: string | null, observationId?: number) => void; onViewPath: (detail: PacketDetail) => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -87,6 +87,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati const next = expandedHash === hash ? null : hash; setSearchParams((p) => { const n = new URLSearchParams(p); + n.delete("observation"); if (next) n.set("hash", next); else n.delete("hash"); return n; }, { replace: true }); @@ -96,8 +97,10 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati const { data: expandedDetail, isError: selectedError, error: detailError, refetch: retryDetail } = usePacketDetail(expandedHash); const detailStatus = detailError && "status" in detailError ? detailError.status : undefined; - const handleOpenAnalyzer = useCallback(() => { - if (expandedHash) onAnalyze(expandedHash); + const handleOpenAnalyzer = useCallback((observationId?: number) => { + if (!expandedHash) return; + if (observationId == null) onAnalyze(expandedHash); + else onAnalyze(expandedHash, observationId); }, [expandedHash, onAnalyze]); const handleViewPath = useCallback(() => { @@ -192,7 +195,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati ) : expandedDetail ? ( <>

Selected packet is outside the loaded results.

- + ) :

Loading selected packet…

}
diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index e2d64a2..6fd1d59 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -23,7 +23,7 @@ interface PacketVirtualListProps { expandedHash: string | null; onToggleExpand: (hash: string) => void; // only the expanded row renders an expansion, so these need no hash argument - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 90f20e1..59db7f9 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -17,7 +17,7 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -function HopPopover({ hop, onViewNode, showSnr = true, children }: { +export function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; @@ -94,7 +94,8 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - className="inline-flex" + title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} + className="inline-flex min-w-0 max-w-full" > {children} {anchor && diff --git a/src/features/packets/packet-investigation.ts b/src/features/packets/packet-investigation.ts new file mode 100644 index 0000000..2ac2a19 --- /dev/null +++ b/src/features/packets/packet-investigation.ts @@ -0,0 +1,39 @@ +import type { Observation } from "../../types/api"; + +export interface ReportGroup { + key: string; + kind: "path" | "empty" | "unavailable"; + hashes: string[]; + reports: Observation[]; +} + +// Equal prefixes describe equal recorded bytes, not proof of the same physical route. +export function groupPacketReports(reports: Observation[]): ReportGroup[] { + const groups = new Map(); + for (const report of [...reports].sort((a, b) => a.heardAt - b.heardAt || a.id - b.id)) { + const width = report.pathLength?.hashSize; + const count = report.pathLength?.hopCount; + const raw = (report.pathBytes ?? "").toUpperCase(); + const valid = [1, 2, 3].includes(width) && Number.isInteger(count) && count >= 0 && raw.length === width * count * 2 && /^[0-9A-F]*$/.test(raw); + const kind = !valid ? "unavailable" : count === 0 ? "empty" : "path"; + const key = valid ? `${width}:${count}:${raw}` : `unavailable:${report.id}`; + let group = groups.get(key); + if (!group) { + const hashes: string[] = []; + if (valid) for (let i = 0; i < raw.length; i += width * 2) hashes.push(raw.slice(i, i + width * 2)); + group = { key, kind, hashes, reports: [] }; groups.set(key, group); + } + group.reports.push(report); + } + return [...groups.values()]; +} + +export function reportSelection(reports: Observation[], params: URLSearchParams, fallback: number | null) { + const requested = params.get("observation"); + if (requested != null) { + const id = Number(requested); + const selected = params.getAll("observation").length === 1 && /^[1-9]\d*$/.test(requested) && Number.isSafeInteger(id) ? reports.find(o => o.id === id) ?? null : null; + return { selected, unavailable: selected == null }; + } + return { selected: reports.find(o => o.id === fallback) ?? reports[0] ?? null, unavailable: false }; +} diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts index 8df354d..3fbcf01 100644 --- a/src/features/packets/path-summary.ts +++ b/src/features/packets/path-summary.ts @@ -15,12 +15,10 @@ export interface PathSummary { hopLabel: string; chips: PathChip[]; overflow: number; // hops not represented by a visible chip - source: PathChip | null; - destination: PathChip | null; isNa: boolean; } -const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, isNa: true }; function hopChip(hop: ResolvedHop): PathChip | null { if (hop.confidence === "none") return null; @@ -29,12 +27,6 @@ function hopChip(hop: ResolvedHop): PathChip | null { return { kind: "node", label, confidence: hop.confidence }; } -// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. -function endpointChip(hop: ResolvedHop | undefined): PathChip | null { - if (!hop) return null; - return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; -} - function chipsFromResolved(path: ResolvedHop[]): PathChip[] { const out: PathChip[] = []; for (const hop of path) { @@ -63,9 +55,7 @@ export function buildPathSummary(packet: PacketSummary): PathSummary { if (!observer || !length) return NA; const { hopCount, hashSize } = length; - const source = endpointChip(observer.resolvedSource); - const destination = endpointChip(observer.resolvedDestination); - const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, isNa: false }; if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index a90fe0a..ad2d1a7 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,200 +1,65 @@ -import { useEffect, useMemo, useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Badge } from "../../components/Badge"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; -import { formatBattery, formatCount, formatRadioParts, formatUptime } from "../../lib/formatters"; -import { getObserversPage } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { formatRadioParts } from "../../lib/formatters"; +import { ObserverPicker } from "../observers/ObserverPicker"; +import { ObserverComparison } from "../observers/ObserverComparison"; +import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; -import { useTopObservers } from "./useStats"; import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "../observers/observer-status"; -import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, typeBarOption } from "./chartOptions"; +import { + airtimeOption, + batteryOption, + noiseFloorOption, + queueOption, + receiveErrorsOption, + busyOption, + heardOption, + snrHeardOption, + typeBarOption, +} from "./chartOptions"; import { Card, ChartCard } from "./cards"; import { fillActivity, hasTelemetry, intervalToMs, latestAirtimePct, payloadBarItems } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; import { isNotFound } from "../../api/client"; import type { WsManager } from "../../api/ws-manager"; -import type { Observer } from "../observers/types"; import { RANGE_MS, type StatsRange } from "./types"; -// how the "Heard per …" title reads for each bucket the activity endpoint returns -const PER_BUCKET: Record = { "5m": "5 min", "15m": "15 min", "1h": "hour", "6h": "6 h", "24h": "day" }; - -type AirtimePct = { rx: number | null; tx: number | null }; - -function airtimeLabel(a: AirtimePct): string { +function airtimeLabel(a: { rx: number | null; tx: number | null }): string { return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); } - -function ObserverList({ - range, - selectedId, - onSelect, -}: { - range: StatsRange; - selectedId: string | null; - onSelect: (id: string) => void; -}) { - const { iatas, regionKey } = useRegion(); - const [query, setQuery] = useState(""); - // debounce so the server-side lookup fires once per pause, not once per keystroke - const [q, setQ] = useState(""); - useEffect(() => { - const id = setTimeout(() => setQ(query.trim()), 250); - return () => clearTimeout(id); - }, [query]); - const searching = q.length > 0; - - // default: top observers by activity (with count + activity bar). Searching swaps to a server-side - // name lookup across ALL observers in the region, not just the loaded top rows. - const top = useTopObservers(range, 15); - const max = useMemo(() => Math.max(1, ...(top.data ?? []).map((o) => o.observationCount)), [top.data]); - - const search = useQuery({ - queryKey: ["observer-search", regionKey, q], - queryFn: () => getObserversPage(iatas, { name: q, limit: 50 }), - enabled: searching, - staleTime: 30_000, - placeholderData: keepPreviousData, - }); - - type Row = { id: string; name: string; count?: number; iata?: string; online?: boolean }; - const rows: Row[] = searching - ? (search.data?.items ?? []).map((o) => ({ id: o.id, name: o.displayName ?? o.id.slice(0, 8), iata: o.iata, online: o.status === "online" })) - : (top.data ?? []).map((o) => ({ id: o.observerId, name: o.displayName ?? o.observerId.slice(0, 8), count: o.observationCount })); - - const loading = searching ? search.isLoading : top.isLoading; - - return ( - - setQuery(e.target.value)} - placeholder="Search observers…" - className="mb-2 w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-[12px] text-text-normal placeholder:text-text-dim" - /> -
- {loading &&
{searching ? "Searching…" : "Loading…"}
} - {searching && search.isError && ( -
Search failed
- )} - {!loading && !(searching && search.isError) && rows.length === 0 && ( -
{searching ? "No matches" : "No observers"}
- )} - {rows.map((r) => { - const active = r.id === selectedId; - return ( - - ); - })} -
-
- ); -} - -function ObserverHeader({ observer, airtime }: { observer: Observer; airtime: AirtimePct }) { - useTick(); // keep the recency-derived status badge fresh - const status = deriveObserverStatus(observer); - const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); - - return ( - - {observer.displayName ?? observer.id.slice(0, 8)} - {status} - {observer.observerType && {observer.observerType}} - - } - right={ - {observer.iata} - } - > -
- - - - {(airtime.rx != null || airtime.tx != null) && } - {radio && } -
-
- ); -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ); -} - interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; - onSelectObserver: (observerId: string) => void; + onSelectObserver: (id: string) => void; wsManager: WsManager; + comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => number }; } - -export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { +export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager, comparison }: ObserverTabProps) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); - const topObservers = useTopObservers(range, 15); - const observer = useObserver(selectedObserverId); + const observer = useObserver(selectedObserverId, true); const telemetry = useObserverTelemetry(selectedObserverId, range); - const activity = useObserverActivity(selectedObserverId, range); - - // default to the busiest observer once the list loads and nothing is selected - useEffect(() => { - if (selectedObserverId) return; - const first = topObservers.data?.[0]; - if (first) onSelectObserver(first.observerId); - }, [selectedObserverId, topObservers.data, onSelectObserver]); - + const activity = useObserverActivity(selectedObserverId, range, comparison?.until ?? undefined); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points - const bucketMs = telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; + // Use the response interval so the plotted bucket width always matches the data. + const bucketMs = + telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; const bucketed = bucketMs != null; const airtime = useMemo(() => airtimeOption(points, colors, bucketMs), [points, colors, bucketMs]); - // bucketed values span less than their bucket, so only the hourly series gives an honest header number + // Show the latest airtime percentage only when computed from unbucketed reports. const latestAirtime = useMemo( () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), [points, bucketMs], ); - const battery = useMemo(() => batteryOption(points, colors), [points, colors]); - const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); - const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.battery") + " V"), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noise") + " dBm"), [points, colors, t]); + const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); + const recvErrors = useMemo( + () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), + [points, colors, bucketed, t], + ); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. @@ -204,96 +69,244 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => ready && !points.some((p) => accessors.some((a) => a(p) != null)); - // a 404 means this server has no activity endpoint yet: hide the heard group rather than show failed cards - const heardUnavailable = activity.isError && isNotFound(activity.error); - // a placeholder is the previous selection's data, so treat it as loading rather than read anything from it - const heardLoading = activity.isLoading || activity.isPlaceholderData; - const heardData = activity.isPlaceholderData ? undefined : activity.data; + // A 404 without cached data means this server has no activity endpoint yet. + const heardUnavailable = !activity.data && activity.isError && isNotFound(activity.error); + const heardLoading = activity.isLoading; + const heardData = activity.data; const intervalMs = heardData ? intervalToMs(heardData.interval) : null; // the window ends at the last fetch so the right edge follows now on every poll const heardWindow = useMemo(() => { - const end = activity.dataUpdatedAt; + const end = heardData?.windowEnd ?? activity.dataUpdatedAt; const span = (heardData && intervalToMs(heardData.range)) ?? RANGE_MS[range]; - return { start: end - span, end }; + return { start: heardData?.windowStart ?? end - span, end }; }, [heardData, activity.dataUpdatedAt, range]); const heard = useMemo( () => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow) : []), [heardData, intervalMs, heardWindow], ); - const busy = useMemo(() => busyOption(heard, colors, intervalMs, heardWindow), [heard, colors, intervalMs, heardWindow]); - const heardCount = useMemo(() => heardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const snr = useMemo(() => snrHeardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const payloadItems = useMemo(() => payloadBarItems(heardData?.payloadTypes ?? []), [heardData]); + const busy = useMemo( + () => busyOption(heard, colors, intervalMs, heardWindow), + [heard, colors, intervalMs, heardWindow], + ); + const heardCount = useMemo( + () => heardOption(heard, colors, heardWindow, t("observerPage.packets")), + [heard, colors, heardWindow, t], + ); + const snr = useMemo( + () => + snrHeardOption(heard, colors, heardWindow, { + average: t("observerPage.mean"), + minimum: t("observerPage.minimum"), + }), + [heard, colors, heardWindow, t], + ); + const payloadItems = useMemo( + () => + payloadBarItems(heardData?.payloadTypes ?? []).map((item) => + item.name.toLowerCase() === "unknown" ? { ...item, name: t("observerPage.unknown") } : item, + ), + [heardData, t], + ); const payload = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const nothingHeard = heardData != null && heardData.points.length === 0; const costed = heardData?.points.some((p) => p.airtimeMs != null) ?? false; const heardSnr = heard.some((p) => p.snrAvg != null); const radioLabel = formatRadioParts(heardData?.radio ?? {}); const interval = heardData?.interval ?? activityParamsFor(range).interval; - const perBucket = PER_BUCKET[interval] ?? interval; + const perBucket = + interval === "1h" + ? t("observerPage.hour") + : interval === "24h" + ? t("observerPage.day") + : interval.replace("m", " min").replace("h", " h"); - return ( -
-
- + if (!selectedObserverId) return ; + if (observer.isError) + return ( +
+

{t("observerPage.loadFailed")}

+
- -
- {!selectedObserverId ? ( - - - - ) : ( - <> - {observer.data && } - {noTelemetry ? ( - - - - ) : ( - <> - Airtime TX / RX · {range}} - height={180} - option={airtime} - isLoading={telemetry.isLoading} - isError={telemetry.isError} - isEmpty={missing((p) => p.airtimeTxSecs, (p) => p.airtimeRxSecs)} - /> -
- p.batteryMv)} /> - p.noiseFloorDb)} /> - p.queueLength)} /> - p.receiveErrors)} /> -
- - )} - {!heardUnavailable && - (nothingHeard ? ( - - - - ) : ( - <> - Channel busy · {range}} - right={radioLabel && {radioLabel}} - height={180} - option={busy} - isLoading={heardLoading} - isError={activity.isError} - isEmpty={heardData != null && !costed} - /> -
- - -
- - - ))} - + ); + if (!observer.data) + return ( +

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

+ ); + const date = (value: number) => + new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + return ( +
+ + +
+

{t("observerPage.scopeNote")}

+ {heardData?.windowStart != null && heardData.windowEnd != null && ( +

+ {t("observerPage.window")}: {date(heardData.windowStart)} – {date(heardData.windowEnd)} +

)} +

{t("observerPage.windowHelp")}

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

{t("observerPage.summaryMissing")}

+
+ ) : nothingHeard ? ( + + + + ) : ( + <> +
+ + + +
+ {activity.isError && ( + + )} + {heardData && ( +
+ {t("observerPage.exact")} +
+ + + + + + + + + + + {heardData.points.map((p) => ( + + + + + + + ))} + +
{t("observerPage.time")}{t("observerPage.packets")}SNR (dB)RSSI (dBm)
{date(p.t)}{p.observations.toLocaleString()}{p.snrAvg?.toFixed(1) ?? "—"}{p.rssiAvg?.toFixed(1) ?? "—"}
+
+
+ )} + + )} +

{t("observerPage.device")}

+ {noTelemetry ? ( + +

{t("observerPage.noTelemetryHelp")}

+
+ ) : ( + <> + {airtimeLabel(latestAirtime)}} + height={200} + option={airtime} + isLoading={telemetry.isLoading} + isError={!telemetry.data && telemetry.isError} + isEmpty={missing( + (p) => p.airtimeTxSecs, + (p) => p.airtimeRxSecs, + )} + /> +
+ p.batteryMv)} + /> + p.noiseFloorDb)} + /> + p.queueLength)} + /> + p.receiveErrors)} + /> +
+ {telemetry.isError && ( + + )} + + )} + {!heardUnavailable && costed && ( + {radioLabel}} + height={180} + option={busy} + isLoading={heardLoading} + isError={!heardData && activity.isError} + /> + )} + + ); } diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index aa920f9..cea098c 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -9,12 +9,12 @@ import { PathsTab } from "./PathsTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; -import { ObserverTab } from "./ObserverTab"; +import { observerDestination } from "../observers/observer-navigation"; import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -24,14 +24,13 @@ interface StatsOverviewProps { wsManager: WsManager; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active +// Stats page shell: an analytics sub-header and range over the active // sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); - const observerId = params.get("observerId"); const patch = useCallback( (updates: Record) => { @@ -52,7 +51,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]); const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]); - const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); + const handleSelectObserver = useCallback((id: string) => setParams(observerDestination(params, id)), [params, setParams]); return (
@@ -65,9 +64,6 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } - {tab === "observer" && ( - - )} {tab === "graph" && } {tab === "compare" && }
diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index aaf201f..083bfe3 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -34,15 +34,6 @@ function MeshIcon() { ); } -function ObserverIcon() { - return ( - - - - - ); -} - function SignalIcon() { return @@ -95,7 +86,6 @@ const TAB_OPTIONS = [ { value: "scopes", icon: }, { value: "talkers", icon: }, { value: "clockdrift", icon: }, - { value: "observer", icon: }, { value: "compare", icon: }, { value: "graph", icon: }, ]; diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index b4154fd..e03d085 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -371,18 +371,18 @@ function metricLineOption( }; } -export const batteryOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); +export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Battery V") => + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); -export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb }); +export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => + metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); -export const queueOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); +export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => + metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => - metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => + metricLineOption(p, c, { name, color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); // ---- Observer activity (what it heard) ---- @@ -425,7 +425,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: }; } -export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, name = "Heard"): EChartsOption { return { animation: false, backgroundColor: "transparent", @@ -435,7 +435,7 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind yAxis: valueAxis(c, { minInterval: 1 }), series: [ { - name: "Heard", + name, type: "line", symbol: "none", data: points.map((p) => [p.t, p.observations]), @@ -449,18 +449,18 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—"); -export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, labels = { average: "Avg", minimum: "Min" }): EChartsOption { return { animation: false, backgroundColor: "transparent", grid: { left: 54, right: 14, top: 24, bottom: 22 }, - legend: { data: ["Avg", "Min"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + legend: { data: [labels.average, labels.minimum], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: dbLabel }, xAxis: windowAxis(c, w), yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), series: [ - { name: "Avg", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, - { name: "Min", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, ], }; } diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 0750518..7beff78 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -144,7 +144,19 @@ export interface ActivityRadio { preambleSymbols: number | null; } +export interface ObserverActivitySummary { + recordedPackets: number; + lastCompleteHour: number; + lastCompleteHourStart: number; + lastCompleteHourEnd: number; + latestRecordedAt: number | null; +} export interface ObserverActivity { + windowStart?: number; + windowEnd?: number; + generatedAt?: number; + source?: "raw" | "hourly"; + summary?: ObserverActivitySummary; range: string; interval: string; radio: ActivityRadio | null; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 217f4c3..a65e309 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,4 +1,4 @@ -import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverTelemetry, getObserverActivity, isNotFound } from "../../api/client"; import type { StatsRange } from "./types"; @@ -17,12 +17,13 @@ const INTERVAL_PARAM: Record = { "30d": "24h", }; -export function useObserver(observerId: string | null) { +export function useObserver(observerId: string | null, poll = false) { return useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId!), enabled: !!observerId, staleTime: 30_000, + refetchInterval: poll ? 60_000 : false, refetchOnWindowFocus: false, }); } @@ -43,16 +44,15 @@ export function activityRefetchInterval(error: unknown): number | false { return isNotFound(error) ? false : 60_000; } -export function useObserverActivity(observerId: string | null, range: StatsRange) { +export function useObserverActivity(observerId: string | null, range: StatsRange, until?: number) { const params = activityParamsFor(range); return useQuery({ - queryKey: ["observer-activity", observerId, range], - queryFn: () => getObserverActivity(observerId!, params.range, params.interval), + queryKey: ["observer-activity", observerId, range, until], + queryFn: () => getObserverActivity(observerId!, params.range, params.interval, until), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, - refetchInterval: (query) => activityRefetchInterval(query.state.error), + refetchInterval: (query) => until == null ? activityRefetchInterval(query.state.error) : false, }); } @@ -63,7 +63,6 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts index 803552d..4fd666f 100644 --- a/src/hooks/useTick.ts +++ b/src/hooks/useTick.ts @@ -1,11 +1,11 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; // Forces a re-render on a fixed interval so relative time labels ("2m ago") stay fresh. Backed by one // shared interval per interval-length (module-level), so the many instances across the app // subscribe to a single timer instead of each spinning up its own setInterval. interface Ticker { - version: number; + time: number; listeners: Set<() => void>; id: ReturnType | null; } @@ -15,7 +15,7 @@ const tickers = new Map(); function getTicker(intervalMs: number): Ticker { let t = tickers.get(intervalMs); if (!t) { - t = { version: 0, listeners: new Set(), id: null }; + t = { time: Date.now(), listeners: new Set(), id: null }; tickers.set(intervalMs, t); } return t; @@ -25,8 +25,9 @@ function subscribe(intervalMs: number, listener: () => void): () => void { const t = getTicker(intervalMs); t.listeners.add(listener); if (t.id === null) { + t.time = Date.now(); t.id = setInterval(() => { - t!.version++; + t!.time = Date.now(); t!.listeners.forEach((l) => l()); }, intervalMs); } @@ -39,10 +40,11 @@ function subscribe(intervalMs: number, listener: () => void): () => void { }; } -export function useTick(intervalMs = 10_000): void { - useSyncExternalStore( - (listener) => subscribe(intervalMs, listener), - () => getTicker(intervalMs).version, +export function useTick(intervalMs = 10_000): number { + const listen = useCallback((listener: () => void) => subscribe(intervalMs, listener), [intervalMs]); + return useSyncExternalStore( + listen, + () => getTicker(intervalMs).time, () => 0, ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 396320e..be45d9f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -223,6 +223,132 @@ }, "timestamp": { "ago": "{{duration}} ago" + }, + "observerPage": { + "back": "Back to observers", + "range": "Time range", + "open": "Open observer dashboard", + "legacy": "Legacy presence count", + "metrics": "Observer metrics", + "records": "Recorded packets", + "lastHour": "Last complete hour", + "lastPacket": "Last recorded packet", + "battery": "Battery", + "uptime": "Device uptime", + "noise": "Noise floor", + "packets": "Packets", + "search": "Find an observer", + "choose": "Choose an observer", + "searchHelp": "Search by name to find observers outside the first 50 results.", + "summaryMissing": "Packet summary unavailable on this server. Charts remain available.", + "trafficRecent": "Recent packet traffic", + "trafficQuiet": "No recent recorded packets", + "statusRecent": "Recent status", + "statusStale": "Status is stale", + "statusMissing": "No status report", + "scopeNote": "History for this observer across all received regions.", + "activity": "Recorded packets per {{interval}}", + "mix": "Packet-type mix", + "signal": "Received signal", + "airtime": "Airtime TX / RX", + "device": "Device telemetry", + "details": "Device details", + "queue": "Queue length", + "errors": "Receive errors", + "busy": "Channel busy", + "empty": "No packets recorded in this period", + "noTelemetry": "No telemetry reported", + "noTelemetryHelp": "This observer reports no device telemetry for this period.", + "loadFailed": "Observer could not be loaded.", + "retry": "Retry", + "model": "Model", + "firmware": "Firmware", + "client": "Client", + "radio": "Radio", + "publicKey": "Public key", + "copy": "Copy", + "copied": "Copied", + "broker": "Broker", + "presence": "Last contact", + "packetArrival": "Packet arrival", + "firstSeen": "First seen", + "copyLink": "Copy observer link", + "updated": "Calculated", + "window": "UTC window", + "windowHelp": "Complete buckets; gaps are missing records, not proof of an outage. Repeated deliveries of the same retained packet/observer pair count once.", + "exact": "Exact activity values", + "time": "Time (UTC)", + "mean": "Average", + "minimum": "Minimum", + "unknown": "Unknown", + "latestStatus": "Latest status", + "latestTelemetry": "Latest telemetry interval", + "recording": "Loading observer…", + "hour": "hour", + "day": "day", + "trafficUnknown": "Packet freshness unavailable" + }, + "observerCompare": { + "open": "Compare with…", + "close": "Close comparison", + "title": "Compare observers", + "partner": "Observer B", + "choose": "Choose an observer to compare", + "invalidObserver": "Choose a different observer with a valid identifier.", + "invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.", + "refresh": "Refresh both observers", + "unavailable": "Aligned summaries are unavailable. Refresh both observers, or update the server to support shared windows.", + "currentNote": "Activity totals use this shared window. Device values and the latest complete hour describe current conditions; the hour start is shown in UTC.", + "metrics": "Observer comparison metrics", + "metric": "Metric", + "activity": "Recorded packets · aligned activity", + "overlap": "Retained flood-packet overlap", + "retention": "Raw packet history may expire before the longer-lived activity summaries. Overlap counts only retained flood packets in the shared window, across all regions.", + "total_one": "{{count}} distinct flood packet", + "total_other": "{{count}} distinct flood packets", + "empty": "No retained flood packets were reported by either observer in this window.", + "onlyA": "Only A", + "both": "Both", + "onlyB": "Only B", + "definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports." + }, + "investigation": { + "title": "Reception evidence", + "reports_one": "{{count}} retained report", + "reports_other": "{{count}} retained reports", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "pathNote": "Grouped by path prefixes and hash width, not confirmed physical routes. Inspect each report for candidate identities.", + "traceNote": "TRACE entries describe the intended route, not a measured reception path. Inspect the trace readings separately.", + "empty": "No retained reports for this packet.", + "path": "Path {{number}}", + "tracePath": "Intended route {{number}}", + "noEntries": "No path entries", + "unavailablePath": "Path bytes unavailable", + "inspect": "Inspect report", + "observer": "Inspect observer", + "map": "Map report", + "unmappable": "Mapping needs at least two uniquely resolved, valid locations.", + "retention": "Reports include all received regions and follow packet/observer deduplication, so not every reception or path is retained. Expired reports are unavailable; reported timing does not establish packet loss or delivery.", + "unavailableReport": "Selected report is unavailable or its identifier is invalid. Choose a retained report below.", + "copy": "Copy link", + "copyPacket": "Copy packet link", + "viewAll": "View path on map", + "mapDialog": "Packet path map", + "mapTitle": "Packet Path", + "copyPath": "Copy path link", + "closeMap": "Close path map", + "allPaths": "All paths", + "traceRoute": "Trace route", + "mapNote": "Only uniquely resolved locations are shown. Unresolved or unlocated gaps are not connected; lines do not establish direct radio links or delivery.", + "unavailableMap": "The selected path cannot be mapped from the retained evidence. Choose an available path or All paths.", + "reportedBy_one": "Reported by {{count}} observer", + "reportedBy_other": "Reported by {{count}} observers", + "first": "First", + "last": "Last", + "span": "Reported span", + "showAll": "Show all {{count}} path groups", + "fewer": "Show fewer paths" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c659426..223bea6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -227,6 +227,132 @@ }, "timestamp": { "ago": "il y a {{duration}}" + }, + "observerPage": { + "back": "Retour aux observateurs", + "range": "Période", + "open": "Ouvrir le tableau de bord", + "legacy": "Ancien compteur de présence", + "metrics": "Mesures de l’observateur", + "records": "Paquets enregistrés", + "lastHour": "Dernière heure complète", + "lastPacket": "Dernier paquet enregistré", + "battery": "Batterie", + "uptime": "Durée de fonctionnement", + "noise": "Bruit de fond", + "packets": "Paquets", + "search": "Rechercher un observateur", + "choose": "Choisir un observateur", + "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", + "summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.", + "trafficRecent": "Trafic de paquets récent", + "trafficQuiet": "Aucun paquet récent enregistré", + "statusRecent": "État récent", + "statusStale": "État périmé", + "statusMissing": "Aucun rapport d’état", + "scopeNote": "Historique de cet observateur, toutes régions de réception confondues.", + "activity": "Paquets enregistrés par {{interval}}", + "mix": "Répartition des types", + "signal": "Signal reçu", + "airtime": "Temps radio TX / RX", + "device": "Télémétrie de l’appareil", + "details": "Détails de l’appareil", + "queue": "File d’attente", + "errors": "Erreurs de réception", + "busy": "Occupation du canal", + "empty": "Aucun paquet enregistré sur cette période", + "noTelemetry": "Aucune télémétrie reçue", + "noTelemetryHelp": "Cet observateur ne rapporte aucune télémétrie pour cette période.", + "loadFailed": "Impossible de charger l’observateur.", + "retry": "Réessayer", + "model": "Modèle", + "firmware": "Micrologiciel", + "client": "Client", + "radio": "Radio", + "publicKey": "Clé publique", + "copy": "Copier", + "copied": "Copié", + "broker": "Courtier", + "presence": "Dernier contact", + "packetArrival": "Réception de paquet", + "firstSeen": "Première réception", + "copyLink": "Copier le lien de l’observateur", + "updated": "Calculé", + "window": "Période UTC", + "windowHelp": "Périodes complètes ; les lacunes sont des données manquantes, pas la preuve d’une panne. Les livraisons répétées du même couple paquet/observateur conservé ne comptent qu’une fois.", + "exact": "Valeurs exactes de l’activité", + "time": "Heure (UTC)", + "mean": "Moyenne", + "minimum": "Minimum", + "unknown": "Inconnu", + "latestStatus": "Dernier état", + "latestTelemetry": "Dernière période de télémétrie", + "recording": "Chargement de l’observateur…", + "hour": "heure", + "day": "jour", + "trafficUnknown": "Fraîcheur des paquets inconnue" + }, + "observerCompare": { + "open": "Comparer avec…", + "close": "Fermer la comparaison", + "title": "Comparer les observateurs", + "partner": "Observateur B", + "choose": "Choisissez un observateur à comparer", + "invalidObserver": "Choisissez un autre observateur avec un identifiant valide.", + "invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.", + "refresh": "Actualiser les deux observateurs", + "unavailable": "Les résumés alignés sont indisponibles. Actualisez les deux observateurs ou mettez le serveur à jour pour prendre en charge les périodes communes.", + "currentNote": "Les totaux d’activité utilisent cette période commune. Les valeurs de l’appareil et la dernière heure complète décrivent les conditions actuelles; le début de l’heure est indiqué en UTC.", + "metrics": "Mesures de comparaison des observateurs", + "metric": "Mesure", + "activity": "Paquets enregistrés · activité alignée", + "overlap": "Recoupement des paquets flood conservés", + "retention": "L’historique des paquets bruts peut expirer avant les résumés d’activité. Le recoupement compte uniquement les paquets flood conservés dans la période commune, toutes régions confondues.", + "total_one": "{{count}} paquet flood distinct", + "total_other": "{{count}} paquets flood distincts", + "empty": "Aucun paquet flood conservé n’a été signalé par ces observateurs pendant cette période.", + "onlyA": "A seulement", + "both": "Les deux", + "onlyB": "B seulement", + "definition": "Chaque empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles." + }, + "investigation": { + "title": "Rapports de réception", + "reports_one": "{{count}} rapport conservé", + "reports_other": "{{count}} rapports conservés", + "observers_one": "{{count}} observateur déclarant", + "observers_other": "{{count}} observateurs déclarants", + "pathNote": "Regroupement par préfixes et largeur d’empreinte, pas par trajets physiques confirmés. Examinez les identités candidates de chaque rapport.", + "traceNote": "Les entrées TRACE décrivent l’itinéraire prévu, pas un trajet de réception mesuré. Examinez les mesures de trace séparément.", + "empty": "Aucun rapport conservé pour ce paquet.", + "path": "Trajet {{number}}", + "tracePath": "Itinéraire prévu {{number}}", + "noEntries": "Aucune entrée de trajet", + "unavailablePath": "Octets de trajet indisponibles", + "inspect": "Examiner le rapport", + "observer": "Examiner l’observateur", + "map": "Afficher le rapport sur la carte", + "unmappable": "La carte nécessite au moins deux positions valides résolues sans ambiguïté.", + "retention": "Les rapports incluent toutes les régions reçues et suivent la déduplication paquet/observateur : toutes les réceptions ou tous les trajets ne sont donc pas conservés. Les rapports expirés sont indisponibles; les horaires signalés ne prouvent ni perte ni livraison.", + "unavailableReport": "Le rapport sélectionné est indisponible ou son identifiant est invalide. Choisissez un rapport conservé ci-dessous.", + "copy": "Copier le lien", + "copyPacket": "Copier le lien du paquet", + "viewAll": "Afficher le trajet sur la carte", + "mapDialog": "Carte du trajet du paquet", + "mapTitle": "Trajet du paquet", + "copyPath": "Copier le lien du trajet", + "closeMap": "Fermer la carte du trajet", + "allPaths": "Tous les trajets", + "traceRoute": "Itinéraire de trace", + "mapNote": "Seules les positions résolues sans ambiguïté sont affichées. Les étapes inconnues ou sans position interrompent les lignes, qui ne prouvent ni liaison radio directe ni livraison.", + "unavailableMap": "Le trajet sélectionné ne peut pas être cartographié avec les données conservées. Choisissez un trajet disponible ou Tous les trajets.", + "reportedBy_one": "Signalé par {{count}} observateur", + "reportedBy_other": "Signalé par {{count}} observateurs", + "first": "Premier", + "last": "Dernier", + "span": "Intervalle signalé", + "showAll": "Afficher les {{count}} groupes de trajets", + "fewer": "Afficher moins de trajets" } } } diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx index 7c40724..882fd3e 100644 --- a/tests/App.analyzerObservationCarryOver.test.tsx +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -136,3 +136,12 @@ describe("opening the analyzer from an expanded row", () => { expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); }); }); + +it("replaces a shared observation when another report is selected in the row", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=AA11&observation=3"); + render(); + fireEvent.click(await screen.findByText("Observer Two")); + const drawer = await screen.findByTestId("packet-analyzer-drawer"); + expect(within(drawer).getByText("Observer Two")).toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("2"); +}); diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 749524d..eca03e2 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -33,6 +33,11 @@ vi.mock("../src/api/client", () => ({ getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ + ObserverDetailPanel: ({ observerId, onViewStats }: { observerId: string; onViewStats: (id: string) => void }) => , +})); +vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () =>

Observer dashboard

})); + const packet: PacketSummary = { packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", routeType: 1, routeTypeName: "FLOOD", @@ -136,6 +141,22 @@ afterEach(() => { }); describe("Packets deep links", () => { + it("returns from an observer dashboard to the same packet, report and filters", async () => { + setMobile(false); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1&observation=1&iata=YOW&q=missing"); + render(); + const section = await screen.findByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("No path entries").closest("details")).toHaveAttribute("open"); + fireEvent.click(within(section).getByRole("button", { name: "Inspect observer" })); + fireEvent.click(await screen.findByRole("button", { name: "Open observer dashboard" })); + expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).has("observation")).toBe(false); + act(() => window.history.back()); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + const params = new URLSearchParams(window.location.search); + expect(params.get("hash")).toBe("AA11"); expect(params.get("observation")).toBe("1"); + expect(params.get("iata")).toBe("YOW"); expect(params.get("q")).toBe("missing"); + }); it.each([false, true])("opens an unloaded selection explicitly and preserves region and filters (mobile=%s)", async (mobile) => { setMobile(mobile); window.history.pushState({}, "", "/?tab=Packets&hash=BB22&iata=YOW&q=missing"); diff --git a/tests/App.pathLinkRestore.test.tsx b/tests/App.pathLinkRestore.test.tsx index 787a4fe..d00dd38 100644 --- a/tests/App.pathLinkRestore.test.tsx +++ b/tests/App.pathLinkRestore.test.tsx @@ -20,6 +20,13 @@ beforeEach(() => getPacketDetail.mockReset()); const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; describe("PathLinkRestore", () => { + it("never restores a different packet while the shared packet is still loading", async () => { + getPacketDetail.mockResolvedValue(detail); + const onRestore = vi.fn(); + render(, { wrapper }); + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha")); + expect(onRestore).toHaveBeenCalledTimes(1); + }); // Regression: PacketPathMapModal's Copy Link strips ?analyze, so a copied path link carries ?hash // without it — the popup can't rely on the analyzer drawer's fetch and needs its own. it("restores the path popup from ?hash&?path alone, with no ?analyze", async () => { diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx new file mode 100644 index 0000000..80b8142 --- /dev/null +++ b/tests/App.quickObserver.test.tsx @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; +import { ModalOverlay } from "../src/components/ModalOverlay"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <> })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string) => void }) => })); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzerOverlay: ({ detail, inactive, onClose, onViewObserver }: { detail?: { packetHash: string }; inactive?: boolean; onClose: () => void; onViewObserver: (id: string) => void }) =>

{detail?.packetHash}

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

{detail?.packetHash}

})); + +beforeEach(() => { window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); }); + +describe("quick observer investigation", () => { + it("closes the quick observer before opening its advert analyzer", () => { + render(); + fireEvent.click(screen.getByText("Node observer")); + fireEvent.click(screen.getByText("Observer advert")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getByRole("heading")).toHaveTextContent("advert"); + expect(screen.queryByRole("button", { name: "Observer advert" })).not.toBeInTheDocument(); + }); + it("marks the underlying packet inactive while its observer is open", () => { + render(); + fireEvent.click(screen.getByText("Node packet")); + fireEvent.click(screen.getByText("Packet observer")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getAllByRole("dialog", { hidden: true }).filter(node => node.getAttribute("aria-modal") === "false")).toHaveLength(1); + }); +}); diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts index ed34602..539c7eb 100644 --- a/tests/api/observer-comparison.test.ts +++ b/tests/api/observer-comparison.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getObserverComparison } from "../../src/api/client"; +import { getObserverComparison, getObserverActivity, getObserver } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); @@ -15,3 +15,21 @@ it("sends the complete comparison scope, including an epoch-zero start, and cons controller.abort(); expect(options.signal.aborted).toBe(true); }); + +it("sends the shared activity anchor without altering the selected range", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ points: [] }) }); + vi.stubGlobal("fetch", fetcher); + await getObserverActivity("a", "168h", "1h", 123456789); + expect(Object.fromEntries(new URL(fetcher.mock.calls[0][0]).searchParams)).toEqual({ range: "168h", interval: "1h", until: "123456789" }); +}); + +it.each([ { stats: { noise_floor: -117 } }, btoa('{"stats":{"noise_floor":-117}}') ])("reads both object and legacy byte-encoded status metadata", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + expect((await getObserver("a")).statusMetadata).toEqual({ stats: { noise_floor: -117 } }); +}); + +it.each(["not base64!", btoa('null'), btoa('[]')])("ignores malformed metadata without losing the observer", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + const observer = await getObserver("a"); + expect(observer.id).toBe("a"); expect(observer.statusMetadata).toBeUndefined(); +}); diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index 17595f8..a443f11 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -66,9 +66,13 @@ describe("PacketPathMapModal", () => { expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); - it("falls back to All when initialSelectedKey isn't a known path", () => { + it("does not silently substitute all paths for an unavailable selected path", () => { render( {}} initialSelectedKey="nope" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("nope"); + expect(screen.getByRole("status")).toHaveTextContent("selected path cannot be mapped"); + fireEvent.click(screen.getByText("All paths")); expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("renders a copy-link button", () => { diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index ae6df1a..89d8d30 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -27,18 +27,20 @@ describe("packetChain", () => { }); describe("resolvedPathNodes", () => { - it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { + it("suppresses an animation when a gap would invent a link", () => { const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("a", -75, 45), hop("b", -76, 46)]; - expect(resolvedPathNodes(path)).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); + expect(resolvedPathNodes(path)).toEqual([]); + expect(resolvedPathNodes([hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)])).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); }); it("skips hops with no located candidate", () => { expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]); }); - it("skips reset/invalid candidates and still finds a usable candidate", () => { + it("rejects reset/invalid and ambiguous locations while accepting valid zero axes", () => { const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 10, 91).nodes, ...hop("valid", 0, 45).nodes] }; - expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); + expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([]); + expect(resolvedPathNodes([hop("valid", 0, 45)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); }); }); diff --git a/tests/features/map/packet-path-evidence.test.ts b/tests/features/map/packet-path-evidence.test.ts new file mode 100644 index 0000000..4f2bf83 --- /dev/null +++ b/tests/features/map/packet-path-evidence.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { buildPacketPaths, packetPathsToFeatures } from "../../../src/features/map/packet-path"; +import { resolvedPathNodes } from "../../../src/features/map/packet-flow"; +import type { PacketDetail, ResolvedHop } from "../../../src/types/api"; + +const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, publicKey: id, latitude: 45, longitude: -75 + Number(id) }] }); +function detail(hops: ResolvedHop[]): PacketDetail { + return { header: { payloadType: 2 }, observations: [{ id: 1, observerId: "observer", resolvedPath: hops }] } as unknown as PacketDetail; +} +it("does not choose the located candidate in an ambiguous relay", () => { + const ambiguous: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "unknown", publicKey: "a" }, ...hop("1").nodes] }; + const paths = buildPacketPaths(detail([hop("0"), ambiguous, hop("2"), hop("3")])); + expect(paths[0].points.map(p => p.id)).toEqual(["0", "2", "3"]); + expect(packetPathsToFeatures(paths, null).lines.features.map(f => f.geometry.coordinates)).toEqual([[[-73,45],[-72,45]]]); +}); +it("keeps isolated known locations without drawing a link over an unknown hop", () => { + const paths = buildPacketPaths(detail([hop("0"), { confidence: "none", nodes: [] }, hop("2")])); + const data = packetPathsToFeatures(paths, null); + expect(data.lines.features).toHaveLength(0); + expect(data.points.features).toHaveLength(2); + expect(resolvedPathNodes([hop("0"), { confidence: "none", nodes: [] }, hop("2")])).toEqual([]); +}); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 86b6514..ec0a7af 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -26,9 +26,9 @@ describe("buildPacketPaths", () => { const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)]; const ordinary = buildPacketPaths(detail([obs(1, hops)])); const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial)); - for (const paths of [ordinary, trace]) { - expect(paths[0]!.points.map((p) => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); - } + expect(ordinary).toEqual([]); expect(trace).toEqual([]); + const valid = buildPacketPaths(detail([obs(1, [hop("start", 0, 45), hop("end", -75, 0)])])); + expect(valid[0].points.map(p => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); expect(buildPacketPaths(detail([obs(1, [hop("unknown", 0, 0), hop("one", -75, 45)])]))).toEqual([]); }); @@ -144,19 +144,18 @@ describe("buildPacketPaths", () => { expect(buildPacketPaths(d)).toEqual([]); }); - it("uses the first located candidate for an ambiguous relay hop", () => { + it("does not turn the only located candidate into a resolved identity", () => { const multi: ResolvedHop = { confidence: "ambiguous", nodes: [ { id: "unlocated", publicKey: "p0" }, // no coords — skipped - { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used + { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // location does not disambiguate identity ], }; const d = detail([ obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }), ]); - const [path] = buildPacketPaths(d); - expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]); + expect(buildPacketPaths(d)).toEqual([]); }); it("omits observations that resolve to fewer than 2 located hops", () => { @@ -214,3 +213,9 @@ describe("packetPathsToFeatures", () => { expect(points.features[1]!.properties.title).toBe("Repeater North"); }); }); + +it("resumes a confirmed segment after returning to the same node across a gap", () => { + const paths = buildPacketPaths(detail([obs(1, [hop("a", -79, 43), hop("missing"), hop("a", -79, 43), hop("b", -75, 45)])])); + expect(paths[0].points.map(point => point.id)).toEqual(["a", "b"]); + expect(paths[0].points[1].breakBefore).toBeUndefined(); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx new file mode 100644 index 0000000..72b6444 --- /dev/null +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverComparison } from "../../../src/features/observers/ObserverComparison"; +import { getObserverActivity, getObserverComparison } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity } from "../../../src/features/stats/types"; + +const A = "11111111-1111-1111-1111-111111111111"; +const B = "22222222-2222-2222-2222-222222222222"; +const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; +const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; +const activity: ObserverActivity = { range: "168h", interval: "1h", windowStart: until - 604_800_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); +vi.mock("../../../src/api/client", async original => ({ + ...await original(), + getObserver: vi.fn(async (id: string) => ({ ...observer, id, displayName: "Roof B" })), + getObserversPage: vi.fn(async () => ({ items: [{ ...observer, id: B, displayName: "Roof B" }], hasMore: false })), + getObserverActivity: vi.fn(), getObserverComparison: vi.fn(), +})); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } }); + vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 }); +}); +function view(id = B, data = activity, anchor: number | null = until) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + return { ...render(, { wrapper }), props }; +} +it("aligns activity and retained overlap to the primary observer's effective window", async () => { + view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByText("19")).toBeInTheDocument(); + expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); + expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); + expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument(); + expect(screen.getByText(/Raw packet history may expire/)).toBeInTheDocument(); + expect(screen.getByTestId("chart").textContent).toContain('"name":"A"'); + expect(screen.getByTestId("chart").textContent).toContain('"name":"B"'); +}); +it("does not compare mismatched or old-server windows", async () => { + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 }); + view(); + expect(await screen.findByText(/Aligned summaries are unavailable/)).toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); + expect(screen.queryByTestId("chart")).not.toBeInTheDocument(); +}); +it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing partner: %s", async id => { + view(id); + expect((await screen.findAllByText(id ? /Choose a different observer/ : /Choose an observer to compare/)).length).toBeGreaterThan(0); + expect(getObserverActivity).not.toHaveBeenCalled(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); +it("rejects a bad anchor and offers a fresh common window", async () => { + const { props } = view(B, activity, null); + expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(props.onRefresh).toHaveBeenCalled(); + expect(getObserverActivity).not.toHaveBeenCalled(); +}); +it("clears the previous partner's totals while the next request is pending", async () => { + const { props, rerender } = view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + vi.mocked(getObserverActivity).mockImplementation(() => new Promise(() => {})); + rerender(); + await waitFor(() => expect(screen.queryByText("23")).not.toBeInTheDocument()); + expect(screen.queryByText("9 distinct flood packets")).not.toBeInTheDocument(); +}); + +it("refreshes the current window without requesting the obsolete window after re-anchoring", async () => { + const { props } = view(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1)); + const activityCalls = vi.mocked(getObserverActivity).mock.calls.length; + props.onRefresh.mockReturnValue(until + 3_600_000); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + + props.onRefresh.mockReturnValue(until); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1)); + expect(getObserverComparison).toHaveBeenCalledTimes(2); +}); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx new file mode 100644 index 0000000..93cc494 --- /dev/null +++ b/tests/features/observers/ObserverPage.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import "../../../src/i18n"; +import { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { ObserverPage } from "../../../src/features/observers/ObserverPage"; + +vi.mock("../../../src/features/observers/ObserverTable", () => ({ + ObserverTable: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { + const [search, setSearch] = useState(""); + return ( +
+ setSearch(e.target.value)} /> + +
+ ); + }, +})); +vi.mock("../../../src/features/stats/ObserverTab", () => ({ + ObserverTab: ({ + selectedObserverId, + range, + comparison, + }: { + selectedObserverId: string; + range: string; + comparison?: { until: number | null; onRefresh: () => void }; + }) => ( + <> +

+ Dashboard {selectedObserverId} {range} +

+ {comparison && ( + <> + {String(comparison.until)} + + + )} + + ), +})); +function Location() { + const l = useLocation(); + const go = useNavigate(); + return ( + <> + {l.search} + + + + ); +} +function view(url = "?tab=Observers&iata=YOW") { + return render( + + + + , + ); +} + +describe("Observer destination", () => { + it("opens a canonical dashboard and restores directory state with Back", async () => { + view(); + fireEvent.change(screen.getByLabelText("Directory search"), { target: { value: "roof" } }); + fireEvent.click(screen.getByText("Open A")); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); + expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); + expect(screen.getByRole("status").textContent).toContain("iata=YOW"); + fireEvent.click(screen.getByText("Browser back")); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Directory search")).toHaveValue("roof"); + fireEvent.click(screen.getByText("Browser forward")); + expect(await screen.findByRole("heading")).toHaveTextContent("observer-a"); + }); + it("restores a deep link and has a directory return without prior history", async () => { + view("?tab=Observers&observer=observer-b&range=30d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); + fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); + expect(screen.getByLabelText("Directory search")).toBeVisible(); + }); +}); + +it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 8, 29, 12, 30)); + try { + view("?tab=Observers&observer=observer-a&range=24h"); + await screen.findByRole("heading"); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 15, 59, 59)); + fireEvent.click(screen.getByRole("button", { name: "Compare with…" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 15))); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 16, 0, 1)); + fireEvent.click(screen.getByRole("button", { name: "Refresh comparison" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 16))); + fireEvent.click(screen.getByRole("button", { name: "Close comparison" })); + expect(screen.queryByTestId("comparison-until")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).not.toHaveTextContent("compareUntil"); + } finally { + clock.mockRestore(); + } +}); + +it("rejects duplicate, malformed and future comparison anchors", async () => { + const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + viewResult.unmount(); + view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts new file mode 100644 index 0000000..2c2ca87 --- /dev/null +++ b/tests/features/observers/observer-navigation.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from "vitest"; +import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; +it("normalizes legacy analytics links without losing region",()=>{ + const p=observerDestination(new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"),"abc"); + expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("30d");expect(p.get("iata")).toBe("YOW");expect(p.has("observerId")).toBe(false);expect(p.has("statsTab")).toBe(false); +}); +it("drops unrelated investigation selectors and bounds range",()=>{ + const p=observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"),"b"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=7d");expect(observerRange("invalid")).toBe("7d"); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8dc1853..7c3f47c 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import i18n from "../../../src/i18n"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; import type { PacketDetail } from "../../../src/types/api"; @@ -30,6 +31,49 @@ describe("PacketAnalyzerDrawer close", () => { }); }); +describe("packet reception evidence", () => { + const reports = () => { + const d = makeDetail([hop("a", -79, 43), hop("b", -75, 45)]); + d.observations[0].pathBytes = "aabb"; + d.observations[0].observerName = "Alpha"; + d.observations.push({ ...d.observations[0], id: 2, observerId: "beta", observerName: "Beta" }); + return d; + }; + it("restores a shared report and targets its observer and map", () => { + const onViewObserver = vi.fn(); const onViewPath = vi.fn(); + render( {}} onViewObserver={onViewObserver} onViewPath={onViewPath} />); + const section = screen.getByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open"); + const beta = within(section).getByText(/Beta/).closest("li")!; + expect(within(beta).getByRole("button", { name: "Inspect report" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta"); + fireEvent.click(within(beta).getByRole("button", { name: "Map report" })); expect(onViewPath).toHaveBeenCalledWith("beta"); + expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep"); + }); + it("reports an expired selection without quietly showing another report", () => { + render( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); + expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); + }); + it("explains TRACE and missing-report semantics in French", async () => { + const d = reports(); d.header.payloadType = PayloadType.TRACE; d.observations = []; + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByRole("region", { name: "Rapports de réception" })).toHaveTextContent("itinéraire prévu"); + expect(screen.getByText("Aucun rapport conservé pour ce paquet.")).toBeInTheDocument(); + }); + it("keeps a later selected path visible while bounding the initial list", () => { + const d = reports(); + d.observations = Array.from({ length: 12 }, (_, i) => ({ ...d.observations[0], id: i + 1, observerId: `observer-${i}`, pathBytes: i.toString(16).padStart(4, "0") })); + render( {}} />); + const section = screen.getByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("Path 12").closest("details")).toHaveAttribute("open"); + expect(within(section).queryByText("Path 4")).not.toBeInTheDocument(); + fireEvent.click(within(section).getByRole("button", { name: "Show all 12 path groups" })); + expect(within(section).getByText("Path 4")).toBeInTheDocument(); + }); +}); + 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 { diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 49d0ef2..1c75ded 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; @@ -13,6 +13,16 @@ const obs = (over: Partial = {}): LatestObserver => ({ id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, }); +afterEach(() => vi.restoreAllMocks()); + +const ambiguousSource = { + confidence: "ambiguous" as const, + nodes: [ + { id: "source-a", name: "Alpha", publicKey: "aa010203" }, + { id: "source-b", name: "Beta", publicKey: "aa040506" }, + ], +}; + describe("PacketEndpoints", () => { it("renders a single n/a when there is no observer at all", () => { render(); @@ -84,4 +94,39 @@ describe("PacketEndpoints", () => { expect(screen.getByText("TRACE 2ca2a79c").className).toContain("text-text-muted"); expect(screen.queryByText("n/a")).not.toBeInTheDocument(); }); + + it("shows all ambiguous candidates on hover, with an additional-match count", () => { + render(); + const trigger = screen.getByText("Alpha +1").parentElement!; + fireEvent.mouseEnter(trigger); + const tip = screen.getByRole("tooltip"); + expect(within(tip).getByText("Alpha")).toBeInTheDocument(); + expect(within(tip).getByText("Beta")).toBeInTheDocument(); + expect(trigger).toHaveAttribute("title", "Alpha, Beta"); + expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); + }); + + it("keeps the containing packet row as the only keyboard button", () => { + render(); + expect(screen.getAllByRole("button")).toHaveLength(1); + const chip = screen.getByText("Alpha +1").parentElement!; + expect(chip).not.toHaveAttribute("tabindex"); + expect(chip).toHaveAttribute("title", "Alpha, Beta"); + }); + + it("opens all candidates on touch without activating the packet row", () => { + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })); + const select = vi.fn(); + render(
); + fireEvent.click(screen.getByText("Alpha +1").parentElement!); + expect(within(screen.getByRole("tooltip")).getByText("Beta")).toBeInTheDocument(); + expect(select).not.toHaveBeenCalled(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("renders resolved endpoints without requiring physical path metadata", () => { + render(); + expect(screen.getByText("Source")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index e4244ae..eeb3b51 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -67,7 +67,7 @@ vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ }) => (
{String(expandedHash)}
- + {packets.map((p) => (