From e65e8e42ec7bcd75029ebc636645eedc2b889cbf Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 03:28:04 -0400 Subject: [PATCH 01/20] fix(packets): expose all endpoint candidates --- src/features/packets/PacketEndpoints.tsx | 42 +++++++------- src/features/packets/PathData.tsx | 23 ++++++-- .../features/packets/PacketEndpoints.test.tsx | 56 ++++++++++++++++++- 3 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 6978839..6e8c66d 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/PathData.tsx b/src/features/packets/PathData.tsx index 90f20e1..0dc098c 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { useId, useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useHasHover } from "../../hooks/useMediaQuery"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; @@ -17,13 +17,15 @@ 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, focusable = false, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; + focusable?: boolean; children: ReactNode; }) { const hasHover = useHasHover(); + const tooltipId = useId(); const ref = useRef(null); const tipRef = useRef(null); const [anchor, setAnchor] = useState(null); @@ -93,8 +95,20 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { ref={ref} onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} - onClick={hasHover ? undefined : toggle} - className="inline-flex" + onClick={hasHover ? (focusable ? (e) => { e.stopPropagation(); open(); } : undefined) : toggle} + role={focusable ? "button" : undefined} + tabIndex={focusable ? 0 : undefined} + aria-expanded={focusable ? !!anchor : undefined} + aria-describedby={focusable && anchor ? tooltipId : undefined} + onFocus={focusable && hasHover ? open : undefined} + onBlur={focusable ? (e) => { + if (!e.currentTarget.contains(e.relatedTarget) && !tipRef.current?.contains(e.relatedTarget)) setAnchor(null); + } : undefined} + onKeyDown={focusable ? (e) => { + if (e.key === "Escape") { e.stopPropagation(); setAnchor(null); } + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); open(); } + } : undefined} + className="inline-flex min-w-0 max-w-full" > {children} {anchor && @@ -102,6 +116,7 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { = {}): 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,46 @@ 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.getByRole("button", { name: "Alpha +1" }); + fireEvent.mouseEnter(trigger); + const tip = screen.getByRole("tooltip"); + expect(within(tip).getByText("Alpha")).toBeInTheDocument(); + expect(within(tip).getByText("Beta")).toBeInTheDocument(); + expect(trigger).toHaveAttribute("aria-describedby", tip.id); + expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); + }); + + it("opens candidates from the keyboard and closes with Escape without selecting the packet", () => { + const select = vi.fn(); + render(
); + const trigger = screen.getByRole("button", { name: "Alpha +1" }); + fireEvent.focus(trigger); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + fireEvent.keyDown(trigger, { key: "Enter" }); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + expect(select).not.toHaveBeenCalled(); + fireEvent.blur(trigger, { relatedTarget: document.body }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + 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.getByRole("button", { name: "Alpha +1" })); + 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(); + }); }); From f4092a492c506c0b81c8067a62c1f7635607b782 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:44 -0400 Subject: [PATCH 02/20] fix: address release review findings for #75 --- src/features/packets/PacketEndpoints.tsx | 2 +- src/features/packets/PathData.tsx | 22 +++------------- src/features/packets/path-summary.ts | 14 ++--------- .../features/packets/PacketEndpoints.test.tsx | 25 +++++++------------ .../features/packets/PacketTableRow.test.tsx | 2 +- tests/features/packets/path-summary.test.ts | 16 ------------ 6 files changed, 17 insertions(+), 64 deletions(-) diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 6e8c66d..cd9e5df 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -13,7 +13,7 @@ function Chip({ hop }: { hop: ResolvedHop }) { const node = hop.nodes[0]; const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; return ( - + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 0dc098c..59db7f9 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -1,4 +1,4 @@ -import { useId, useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useHasHover } from "../../hooks/useMediaQuery"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; @@ -17,15 +17,13 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -export function HopPopover({ hop, onViewNode, showSnr = true, focusable = false, children }: { +export function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; - focusable?: boolean; children: ReactNode; }) { const hasHover = useHasHover(); - const tooltipId = useId(); const ref = useRef(null); const tipRef = useRef(null); const [anchor, setAnchor] = useState(null); @@ -95,19 +93,8 @@ export function HopPopover({ hop, onViewNode, showSnr = true, focusable = false, ref={ref} onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} - onClick={hasHover ? (focusable ? (e) => { e.stopPropagation(); open(); } : undefined) : toggle} - role={focusable ? "button" : undefined} - tabIndex={focusable ? 0 : undefined} - aria-expanded={focusable ? !!anchor : undefined} - aria-describedby={focusable && anchor ? tooltipId : undefined} - onFocus={focusable && hasHover ? open : undefined} - onBlur={focusable ? (e) => { - if (!e.currentTarget.contains(e.relatedTarget) && !tipRef.current?.contains(e.relatedTarget)) setAnchor(null); - } : undefined} - onKeyDown={focusable ? (e) => { - if (e.key === "Escape") { e.stopPropagation(); setAnchor(null); } - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); open(); } - } : undefined} + onClick={hasHover ? undefined : toggle} + title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} className="inline-flex min-w-0 max-w-full" > {children} @@ -116,7 +103,6 @@ export function HopPopover({ hop, onViewNode, showSnr = true, focusable = false, { it("shows all ambiguous candidates on hover, with an additional-match count", () => { render(); - const trigger = screen.getByRole("button", { name: "Alpha +1" }); + 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("aria-describedby", tip.id); + expect(trigger).toHaveAttribute("title", "Alpha, Beta"); expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); }); - it("opens candidates from the keyboard and closes with Escape without selecting the packet", () => { - const select = vi.fn(); - render(
); - const trigger = screen.getByRole("button", { name: "Alpha +1" }); - fireEvent.focus(trigger); - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - fireEvent.keyDown(trigger, { key: "Escape" }); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - fireEvent.keyDown(trigger, { key: "Enter" }); - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - expect(select).not.toHaveBeenCalled(); - fireEvent.blur(trigger, { relatedTarget: document.body }); - expect(screen.queryByRole("tooltip")).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.getByRole("button", { name: "Alpha +1" })); + fireEvent.click(screen.getByText("Alpha +1").parentElement!); expect(within(screen.getByRole("tooltip")).getByText("Beta")).toBeInTheDocument(); expect(select).not.toHaveBeenCalled(); fireEvent.pointerDown(document.body); diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 6833393..e537033 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -14,7 +14,7 @@ const node = (name: string): ResolvedHop => ({ nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }], }); -// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here. +// Physical path metadata is independent of the logical source/destination chips. const observer = ( over: { hopCount?: number; hashSize?: number } & Partial> = {}, ): LatestObserver => { diff --git a/tests/features/packets/path-summary.test.ts b/tests/features/packets/path-summary.test.ts index e17ed59..17720fc 100644 --- a/tests/features/packets/path-summary.test.ts +++ b/tests/features/packets/path-summary.test.ts @@ -102,20 +102,4 @@ describe("buildPathSummary", () => { expect(s.chips).toEqual([]); }); - it("carries resolved endpoints through", () => { - const s = buildPathSummary(pkt(obs({ - pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", - resolvedSource: { confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Salish" }] }, - }))); - expect(s.source).toEqual({ kind: "node", label: "Salish", confidence: "high" }); - expect(s.destination).toBeNull(); - }); - - it("falls back to an unresolved-run chip for an endpoint with confidence none", () => { - const s = buildPathSummary(pkt(obs({ - pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", - resolvedSource: { confidence: "none", nodes: [] }, - }))); - expect(s.source).toEqual({ kind: "unresolved-run", count: 1 }); - }); }); From e59e78bf550c300682fb419d2aea1d893ded2a0a Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 22:02:12 -0400 Subject: [PATCH 03/20] feat(observers): open a unified shareable monitoring page --- src/App.tsx | 46 ++++++++++--------- .../observers/ObserverDetailPanel.tsx | 6 ++- src/features/observers/ObserverPage.tsx | 36 +++++++++++++++ src/features/observers/observer-navigation.ts | 12 +++++ src/i18n/locales/en.json | 6 +++ src/i18n/locales/fr.json | 6 +++ .../features/observers/ObserverPage.test.tsx | 30 ++++++++++++ .../observers/observer-navigation.test.ts | 10 ++++ 8 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 src/features/observers/ObserverPage.tsx create mode 100644 src/features/observers/observer-navigation.ts create mode 100644 tests/features/observers/ObserverPage.test.tsx create mode 100644 tests/features/observers/observer-navigation.test.ts diff --git a/src/App.tsx b/src/App.tsx index 28fcb79..f972077 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"; @@ -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) @@ -210,15 +218,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,7 +240,7 @@ function AppInner() { setSelectedNodeId(null); setOverlayNodeId(null); setOverlayPacketHash(null); - setSelectedObserverId(null); + setQuickObserverId(null); setPathMapDetail(null); }, []); @@ -251,22 +260,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 +293,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 @@ -335,8 +337,7 @@ function AppInner() { nodeId={selectedNodeId} onClose={handleCloseNode} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setSelectedNodeId} onAnalyzePacket={setOverlayPacketHash} @@ -347,8 +348,7 @@ function AppInner() { nodeId={overlayNodeId} onClose={() => setOverlayNodeId(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setOverlayNodeId} /> @@ -359,13 +359,15 @@ function AppInner() { loading={overlayPacketLoading} onClose={() => setOverlayPacketHash(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} inactive={!!pathMapDetail} /> )} + {quickObserverId && setQuickObserverId(null)}> + setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={setOverlayPacketHash} /> + } {pathMapDetail && ( 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..40622c9 --- /dev/null +++ b/src/features/observers/ObserverPage.tsx @@ -0,0 +1,36 @@ +import { lazy, Suspense } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +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 id = params.get("observer"); + const range = observerRange(params.get("range")); + const select = (observer: string | null) => setParams(observerDestination(params, observer, range)); + return
+ {/* Keep the directory mounted so Back restores filters, sorting and scroll. */} +
+ +
+ {id &&
+
+ + +
+
+ {t("common.loading")}

}> + +
+
+
} +
; +} diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts new file mode 100644 index 0000000..9aee912 --- /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"]) next.delete(key); + if (id) { next.set("observer", id); next.set("range", range); } + else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); } + return next; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 396320e..d602986 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -223,6 +223,12 @@ }, "timestamp": { "ago": "{{duration}} ago" + }, + "observerPage": { + "back": "Back to observers", + "range": "Time range", + "open": "Open observer dashboard", + "legacy": "Legacy presence count" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c659426..23554d2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -227,6 +227,12 @@ }, "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" } } } diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx new file mode 100644 index 0000000..3eabd8a --- /dev/null +++ b/tests/features/observers/ObserverPage.test.tsx @@ -0,0 +1,30 @@ +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}:{selectedObserverId:string;range:string})=>

Dashboard {selectedObserverId} {range}

})); +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(); + }); +}); 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"); +}); From f27f13a1c1574481c1719f819670de04b631a5ee Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:45 -0400 Subject: [PATCH 04/20] fix: address release review findings for #79 --- src/App.tsx | 7 +- src/features/nodes/NodeDetailOverlay.tsx | 9 ++- src/features/stats/StatsOverview.tsx | 12 +-- src/features/stats/StatsSubHeader.tsx | 10 --- tests/App.quickObserver.test.tsx | 35 +++++++++ .../features/observers/ObserverPage.test.tsx | 77 ++++++++++++++----- 6 files changed, 105 insertions(+), 45 deletions(-) create mode 100644 tests/App.quickObserver.test.tsx diff --git a/src/App.tsx b/src/App.tsx index f972077..45a155b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -247,7 +247,7 @@ function AppInner() { // 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); @@ -346,6 +346,7 @@ function AppInner() { {overlayNodeId && ( setOverlayNodeId(null)} onViewObserver={(observerId) => { setQuickObserverId(observerId); @@ -362,11 +363,11 @@ function AppInner() { setQuickObserverId(observerId); }} onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} - inactive={!!pathMapDetail} + inactive={!!pathMapDetail || !!quickObserverId} /> )} {quickObserverId && setQuickObserverId(null)}> - setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={setOverlayPacketHash} /> + setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={hash => { setQuickObserverId(null); setOverlayPacketHash(hash); }} /> } {pathMapDetail && ( 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/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/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx new file mode 100644 index 0000000..bfbc281 --- /dev/null +++ b/tests/App.quickObserver.test.tsx @@ -0,0 +1,35 @@ +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 } : 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}

})); + +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.queryByText("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.getByRole("dialog", { name: "Packet", hidden: true })).toHaveAttribute("aria-modal", "false"); + }); +}); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index 3eabd8a..2468ba4 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -6,25 +6,62 @@ 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}:{selectedObserverId:string;range:string})=>

Dashboard {selectedObserverId} {range}

})); -function Location(){const l=useLocation();const go=useNavigate();return <>{l.search};} -function view(url="?tab=Observers&iata=YOW") {return render();} +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 }: { selectedObserverId: string; range: string }) => ( +

+ Dashboard {selectedObserverId} {range} +

+ ), +})); +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(); - }); +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(); + }); }); From ab5bc0c38963cfb91a64b3fb18c8c2e3d64b4bba Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:47:18 -0400 Subject: [PATCH 05/20] test: cover both close and stacked observer inspection --- tests/App.quickObserver.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx index bfbc281..80b8142 100644 --- a/tests/App.quickObserver.test.tsx +++ b/tests/App.quickObserver.test.tsx @@ -11,9 +11,11 @@ vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { childr 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 } : undefined, isLoading: false }) })); +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", () => { @@ -23,13 +25,13 @@ describe("quick observer investigation", () => { fireEvent.click(screen.getByText("Observer advert")); expect(screen.getAllByRole("dialog")).toHaveLength(1); expect(screen.getByRole("heading")).toHaveTextContent("advert"); - expect(screen.queryByText("Observer advert")).not.toBeInTheDocument(); + 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.getByRole("dialog", { name: "Packet", hidden: true })).toHaveAttribute("aria-modal", "false"); + expect(screen.getAllByRole("dialog", { hidden: true }).filter(node => node.getAttribute("aria-modal") === "false")).toHaveLength(1); }); }); From 7d9fa699e207dcb2aec71def0933a990a111317b Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 22:36:57 -0400 Subject: [PATCH 06/20] feat(observers): prioritize activity and readable status cards --- src/features/observers/ObserverPage.tsx | 11 +- src/features/observers/ObserverPicker.tsx | 25 ++ src/features/observers/ObserverSummary.tsx | 61 ++++ src/features/stats/ObserverTab.tsx | 309 +++++---------------- src/features/stats/chartOptions.ts | 28 +- src/features/stats/types.ts | 12 + src/features/stats/useTelemetry.ts | 4 +- src/hooks/useTick.ts | 18 +- src/i18n/locales/en.json | 60 +++- src/i18n/locales/fr.json | 60 +++- tests/features/stats/ObserverTab.test.tsx | 38 ++- 11 files changed, 344 insertions(+), 282 deletions(-) create mode 100644 src/features/observers/ObserverPicker.tsx create mode 100644 src/features/observers/ObserverSummary.tsx diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 40622c9..a1e87a9 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -1,6 +1,7 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { CopyButton } from "../../components/CopyButton"; import { ObserverTable } from "./ObserverTable"; import { observerDestination, observerRange } from "./observer-navigation"; import type { WsManager } from "../../api/ws-manager"; @@ -10,16 +11,20 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) { const [params, setParams] = useSearchParams(); const { t } = useTranslation(); const id = params.get("observer"); + const [directoryVisited, setDirectoryVisited] = useState(!id); + if (!id && !directoryVisited) setDirectoryVisited(true); + const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); const range = observerRange(params.get("range")); const select = (observer: string | null) => setParams(observerDestination(params, observer, range)); return
{/* Keep the directory mounted so Back restores filters, sorting and scroll. */} -
+ {directoryVisited &&
-
+
} {id &&
+
+ {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..148c747 --- /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 } = useTranslation(); const now = useTick(); + const summary = activity?.summary; + const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; + const lastArrival = Math.max(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() ?? "—"], + ["lastHour", summary?.lastCompleteHour.toLocaleString() ?? "—"], + ["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} 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/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index a90fe0a..bc85710 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,187 +1,31 @@ -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 { 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 { 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 { - return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); +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; - wsManager: WsManager; -} - +interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; onSelectObserver: (id: string) => void; wsManager: WsManager } export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { - const colors = useChartColors(); - useLiveObserver(wsManager, selectedObserverId, range); - const topObservers = useTopObservers(range, 15); - const observer = useObserver(selectedObserverId); - 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 points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + useLiveObserver(wsManager, selectedObserverId, range); + const observer = useObserver(selectedObserverId); + const telemetry = useObserverTelemetry(selectedObserverId, range); + const activity = useObserverActivity(selectedObserverId, range); + const points = useMemo(() => telemetry.isError ? [] : telemetry.data?.points ?? [], [telemetry.data, telemetry.isError]); // 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; const bucketed = bucketMs != null; @@ -191,10 +35,10 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan () => (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. @@ -208,92 +52,67 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan 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; + const heardData = activity.isPlaceholderData || activity.isError ? undefined : 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 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; - - return ( -
-
- + const perBucket = interval === "1h" ? t("observerPage.hour") : interval === "24h" ? t("observerPage.day") : interval.replace("m", " min").replace("h", " h"); + + if (!selectedObserverId) return ; + if (observer.isError) return

{t("observerPage.loadFailed")}

; + 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")}

+
+ {heardUnavailable ?

{t("observerPage.summaryMissing")}

: nothingHeard ? : <> +
+ + +
- -
- {!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} - /> -
- - -
- - - ))} - - )} + {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.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={activity.isError} />} + +
; } 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..7b257ab 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"; @@ -50,7 +50,6 @@ export function useObserverActivity(observerId: string | null, range: StatsRange queryFn: () => getObserverActivity(observerId!, params.range, params.interval), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, refetchInterval: (query) => activityRefetchInterval(query.state.error), }); @@ -63,7 +62,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 d602986..2582b47 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -228,7 +228,65 @@ "back": "Back to observers", "range": "Time range", "open": "Open observer dashboard", - "legacy": "Legacy presence count" + "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" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 23554d2..0053574 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -232,7 +232,65 @@ "back": "Retour aux observateurs", "range": "Période", "open": "Ouvrir le tableau de bord", - "legacy": "Ancien compteur de présence" + "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" } } } diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index a6006f8..6735189 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { ObserverTab } from "../../../src/features/stats/ObserverTab"; +import i18n from "../../../src/i18n"; import { ApiError } from "../../../src/api/client"; import type { Observer } from "../../../src/features/observers/types"; import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types"; @@ -93,6 +94,7 @@ function renderTab() { } beforeEach(() => { + observer.brokers = []; telemetryResult.data = telemetry; telemetryResult.isLoading = false; telemetryResult.isError = false; @@ -105,7 +107,7 @@ beforeEach(() => { }); describe("ObserverTab", () => { - it("shows the latest reported RX and TX airtime as percent in the header", () => { + it("shows the latest reported RX and TX airtime as percent beside the airtime chart", () => { renderTab(); // +54 s RX and +36 s TX over the hour between the two reports expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument(); @@ -115,9 +117,9 @@ describe("ObserverTab", () => { it("renders the heard charts when the server has activity for the observer", () => { renderTab(); expect(screen.getByText(/channel busy/i)).toBeInTheDocument(); - expect(screen.getByText(/heard per 15 min/i)).toBeInTheDocument(); - expect(screen.getByText(/snr heard/i)).toBeInTheDocument(); - expect(screen.getByText(/payload types heard/i)).toBeInTheDocument(); + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.getByText(/received signal/i)).toBeInTheDocument(); + expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument(); }); it("names the radio settings the busy percent assumes, in the same form as the header", () => { @@ -147,14 +149,36 @@ describe("ObserverTab", () => { activityResult.dataUpdatedAt = 0; renderTab(); expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); - expect(screen.queryByText(/no packets heard/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/no packets recorded/i)).not.toBeInTheDocument(); expect(screen.queryByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toBeInTheDocument(); // header only }); it("shows one empty card instead of flat charts when nothing was heard", () => { activityResult.data = { ...activity, payloadTypes: [], points: [] }; renderTab(); - expect(screen.getByText(/no packets heard/i)).toBeInTheDocument(); + expect(screen.getByText(/no packets recorded/i)).toBeInTheDocument(); expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); }); }); + + +describe("Observer dashboard hierarchy",()=>{ + it("uses period metrics rather than the legacy presence counter",()=>{ + activityResult.data={...activity,windowStart:Date.now()-86400000,windowEnd:Date.now(),generatedAt:Date.now(),source:"raw",summary:{recordedPackets:9,lastCompleteHour:2,lastCompleteHourStart:Date.now()-7200000,lastCompleteHourEnd:Date.now()-3600000,latestRecordedAt:Date.now()-60000}}; + renderTab();const cards=screen.getByRole("list",{name:"Observer metrics"}); + expect(within(cards).getByText("9")).toBeInTheDocument();expect(within(cards).getAllByRole("listitem")).toHaveLength(6);expect(within(cards).queryByText("12")).not.toBeInTheDocument(); + expect(screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it("does not invent zero packet metrics on an older server",()=>{ + renderTab();expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument(); + }); + it("provides French monitoring labels",async()=>{ + await i18n.changeLanguage("fr");renderTab();expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument();expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); + }); +}); + +it("distinguishes packet arrivals from last deduplicated record and status freshness",()=>{ + const now=Date.now();observer.brokers=[{name:"one",lastSeenAt:now,lastPacketAt:now}]; + activityResult.data={...activity,summary:{recordedPackets:9,lastCompleteHour:2,lastCompleteHourStart:now-7200000,lastCompleteHourEnd:now-3600000,latestRecordedAt:now-3600000}}; + renderTab();expect(screen.getByText("Recent packet traffic")).toBeInTheDocument(); +}); From 6852b4f0c15e6f77266bab24fc3a8514bb55ace9 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:46 -0400 Subject: [PATCH 07/20] fix: address release review findings for #80 --- src/features/observers/ObserverSummary.tsx | 2 +- src/features/stats/ObserverTab.tsx | 303 +++++++++++++++++---- tests/features/stats/ObserverTab.test.tsx | 96 +++++-- 3 files changed, 313 insertions(+), 88 deletions(-) diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index 148c747..128a1be 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -22,7 +22,7 @@ export function ObserverSummary({ observer, activity, points, pending = false }: ["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} dBm` : "—"], + ["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"], ] as const; return <>
diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index bc85710..8fc63d0 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -6,7 +6,17 @@ import { ObserverPicker } from "../observers/ObserverPicker"; import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; -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"; @@ -15,22 +25,28 @@ import type { WsManager } from "../../api/ws-manager"; import { RANGE_MS, type StatsRange } from "./types"; 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(" · "); + return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); +} +interface ObserverTabProps { + range: StatsRange; + selectedObserverId: string | null; + onSelectObserver: (id: string) => void; + wsManager: WsManager; } -interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; onSelectObserver: (id: string) => void; wsManager: WsManager } export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { - const { t, i18n } = useTranslation(); - const colors = useChartColors(); - useLiveObserver(wsManager, selectedObserverId, range); - const observer = useObserver(selectedObserverId); - const telemetry = useObserverTelemetry(selectedObserverId, range); - const activity = useObserverActivity(selectedObserverId, range); - const points = useMemo(() => telemetry.isError ? [] : telemetry.data?.points ?? [], [telemetry.data, telemetry.isError]); + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + useLiveObserver(wsManager, selectedObserverId, range); + const observer = useObserver(selectedObserverId); + const telemetry = useObserverTelemetry(selectedObserverId, range); + const activity = useObserverActivity(selectedObserverId, range); + 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; + 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], @@ -38,7 +54,10 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan 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]); + 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. @@ -48,11 +67,10 @@ 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 || activity.isError ? 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(() => { @@ -64,55 +82,222 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan () => (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, 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 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 = interval === "1h" ? t("observerPage.hour") : interval === "24h" ? t("observerPage.day") : interval.replace("m", " min").replace("h", " h"); + const perBucket = + interval === "1h" + ? t("observerPage.hour") + : interval === "24h" + ? t("observerPage.day") + : interval.replace("m", " min").replace("h", " h"); if (!selectedObserverId) return ; - if (observer.isError) return

{t("observerPage.loadFailed")}

; - 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")}

-
- {heardUnavailable ?

{t("observerPage.summaryMissing")}

: nothingHeard ? : <> -
- - - + if (observer.isError) + return ( +
+

{t("observerPage.loadFailed")}

+
- {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.isError} isEmpty={missing(p => p.airtimeTxSecs, p => p.airtimeRxSecs)} /> -
- p.batteryMv)} /> - p.noiseFloorDb)} /> - p.queueLength)} /> - p.receiveErrors)} /> + ); + 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")}

- {telemetry.isError && } - } - {!heardUnavailable && costed && {radioLabel}} height={180} option={busy} isLoading={heardLoading} isError={activity.isError} />} - -
; + {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/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index 6735189..2976015 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -76,7 +76,10 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ const telemetry: ObserverTelemetry = { range: "24h", interval: "1h", - points: [point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 })], + points: [ + point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), + point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 }), + ], }; const activity: ObserverActivity = { @@ -89,8 +92,13 @@ const activity: ObserverActivity = { function renderTab() { const qc = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => {children}; - return render( {}} wsManager={{} as WsManager} />, { wrapper }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render( + {}} wsManager={{} as WsManager} />, + { wrapper }, + ); } beforeEach(() => { @@ -143,14 +151,13 @@ describe("ObserverTab", () => { expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); }); - it("shows the heard cards as loading while the previous selection's data is only a placeholder", () => { - activityResult.data = { ...activity, points: [] }; - activityResult.isPlaceholderData = true; - activityResult.dataUpdatedAt = 0; + it("keeps the last successful activity visible after a failed poll", () => { + activityResult.isError = true; + activityResult.error = new Error("temporary failure"); renderTab(); - expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); - expect(screen.queryByText(/no packets recorded/i)).not.toBeInTheDocument(); - expect(screen.queryByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toBeInTheDocument(); // header only + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); }); it("shows one empty card instead of flat charts when nothing was heard", () => { @@ -161,24 +168,57 @@ describe("ObserverTab", () => { }); }); - -describe("Observer dashboard hierarchy",()=>{ - it("uses period metrics rather than the legacy presence counter",()=>{ - activityResult.data={...activity,windowStart:Date.now()-86400000,windowEnd:Date.now(),generatedAt:Date.now(),source:"raw",summary:{recordedPackets:9,lastCompleteHour:2,lastCompleteHourStart:Date.now()-7200000,lastCompleteHourEnd:Date.now()-3600000,latestRecordedAt:Date.now()-60000}}; - renderTab();const cards=screen.getByRole("list",{name:"Observer metrics"}); - expect(within(cards).getByText("9")).toBeInTheDocument();expect(within(cards).getAllByRole("listitem")).toHaveLength(6);expect(within(cards).queryByText("12")).not.toBeInTheDocument(); - expect(screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - it("does not invent zero packet metrics on an older server",()=>{ - renderTab();expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument(); - }); - it("provides French monitoring labels",async()=>{ - await i18n.changeLanguage("fr");renderTab();expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument();expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); - }); +describe("Observer dashboard hierarchy", () => { + it("uses period metrics rather than the legacy presence counter", () => { + activityResult.data = { + ...activity, + windowStart: Date.now() - 86400000, + windowEnd: Date.now(), + generatedAt: Date.now(), + source: "raw", + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: Date.now() - 7200000, + lastCompleteHourEnd: Date.now() - 3600000, + latestRecordedAt: Date.now() - 60000, + }, + }; + renderTab(); + const cards = screen.getByRole("list", { name: "Observer metrics" }); + expect(within(cards).getByText("9")).toBeInTheDocument(); + expect(within(cards).getAllByRole("listitem")).toHaveLength(6); + expect(within(cards).queryByText("12")).not.toBeInTheDocument(); + expect( + screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("does not invent zero packet metrics on an older server", () => { + renderTab(); + expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument(); + }); + it("provides French monitoring labels", async () => { + await i18n.changeLanguage("fr"); + renderTab(); + expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument(); + expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); + }); }); -it("distinguishes packet arrivals from last deduplicated record and status freshness",()=>{ - const now=Date.now();observer.brokers=[{name:"one",lastSeenAt:now,lastPacketAt:now}]; - activityResult.data={...activity,summary:{recordedPackets:9,lastCompleteHour:2,lastCompleteHourStart:now-7200000,lastCompleteHourEnd:now-3600000,latestRecordedAt:now-3600000}}; - renderTab();expect(screen.getByText("Recent packet traffic")).toBeInTheDocument(); +it("distinguishes packet arrivals from last deduplicated record and status freshness", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.getByText("Recent packet traffic")).toBeInTheDocument(); }); From 368ba1801681afcf77a65d54a4d411c5962e36ba Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 22:07:52 -0400 Subject: [PATCH 08/20] fix: use the active locale when formatting observer noise --- src/features/observers/ObserverSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index 128a1be..511e444 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -7,7 +7,7 @@ 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 } = useTranslation(); const now = useTick(); + 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(0, ...observer.brokers.map(b => b.lastPacketAt || 0)); From 74f98a8de23d3d652b1eed654d9f28edcac80d8e Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 23:15:56 -0400 Subject: [PATCH 09/20] feat(observers): compare aligned dashboard activity and retained overlap --- src/api/client.ts | 15 +++- src/features/observers/ObserverComparison.tsx | 88 +++++++++++++++++++ src/features/observers/ObserverPage.tsx | 16 +++- src/features/observers/ObserverPicker.tsx | 8 +- src/features/observers/ObserverSummary.tsx | 6 +- src/features/observers/observer-navigation.ts | 2 +- src/features/stats/ObserverTab.tsx | 9 +- src/features/stats/useTelemetry.ts | 7 +- src/i18n/locales/en.json | 24 +++++ src/i18n/locales/fr.json | 24 +++++ tests/api/observer-comparison.test.ts | 20 ++++- .../observers/ObserverComparison.test.tsx | 72 +++++++++++++++ 12 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 src/features/observers/ObserverComparison.tsx create mode 100644 tests/features/observers/ObserverComparison.test.tsx diff --git a/src/api/client.ts b/src/api/client.ts index da77179..2493d83 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -225,8 +225,15 @@ export function getTraceDetail(tag: string): Promise { 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/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx new file mode 100644 index 0000000..b236153 --- /dev/null +++ b/src/features/observers/ObserverComparison.tsx @@ -0,0 +1,88 @@ +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: () => void; +}) { + 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: 60_000, 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 = () => { onRefresh(); if (valid) { void b.refetch(); 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/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index a1e87a9..1afbc5d 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -2,6 +2,7 @@ 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"; @@ -10,12 +11,24 @@ const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ defau 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 [initialUntil] = useState(() => Math.floor(Date.now() / 3_600_000) * 3_600_000); + const comparing = params.has("compareWith"); + const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : initialUntil; + const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= now; + 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) => setParams(old => { + const next = new URLSearchParams(old); next.set("compareWith", observer); next.set("compareUntil", String(until ?? initialUntil)); return next; + }); + const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }); + const refreshCompare = () => setParams(old => { const next = new URLSearchParams(old); next.set("compareUntil", String(Math.floor(Date.now() / 3_600_000) * 3_600_000)); return next; }, { replace: true }); return
{/* Keep the directory mounted so Back restores filters, sorting and scroll. */} {directoryVisited &&
@@ -25,6 +38,7 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
+
+ {comparison && { comparison.onRefresh(); void activity.refetch(); void observer.refetch(); }} />} {heardUnavailable ? (

{t("observerPage.summaryMissing")}

diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 7b257ab..ab78a03 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -23,6 +23,7 @@ export function useObserver(observerId: string | null) { queryFn: () => getObserver(observerId!), enabled: !!observerId, staleTime: 30_000, + refetchInterval: 60_000, refetchOnWindowFocus: false, }); } @@ -43,11 +44,11 @@ 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, refetchOnWindowFocus: false, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2582b47..10c35b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -287,6 +287,30 @@ "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." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0053574..c60ff19 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -291,6 +291,30 @@ "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." } } } 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/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx new file mode 100644 index 0000000..d396f02 --- /dev/null +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -0,0 +1,72 @@ +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() }; + 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(); +}); From 48b3ad1ba14bc311fa36090d49b7d70fa972d25c Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:47 -0400 Subject: [PATCH 10/20] fix: address release review findings for #81 --- src/features/observers/ObserverComparison.tsx | 2 +- src/features/observers/ObserverPage.tsx | 30 ++++++++--- src/features/stats/ObserverTab.tsx | 4 +- src/features/stats/useTelemetry.ts | 6 +-- .../features/observers/ObserverPage.test.tsx | 51 +++++++++++++++++-- 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index b236153..c8a7886 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -35,7 +35,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u 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: 60_000, refetchOnWindowFocus: false, retry: false, + staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, }); const option = useMemo(() => { const window = { start, end }; diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 1afbc5d..24fc298 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -14,21 +14,37 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) { const now = useTick(60_000); const id = params.get("observer"); const [directoryVisited, setDirectoryVisited] = useState(!id); - const [initialUntil] = useState(() => Math.floor(Date.now() / 3_600_000) * 3_600_000); + 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")) : initialUntil; - const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= now; + 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) => setParams(old => { - const next = new URLSearchParams(old); next.set("compareWith", observer); next.set("compareUntil", String(until ?? initialUntil)); return next; - }); + 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 = () => setParams(old => { const next = new URLSearchParams(old); next.set("compareUntil", String(Math.floor(Date.now() / 3_600_000) * 3_600_000)); return next; }, { replace: true }); + 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
{/* Keep the directory mounted so Back restores filters, sorting and scroll. */} {directoryVisited &&
diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 7c8619e..c5e2298 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -39,7 +39,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const { t, i18n } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); - const observer = useObserver(selectedObserverId); + const observer = useObserver(selectedObserverId, true); const telemetry = useObserverTelemetry(selectedObserverId, range); const activity = useObserverActivity(selectedObserverId, range, comparison?.until ?? undefined); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); @@ -160,7 +160,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan )}

{t("observerPage.windowHelp")}

- {comparison && { comparison.onRefresh(); void activity.refetch(); void observer.refetch(); }} />} + {comparison && } {heardUnavailable ? (

{t("observerPage.summaryMissing")}

diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index ab78a03..a65e309 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -17,13 +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: 60_000, + refetchInterval: poll ? 60_000 : false, refetchOnWindowFocus: false, }); } @@ -52,7 +52,7 @@ export function useObserverActivity(observerId: string | null, range: StatsRange enabled: !!observerId, staleTime: 30_000, refetchOnWindowFocus: false, - refetchInterval: (query) => activityRefetchInterval(query.state.error), + refetchInterval: (query) => until == null ? activityRefetchInterval(query.state.error) : false, }); } diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index 2468ba4..93cc494 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -18,10 +18,26 @@ vi.mock("../../../src/features/observers/ObserverTable", () => ({ }, })); vi.mock("../../../src/features/stats/ObserverTab", () => ({ - ObserverTab: ({ selectedObserverId, range }: { selectedObserverId: string; range: string }) => ( -

- Dashboard {selectedObserverId} {range} -

+ ObserverTab: ({ + selectedObserverId, + range, + comparison, + }: { + selectedObserverId: string; + range: string; + comparison?: { until: number | null; onRefresh: () => void }; + }) => ( + <> +

+ Dashboard {selectedObserverId} {range} +

+ {comparison && ( + <> + {String(comparison.until)} + + + )} + ), })); function Location() { @@ -65,3 +81,30 @@ describe("Observer destination", () => { 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"); +}); From 4b207fafd28c5637177f44f1b0025072ce90cd6d Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:45:48 -0400 Subject: [PATCH 11/20] fix(observers): refresh only the selected comparison window --- src/features/observers/ObserverComparison.tsx | 13 +++++++++++-- src/features/observers/ObserverPage.tsx | 1 + src/features/stats/ObserverTab.tsx | 10 ++++++++-- .../observers/ObserverComparison.test.tsx | 17 ++++++++++++++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index c8a7886..7d27770 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -20,7 +20,7 @@ function statusNoise(observer: Observer) { 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: () => void; + 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(); @@ -56,7 +56,16 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, ] : []; - const refresh = () => { onRefresh(); if (valid) { void b.refetch(); void activityB.refetch(); if (aligned) void overlap.refetch(); } }; + 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)})`], diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 24fc298..9a449f5 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -44,6 +44,7 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) { 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. */} diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index c5e2298..ad2d1a7 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -33,7 +33,7 @@ interface ObserverTabProps { selectedObserverId: string | null; onSelectObserver: (id: string) => void; wsManager: WsManager; - comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => void }; + comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => number }; } export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager, comparison }: ObserverTabProps) { const { t, i18n } = useTranslation(); @@ -160,7 +160,13 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan )}

{t("observerPage.windowHelp")}

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

{t("observerPage.summaryMissing")}

diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx index d396f02..72b6444 100644 --- a/tests/features/observers/ObserverComparison.test.tsx +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -27,7 +27,7 @@ beforeEach(() => { }); 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() }; + 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 }; } @@ -70,3 +70,18 @@ it("clears the previous partner's totals while the next request is pending", asy 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); +}); From 4717f86e57a9dc42168271ee80246f0c25394c5a Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 00:15:42 -0400 Subject: [PATCH 12/20] feat(packets): connect retained reports to observer and path investigation --- docs/translations.md | 4 +- src/App.tsx | 17 +++---- src/features/map/PacketPathMapModal.tsx | 23 +++++---- src/features/map/packet-flow.ts | 18 ++++--- src/features/map/packet-path.ts | 35 ++++++++------ src/features/observers/observer-navigation.ts | 2 +- src/features/packets/PacketAnalyzerDrawer.tsx | 48 ++++++++++++------- .../packets/PacketAnalyzerOverlay.tsx | 3 +- src/features/packets/PacketInvestigation.tsx | 46 ++++++++++++++++++ src/features/packets/PacketList.tsx | 1 + src/features/packets/packet-investigation.ts | 39 +++++++++++++++ src/i18n/locales/en.json | 36 ++++++++++++++ src/i18n/locales/fr.json | 36 ++++++++++++++ tests/App.packetUrlContract.test.tsx | 21 ++++++++ tests/App.pathLinkRestore.test.tsx | 7 +++ .../features/map/PacketPathMapModal.test.tsx | 6 ++- tests/features/map/packet-flow.test.ts | 10 ++-- .../features/map/packet-path-evidence.test.ts | 22 +++++++++ tests/features/map/packet-path.test.ts | 13 +++-- .../packets/PacketAnalyzerDrawer.test.tsx | 36 +++++++++++++- .../packets/packet-investigation.test.ts | 30 ++++++++++++ 21 files changed, 382 insertions(+), 71 deletions(-) create mode 100644 src/features/packets/PacketInvestigation.tsx create mode 100644 src/features/packets/packet-investigation.ts create mode 100644 tests/features/map/packet-path-evidence.test.ts create mode 100644 tests/features/packets/packet-investigation.test.ts 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 45a155b..09e3475 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -117,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; } @@ -193,17 +193,17 @@ 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) => { // 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"); } else n.delete("analyze"); return n; }, { replace: true }); @@ -329,7 +329,8 @@ function AppInner() { onSelectObservation={setSelectedObservationId} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} + onViewObserver={setQuickObserverId} + onViewPath={(key) => { if (analyzerDetail) handleViewPath(analyzerDetail, key); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -362,7 +363,7 @@ function AppInner() { onViewObserver={(observerId) => { setQuickObserverId(observerId); }} - onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} + onViewPath={(key) => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail, key); }} inactive={!!pathMapDetail || !!quickObserverId} /> )} 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..e2ec1a2 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 if (out.at(-1)?.id !== node.id) gap = true; } 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/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index 56b97f2..ca9d5f3 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24 export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); next.set("tab", "Observers"); - for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path"]) next.delete(key); + 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..f617971 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/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx new file mode 100644 index 0000000..45492e5 --- /dev/null +++ b/src/features/packets/PacketInvestigation.tsx @@ -0,0 +1,46 @@ +import { useMemo } 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; + 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")}

: groups.map((group, i) =>
+ + {t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: i + 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")}

    } +
  • ; + })}
+
)} +

{t("investigation.retention")}

+
; +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 2321faa..3592204 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -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 }); 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/i18n/locales/en.json b/src/i18n/locales/en.json index 10c35b7..072fe38 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -311,6 +311,42 @@ "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": "Reports are grouped by recorded path prefixes and hash width. Equal prefixes do not prove the same physical route; inspect each report for its 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" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c60ff19..5ab3b1e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -315,6 +315,42 @@ "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": "Les rapports sont regroupés par préfixes enregistrés et largeur d’empreinte. Des préfixes égaux ne prouvent pas un même trajet physique; 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é" } } } diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 749524d..2094107 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" }); + fireEvent.click(within(section).getByText("No path entries")); + 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/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..bd2ae8b 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", () => { diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8dc1853..fc48a50 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,39 @@ 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" }); + fireEvent.click(within(section).getByText("Path 1")); + 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(); + }); +}); + 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/packet-investigation.test.ts b/tests/features/packets/packet-investigation.test.ts new file mode 100644 index 0000000..813cb3c --- /dev/null +++ b/tests/features/packets/packet-investigation.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { groupPacketReports, reportSelection } from "../../../src/features/packets/packet-investigation"; +import type { Observation } from "../../../src/types/api"; + +const observation = (id: number, pathBytes?: string, hashSize = 1, hopCount = 2): Observation => ({ + id, observerId: `observer-${id}`, iata: "YOW", heardAt: id * 1000, sourceBroker: "mqtt1", + pathBytes, pathLength: { raw: "02", hashSize, hopCount }, resolvedPath: [], +}); + +it("groups equal complete prefixes, preserving width, order and every report", () => { + const groups = groupPacketReports([observation(3, "aabb"), observation(1, "AABB"), observation(2, "aabb", 2, 1), observation(4, "bbaa")]); + expect(groups).toHaveLength(3); + expect(groups[0].reports.map(o => o.id)).toEqual([1, 3]); + expect(groups[0].hashes).toEqual(["AA", "BB"]); + expect(groups[1].hashes).toEqual(["AABB"]); +}); +it("never conflates missing or malformed evidence with an empty path", () => { + const groups = groupPacketReports([observation(1, undefined, 1, 0), observation(2), observation(3, "zz00"), observation(4, "aa"), observation(5, "aabb", 0)]); + expect(groups[0].kind).toBe("empty"); + expect(groups.filter(g => g.kind === "unavailable")).toHaveLength(4); +}); +it("validates a requested report instead of silently substituting another", () => { + const rows = [observation(1, "aabb"), observation(2, "ccdd")]; + expect(reportSelection(rows, new URLSearchParams("observation=2"), null)).toMatchObject({ selected: { id: 2 }, unavailable: false }); + for (const query of ["observation=9", "observation=1e0", "observation=-1", "observation=1&observation=2"]) { + expect(reportSelection(rows, new URLSearchParams(query), null)).toEqual({ selected: null, unavailable: true }); + } + expect(reportSelection(rows, new URLSearchParams(), 9)).toMatchObject({ selected: { id: 1 }, unavailable: false }); + expect(reportSelection([], new URLSearchParams(), null).selected).toBeNull(); +}); From dccf69ed8a3959a99fa31e954b77952c03cbf28b Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 00:23:26 -0400 Subject: [PATCH 13/20] fix(packets): keep selected evidence visible in compact investigations --- src/features/map/PacketPathMap.tsx | 2 +- src/features/packets/PacketAnalyzerDrawer.tsx | 2 +- src/features/packets/PacketInvestigation.tsx | 9 ++++++--- src/i18n/locales/en.json | 6 ++++-- src/i18n/locales/fr.json | 6 ++++-- tests/App.packetUrlContract.test.tsx | 2 +- tests/features/packets/PacketAnalyzerDrawer.test.tsx | 12 +++++++++++- 7 files changed, 28 insertions(+), 11 deletions(-) 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/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index f617971..4cb66d8 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -129,7 +129,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {unavailable &&

{t("investigation.unavailableReport")}

} - +
}

{t("investigation.retention")}

; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 072fe38..be45d9f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -318,7 +318,7 @@ "reports_other": "{{count}} retained reports", "observers_one": "{{count}} reporting observer", "observers_other": "{{count}} reporting observers", - "pathNote": "Reports are grouped by recorded path prefixes and hash width. Equal prefixes do not prove the same physical route; inspect each report for its candidate identities.", + "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}}", @@ -346,7 +346,9 @@ "reportedBy_other": "Reported by {{count}} observers", "first": "First", "last": "Last", - "span": "Reported span" + "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 5ab3b1e..223bea6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -322,7 +322,7 @@ "reports_other": "{{count}} rapports conservés", "observers_one": "{{count}} observateur déclarant", "observers_other": "{{count}} observateurs déclarants", - "pathNote": "Les rapports sont regroupés par préfixes enregistrés et largeur d’empreinte. Des préfixes égaux ne prouvent pas un même trajet physique; examinez les identités candidates de chaque rapport.", + "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}}", @@ -350,7 +350,9 @@ "reportedBy_other": "Signalé par {{count}} observateurs", "first": "Premier", "last": "Dernier", - "span": "Intervalle signalé" + "span": "Intervalle signalé", + "showAll": "Afficher les {{count}} groupes de trajets", + "fewer": "Afficher moins de trajets" } } } diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 2094107..eca03e2 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -146,7 +146,7 @@ describe("Packets deep links", () => { 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" }); - fireEvent.click(within(section).getByText("No path entries")); + 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(); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index fc48a50..7c3f47c 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -43,7 +43,7 @@ describe("packet reception evidence", () => { const onViewObserver = vi.fn(); const onViewPath = vi.fn(); render( {}} onViewObserver={onViewObserver} onViewPath={onViewPath} />); const section = screen.getByRole("region", { name: "Reception evidence" }); - fireEvent.click(within(section).getByText("Path 1")); + 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"); @@ -62,6 +62,16 @@ describe("packet reception evidence", () => { 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 }] }); From 5e6bf274f8cd30503bfba1222edef66e2ae00b36 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:49 -0400 Subject: [PATCH 14/20] fix: address release review findings for #83 --- src/App.tsx | 13 +++++++++++-- src/features/map/packet-path.ts | 2 +- tests/App.analyzerObservationCarryOver.test.tsx | 9 +++++++++ tests/features/map/packet-path.test.ts | 6 ++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 09e3475..37791bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -199,6 +199,15 @@ function AppInner() { setPathMapInitialKey(key ?? 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) => { // No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer. setSearchParams((p) => { @@ -289,7 +298,7 @@ function AppInner() { onAnalyze={handleAnalyze} onViewPath={handleViewPath} selectedObservationId={selectedObservationId} - onSelectObservation={setSelectedObservationId} + onSelectObservation={selectObservation} /> ), Nodes: , @@ -326,7 +335,7 @@ function AppInner() { detail={analyzerDetail} loading={analyzerLoading} selectedObservationId={selectedObservationId} - onSelectObservation={setSelectedObservationId} + onSelectObservation={selectObservation} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} onViewObserver={setQuickObserverId} diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index e2ec1a2..8e2e0e8 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -44,7 +44,7 @@ function pathPoints(hops: ResolvedHop[]): PathPoint[] { seen.add(node.id); out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude!, ...(gap && out.length ? { breakBefore: true } : {}) }); gap = false; - } else if (out.at(-1)?.id !== node.id) gap = true; + } else gap = out.at(-1)?.id !== node.id; } return out; } 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/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index bd2ae8b..ec0a7af 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -213,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(); +}); From 327ea06e92a1fbbdc2674d14ec8386216ce63a2a Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 04:59:39 -0400 Subject: [PATCH 15/20] fix(packets): select a report and open its analyzer atomically --- src/App.tsx | 6 +++--- src/features/packets/PacketExpansion.tsx | 4 ++-- src/features/packets/PacketList.tsx | 10 ++++++---- src/features/packets/PacketVirtualList.tsx | 2 +- tests/features/packets/PacketList.test.tsx | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 37791bd..a664b7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -208,11 +208,11 @@ function AppInner() { }, { replace: true }); }, [setSearchParams]); - const handleAnalyze = useCallback((hash: string | null) => { + 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) { if (n.get("hash") !== hash) n.delete("observation"); 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 }); @@ -298,7 +298,7 @@ function AppInner() { onAnalyze={handleAnalyze} onViewPath={handleViewPath} selectedObservationId={selectedObservationId} - onSelectObservation={selectObservation} + onSelectObservation={setSelectedObservationId} /> ), Nodes: , 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/PacketList.tsx b/src/features/packets/PacketList.tsx index 3592204..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; @@ -97,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(() => { @@ -193,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/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) => (
} + {first && <> +
+

{t("routeEvidence.match", { width: first.hashSize ?? "—" })}

+
    {first.route.hops.map((hop, index) =>
  1. {index + 1}.{hop.node?.name ?? "—"}
  2. )}
+

{t("routeEvidence.caution")}

+
+
+

{t("routeEvidence.retention")}

+ {!first.matchAvailable ?

{t("routeEvidence.unavailable")}

: reports.length === 0 ?

{t("routeEvidence.empty")}

:
    {reports.map(report =>
  • +
    {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}
    +

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

    +

    · SNR {report.snr == null ? "—" : `${report.snr} dB`} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

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

{t("routeEvidence.cap")}

} +
+ } + } + ; +} diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index a92b051..e3d883e 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -1,5 +1,7 @@ import { useState, useMemo, useCallback, useEffect, useRef, memo } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { RouteEvidencePanel, type RouteActions } from "./RouteEvidencePanel"; import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { useInfinitePages } from "../../hooks/useInfinitePages"; @@ -140,19 +142,26 @@ function directedPairs(iatas: string[]): [string, string][] { return pairs; } -export function RouteTable() { +export function RouteTable(actions: RouteActions) { const { iatas, regionKey } = useRegion(); - + const [params, setParams] = useSearchParams(); const [selectedKey, setSelectedKey] = useState(null); + const pathKey = params.get("route"), routeIata = params.get("routeIata"); + const closeRoute = useCallback(() => { + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); return next; }, { replace: true }); + }, [setParams]); + + // drop the selection when the region changes — the selected route may not be in the new region const prevRegion = useRef(regionKey); useEffect(() => { if (prevRegion.current !== regionKey) { prevRegion.current = regionKey; - setSelectedKey(null); + closeRoute(); } - }, [regionKey]); + }, [regionKey, closeRoute]); // path search form: source→dest hashes, scoped to a multi-select of IATAs. One IATA → within-IATA // /routes/search; two+ → /routes/cross across the directed pairs. Hashes + ≥1 IATA required. @@ -223,6 +232,18 @@ export function RouteTable() { [rows, selectedKey], ); + const selectRoute = (id: string | null) => { + if (id === null) { closeRoute(); return; } + const route = rows?.find(row => String(row.id) === id); + if (route?.pathKey) { + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); next.delete("routeSince"); next.delete("routeUntil"); return next; }); + } else { + closeRoute(); + setSelectedKey(id); + } + }; + // A multi-IATA region filters globally-paged rows client-side, so the filtered list can be too // short to ever trigger scroll paging — or empty, with the region's routes deeper in the cursor // stream. Keep pulling pages until there's a screenful or the cap says the region is just sparse. @@ -238,12 +259,12 @@ export function RouteTable() { const submitSearch = useCallback(() => { if (!from.trim() || !to.trim() || searchIatas.length < 1) return; setSearch({ from: from.trim(), to: to.trim(), iatas: searchIatas }); - setSelectedKey(null); - }, [from, to, searchIatas]); + closeRoute(); + }, [from, to, searchIatas, closeRoute]); const clearSearch = useCallback(() => { setSearch(null); - setSelectedKey(null); - }, []); + closeRoute(); + }, [closeRoute]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") submitSearch(); @@ -316,8 +337,8 @@ export function RouteTable() { columns={COLUMNS} rows={rows} rowKey={(r) => String(r.id)} - selectedKey={selectedKey} - onSelect={setSelectedKey} + selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey} + onSelect={selectRoute} isLoading={search ? searchLoading : listLoading} emptyLabel={search ? "No matching routes" : "No routes"} defaultSort={{ header: "Last seen", direction: "desc" }} @@ -329,7 +350,7 @@ export function RouteTable() { )}
)} - {selectedRoute && ( + {pathKey && routeIata ? : selectedRoute && ( setSelectedKey(null)} /> )}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index be45d9f..a7e79bc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -349,6 +349,30 @@ "span": "Reported span", "showAll": "Show all {{count}} path groups", "fewer": "Show fewer paths" + }, + "routeEvidence": { + "title": "Retained reports", + "window": "Time window", + "day": "24 hours", + "week": "7 days", + "month": "30 days", + "shared": "Shared time window", + "invalid": "Invalid shared time window. Choose a range to continue.", + "loading": "Loading retained reports…", + "missing": "The saved route or evidence API is unavailable on this server.", + "error": "Could not load retained reports. Try again.", + "retry": "Retry", + "savedPath": "Full saved route", + "match": "Matches the complete saved prefix sequence at {{width}} byte(s) per hop. Other widths, subsegments, TRACE and unclassified reports are excluded.", + "caution": "Matching bytes are not proof of the same physical route. Hop names are the saved route identities; inspect each packet for its candidate matches.", + "retention": "These are retained reports, not distinct packets or the route’s lifetime count. Reports can expire while the saved route remains. The time window ends at the exclusive UTC boundary shown above.", + "reports_one": "{{count}} report loaded", + "reports_other": "{{count}} reports loaded", + "unavailable": "The saved prefixes cannot be used to match retained reports.", + "empty": "No retained reports match this saved prefix sequence in this window. History may have expired or used a different prefix width.", + "more": "Load more reports", + "cap": "Showing up to 500 reports. Choose a shorter window to narrow the investigation.", + "legacy": "Retained report links need a server that supplies stable route keys." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 223bea6..b0b9273 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -353,6 +353,30 @@ "span": "Intervalle signalé", "showAll": "Afficher les {{count}} groupes de trajets", "fewer": "Afficher moins de trajets" + }, + "routeEvidence": { + "title": "Rapports conservés", + "window": "Période", + "day": "24 heures", + "week": "7 jours", + "month": "30 jours", + "shared": "Période partagée", + "invalid": "Période partagée invalide. Choisissez une période pour continuer.", + "loading": "Chargement des rapports conservés…", + "missing": "Le trajet enregistré ou l’API des rapports est indisponible sur ce serveur.", + "error": "Impossible de charger les rapports conservés. Réessayez.", + "retry": "Réessayer", + "savedPath": "Trajet complet enregistré", + "match": "Correspond à la séquence complète des préfixes enregistrés, avec {{width}} octet(s) par saut. Les autres tailles, les sous-trajets, TRACE et les rapports non classés sont exclus.", + "caution": "Des octets identiques ne prouvent pas un même trajet physique. Les noms des sauts correspondent aux identités enregistrées ; consultez chaque paquet pour voir ses correspondances possibles.", + "retention": "Il s’agit de rapports conservés, et non de paquets distincts ni du total historique du trajet. Les rapports peuvent expirer alors que le trajet reste enregistré. La fin de période UTC affichée ci-dessus est exclusive.", + "reports_one": "{{count}} rapport chargé", + "reports_other": "{{count}} rapports chargés", + "unavailable": "Les préfixes enregistrés ne permettent pas de rechercher les rapports conservés.", + "empty": "Aucun rapport conservé ne correspond à ces préfixes dans cette période. L’historique a pu expirer ou utiliser une autre taille de préfixe.", + "more": "Charger plus de rapports", + "cap": "Affichage limité à 500 rapports. Choisissez une période plus courte pour affiner la recherche.", + "legacy": "Les liens vers les rapports conservés nécessitent un serveur fournissant des clés de trajet stables." } } } diff --git a/src/types/api.ts b/src/types/api.ts index e8fbf13..5de0906 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -157,6 +157,7 @@ export interface RouteHop { export interface KnownRoute { id: number; + pathKey?: string; // stable full-route identity; absent on older servers iata: string; hopCount: number; hops: RouteHop[]; @@ -165,6 +166,32 @@ export interface KnownRoute { observationCount: number; } +export interface RouteObservation { + id: number; + packetHash: string; + observerId: string; + observerName?: string; + heardAt: number; + payloadType: number; + payloadTypeName: string; + rssi?: number; + snr?: number; +} + +export interface RouteEvidence { + route: KnownRoute; + windowStart: number; + windowEnd: number; + generatedAt: number; + matchType: "saved_path_prefixes"; + matchAvailable: boolean; + hashSize?: number; + pathBytes?: string; + items: RouteObservation[]; + hasMore: boolean; + nextPageCursor?: string; +} + // the boundary hop in a cross-IATA route: the link from the last node in the source IATA to the // first node in the target IATA. export interface CrossIATAHop { diff --git a/tests/api/route-evidence.test.ts b/tests/api/route-evidence.test.ts new file mode 100644 index 0000000..10de581 --- /dev/null +++ b/tests/api/route-evidence.test.ts @@ -0,0 +1,16 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getRouteEvidence } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); +it("encodes route identity and preserves the opaque cursor without numeric truncation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [], hasMore: false }) }); + vi.stubGlobal("fetch", fetcher); + const cursor = "v1:YOW:abcdef:1:2:1234567890000001:9007199254740993"; + const signal = new AbortController().signal; + await getRouteEvidence("Y/W", "abc/def", { pageCursor: cursor, limit: 50 }, signal); + const url = new URL(fetcher.mock.calls[0][0]); + expect(url.pathname).toContain("/routes/Y%2FW/abc%2Fdef/observations"); + expect(url.searchParams.get("pageCursor")).toBe(cursor); + expect(url.searchParams.has("range")).toBe(false); + expect(fetcher.mock.calls[0][1].signal).toBe(signal); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 7c3f47c..fd97546 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -55,6 +55,11 @@ describe("packet reception evidence", () => { expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); }); + it("keeps route evidence selections strict without requiring packet URL state", () => { + 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"); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx new file mode 100644 index 0000000..a1c7b2d --- /dev/null +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { RouteEvidencePanel } from "../../../src/features/routes/RouteEvidencePanel"; +import { getRouteEvidence } from "../../../src/api/client"; +import type { RouteEvidence } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ getRouteEvidence: vi.fn(), isNotFound: () => false })); +const key = "a".repeat(32); +const page: RouteEvidence = { + route: { id: 9, pathKey: key, iata: "YOW", hopCount: 2, hops: [{ nodeId: "n1", hashBytes: "ab", node: { id: "n1", publicKey: "abcd", name: "North" } }], firstSeen: 1, lastSeen: 2, observationCount: 200 }, + windowStart: 1700000000000, windowEnd: 1700086400000, generatedAt: 1700086400001, + matchType: "saved_path_prefixes", matchAvailable: true, hashSize: 1, pathBytes: "abcd", + items: [{ id: 17, packetHash: "aabb", observerId: "o1", observerName: "Garden", heardAt: 1700000000001, payloadType: 2, payloadTypeName: "TXT_MSG", snr: 0 }], hasMore: true, nextPageCursor: "pinned-cursor", +}; +const inspect = vi.fn(); const observer = vi.fn(); const node = vi.fn(); +function Harness() { + const [params, set] = useSearchParams(); + return <>{params.toString()} + {}} onAnalyzePacket={inspect} onViewObserver={observer} onViewNode={node} />; +} +function mount(url = "/?tab=Routes") { + render(); +} +beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); +describe("retained route evidence", () => { + it("opens the exact report and observer, names the saved route, and pins cursor pages", async () => { + mount(); + await screen.findByText("Garden"); + expect(screen.getByText(/not proof of the same physical route/)).toBeInTheDocument(); + expect(screen.getByText("North")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Inspect report" })); + expect(inspect).toHaveBeenCalledWith("aabb", 17); + fireEvent.click(screen.getByRole("button", { name: "Inspect observer" })); + expect(observer).toHaveBeenCalledWith("o1"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Load more reports" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { pageCursor: "pinned-cursor", limit: 50 }, expect.anything())); + }); + it("replaces results when the route changes, even while its request is pending", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByText("Other route")); + expect(screen.queryByText("Garden")).not.toBeInTheDocument(); + expect(screen.getByText("Loading retained reports…")).toBeInTheDocument(); + }); + it("uses shared fixed boundaries, then drops them when a new range is selected", async () => { + mount("/?routeSince=1700000000000&routeUntil=1700086400000"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700086400000, limit: 50 }, expect.anything()); + expect(screen.getByText("Shared time window")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7 days" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "168h", limit: 50 }, expect.anything())); + expect(screen.getByText(/routeRange=7d/)).not.toHaveTextContent("routeSince"); + }); + it("does not replace an invalid shared window with an unrelated default", async () => { + mount("/?routeSince=bad"); + expect(await screen.findByRole("alert")).toHaveTextContent("Invalid shared time window"); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); + it("explains expired or absent evidence without presenting the historical counter as a total", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + mount(); + expect(await screen.findByText(/No retained reports match/)).toBeInTheDocument(); + expect(screen.queryByText("200")).not.toBeInTheDocument(); + }); + it("distinguishes unavailable saved prefixes and translates the panel", async () => { + await i18n.changeLanguage("fr"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, matchAvailable: false, items: [], hasMore: false }); + mount(); + expect(await screen.findByText(/préfixes enregistrés ne permettent pas/)).toBeInTheDocument(); + expect(screen.getByText("Rapports conservés")).toBeInTheDocument(); + }); + it("keeps a failed next page separate from the reports already loaded", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockRejectedValue(new Error("offline")); + fireEvent.click(screen.getByRole("button", { name: "Load more reports" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load retained reports"); + expect(screen.getByText("Garden")).toBeInTheDocument(); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); +}); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index 1a3fd64..dabd0ee 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { RouteTable } from "../../../src/features/routes/RouteTable"; +import { MemoryRouter } from "react-router-dom"; import { RegionProvider } from "../../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../../src/hooks/region-selection"; import { @@ -34,7 +35,7 @@ function renderTable(selection = ALL_REGIONS) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + {children} ); render(, { wrapper }); From 93d26d59cda1febd14ec13d96c4c8def09905fb2 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 15:15:09 -0400 Subject: [PATCH 17/20] fix(routes): keep phone reports visible and within the viewport --- src/components/DetailPanel.tsx | 5 +++-- src/features/routes/RouteEvidencePanel.tsx | 10 +++++----- src/features/routes/RouteTable.tsx | 7 ++++--- src/i18n/locales/en.json | 6 +++++- src/i18n/locales/fr.json | 6 +++++- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index edbc443..0b833bb 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -37,6 +37,7 @@ function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: interface DetailPanelProps { title: string; onClose: () => void; + closeLabel?: string; // mobile-only: adds a minimize toggle that collapses to a bottom bar without deselecting, so the // map underneath (and its neighbor lines) stays visible. No effect at md+ (the panel is a sidebar). collapsible?: boolean; @@ -49,7 +50,7 @@ interface DetailPanelProps { children: ReactNode; } -export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) { +export function DetailPanel({ title, onClose, closeLabel = "Close detail panel", collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) { const [collapsed, setCollapsed] = useState(false); // collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:* // classes below always win at desktop width, so a lingering collapsed state never hides the sidebar. @@ -62,7 +63,7 @@ export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, {headerAction}
{collapsible && setCollapsed((v) => !v)} />} - +
diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index 2fdcdb3..e7a6299 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -37,22 +37,22 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const first = query.data?.pages[0]; const reports = query.data?.pages.flatMap(page => page.items) ?? []; const capped = (query.data?.pages.length ?? 0) >= 10 && query.data?.pages.at(-1)?.hasMore; - return ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}> + return ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}>
setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> {fixed &&

{t("routeEvidence.shared")}

} - {first &&

{new Date(first.windowStart).toISOString().replace("T", " ").replace(".000Z", "")} → {new Date(first.windowEnd).toISOString().replace("T", " ").replace(".000Z", "")} UTC

} + {first &&

{new Date(first.windowStart).toISOString().slice(0, 19).replace("T", " ")} → {new Date(first.windowEnd).toISOString().slice(0, 19).replace("T", " ")} UTC

}
{invalid ?

{t("routeEvidence.invalid")}

: query.isPending ?

{t("routeEvidence.loading")}

: <> {query.isError &&

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

} {first && <>
-

{t("routeEvidence.match", { width: first.hashSize ?? "—" })}

+

{t("routeEvidence.width", { width: first.hashSize ?? "—" })}

    {first.route.hops.map((hop, index) =>
  1. {index + 1}.{hop.node?.name ?? "—"}
  2. )}
-

{t("routeEvidence.caution")}

-

{t("routeEvidence.retention")}

+

{t("routeEvidence.brief")}

+
{t("routeEvidence.definition")}

{t("routeEvidence.match", { width: first.hashSize ?? "—" })}

{t("routeEvidence.caution")}

{t("routeEvidence.retention")}

{!first.matchAvailable ?

{t("routeEvidence.unavailable")}

: reports.length === 0 ?

{t("routeEvidence.empty")}

:
    {reports.map(report =>
  • {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}

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

    diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index e3d883e..b5b7bad 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -254,6 +254,7 @@ export function RouteTable(actions: RouteActions) { loadMore(); }, [search, serverIata, iatas, hasMore, isPaging, rows, loadedCount, loadMore]); + const panelOpen = Boolean(pathKey && routeIata || selectedRoute); const canSearch = !!(from.trim() && to.trim() && searchIatas.length >= 1); // clear any selection when the visible list changes out from under it (search submit/clear) const submitSearch = useCallback(() => { @@ -271,9 +272,9 @@ export function RouteTable(actions: RouteActions) { }; return ( -
    +
    {/* stacks into two rows on mobile (the inputs would otherwise wrap around the arrow); one row at md+ */} -
    +
    Find path ) : ( -
    +
    Date: Tue, 29 Sep 2026 21:29:50 -0400 Subject: [PATCH 18/20] fix: address release review findings for #85 --- src/App.tsx | 1 + src/features/routes/RouteEvidencePanel.tsx | 6 +++--- src/features/routes/RouteTable.tsx | 13 +++++++------ tests/features/routes/RouteTable.test.tsx | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 602c30b..18e6622 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -234,6 +234,7 @@ function AppInner() { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); + if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); // 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"); diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index e7a6299..fa9aed5 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -23,7 +23,7 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); const fixed = rawSince !== null || rawUntil !== null; const since = Number(rawSince), until = Number(rawUntil); - const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until - since > 30 * 86400000); + const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > Date.now() || until - since > 30 * 86400000); const query = useInfiniteQuery({ queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil], queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "7d" ? "168h" : range === "30d" ? "720h" : "24h", limit: 50 }, signal), @@ -47,12 +47,12 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on {query.isError &&

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

    } {first && <>
    -

    {t("routeEvidence.width", { width: first.hashSize ?? "—" })}

    +

    {t("routeEvidence.width", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

      {first.route.hops.map((hop, index) =>
    1. {index + 1}.{hop.node?.name ?? "—"}
    2. )}

    {t("routeEvidence.brief")}

    -
    {t("routeEvidence.definition")}

    {t("routeEvidence.match", { width: first.hashSize ?? "—" })}

    {t("routeEvidence.caution")}

    {t("routeEvidence.retention")}

    +
    {t("routeEvidence.definition")}

    {t("routeEvidence.match", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

    {t("routeEvidence.caution")}

    {t("routeEvidence.retention")}

    {!first.matchAvailable ?

    {t("routeEvidence.unavailable")}

    : reports.length === 0 ?

    {t("routeEvidence.empty")}

    :
      {reports.map(report =>
    • {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}

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

      diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index b5b7bad..c1aed26 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { RouteEvidencePanel, type RouteActions } from "./RouteEvidencePanel"; import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { useRegion, useRegionSelection } from "../../hooks/useRegion"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; @@ -144,6 +144,7 @@ function directedPairs(iatas: string[]): [string, string][] { export function RouteTable(actions: RouteActions) { const { iatas, regionKey } = useRegion(); + const { selection } = useRegionSelection(); const [params, setParams] = useSearchParams(); const [selectedKey, setSelectedKey] = useState(null); @@ -155,13 +156,13 @@ export function RouteTable(actions: RouteActions) { // drop the selection when the region changes — the selected route may not be in the new region - const prevRegion = useRef(regionKey); + const prevRegion = useRef(selection); useEffect(() => { - if (prevRegion.current !== regionKey) { - prevRegion.current = regionKey; + if (prevRegion.current !== selection) { + prevRegion.current = selection; closeRoute(); } - }, [regionKey, closeRoute]); + }, [selection, closeRoute]); // path search form: source→dest hashes, scoped to a multi-select of IATAs. One IATA → within-IATA // /routes/search; two+ → /routes/cross across the directed pairs. Hashes + ≥1 IATA required. @@ -237,7 +238,7 @@ export function RouteTable(actions: RouteActions) { const route = rows?.find(row => String(row.id) === id); if (route?.pathKey) { setSelectedKey(null); - setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); next.delete("routeSince"); next.delete("routeUntil"); return next; }); + setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); next.delete("routeSince"); next.delete("routeUntil"); return next; }, { replace: true }); } else { closeRoute(); setSelectedKey(id); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index dabd0ee..f117233 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -12,6 +12,7 @@ import { searchCrossIATARoutes, getIatas, getRegions, + getRegion, } from "../../../src/api/client"; import type { KnownRoute, CrossIATARoute } from "../../../src/types/api"; @@ -21,8 +22,11 @@ vi.mock("../../../src/api/client", () => ({ searchCrossIATARoutes: vi.fn(), getIatas: vi.fn(), getRegions: vi.fn(), + getRegion: vi.fn(), })); +vi.mock("../../../src/features/routes/RouteEvidencePanel", () => ({ RouteEvidencePanel: ({ pathKey }: { pathKey: string }) =>
      {pathKey}
      })); + const mockGetKnownRoutesPage = vi.mocked(getKnownRoutesPage); const mockSearchKnownRoutes = vi.mocked(searchKnownRoutes); const mockSearchCrossIATARoutes = vi.mocked(searchCrossIATARoutes); @@ -140,3 +144,14 @@ describe("RouteTable search", () => { expect(await screen.findByText("42")).toBeInTheDocument(); }); }); + +it("preserves a shared saved route while a named region resolves", async () => { + vi.mocked(getRegions).mockResolvedValue([{ id: 1, slug: "ontario", name: "Ontario" }]); + vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "ontario", name: "Ontario", iatas: ["YOW"] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + await waitFor(() => expect(getRegion).toHaveBeenCalledWith(1)); + await waitFor(() => expect(getKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "YOW" }))); + expect(screen.getByTestId("saved-route-selection")).toHaveTextContent("shared-key"); + client.clear(); +}); From ef8de07268b6c91944a1fba3c510a98281c23f32 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:06:48 -0400 Subject: [PATCH 19/20] fix(routes): remove obsolete region-key binding --- src/features/routes/RouteTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index c1aed26..8631af0 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -143,7 +143,7 @@ function directedPairs(iatas: string[]): [string, string][] { } export function RouteTable(actions: RouteActions) { - const { iatas, regionKey } = useRegion(); + const { iatas } = useRegion(); const { selection } = useRegionSelection(); const [params, setParams] = useSearchParams(); const [selectedKey, setSelectedKey] = useState(null); From 7a82605e8ecbf343bc5f0d9d4b800a43d7243ab5 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:10:03 -0400 Subject: [PATCH 20/20] fix(routes): validate fixed windows against their opening time --- src/features/routes/RouteEvidencePanel.tsx | 4 +++- src/features/routes/RouteTable.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index fa9aed5..6f81da2 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -19,11 +20,12 @@ export interface RouteActions { export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, onViewObserver, onViewNode }: RouteActions & { iata: string; pathKey: string; onClose: () => void }) { const { t } = useTranslation(); const [params, setParams] = useSearchParams(); + const [openedAt] = useState(Date.now); const range = params.get("routeRange") === "7d" ? "7d" : params.get("routeRange") === "30d" ? "30d" : "24h"; const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); const fixed = rawSince !== null || rawUntil !== null; const since = Number(rawSince), until = Number(rawUntil); - const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > Date.now() || until - since > 30 * 86400000); + const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 30 * 86400000); const query = useInfiniteQuery({ queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil], queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "7d" ? "168h" : range === "30d" ? "720h" : "24h", limit: 50 }, signal), diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 8631af0..9ba9e45 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -352,7 +352,7 @@ export function RouteTable(actions: RouteActions) { )}
    )} - {pathKey && routeIata ? : selectedRoute && ( + {pathKey && routeIata ? : selectedRoute && ( setSelectedKey(null)} /> )}