From e65e8e42ec7bcd75029ebc636645eedc2b889cbf Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 03:28:04 -0400 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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)} /> )}
    From 416f076c28323b0bc302ccf25c7c1532ab3e71be Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 16:14:09 -0400 Subject: [PATCH 21/45] fix(navigation): preserve observer investigation context and keyboard return --- src/App.tsx | 186 ++++++++---------- src/components/ModalOverlay.tsx | 11 +- src/features/InvestigationPanels.tsx | 48 +++++ src/features/map/PacketPathMapModal.tsx | 17 +- src/features/nodes/NodeDetailOverlay.tsx | 27 --- .../observers/ObserverDetailPanel.tsx | 13 +- src/features/observers/ObserverPage.tsx | 38 ++-- src/features/packets/PacketAnalyzerDrawer.tsx | 9 +- .../packets/PacketAnalyzerOverlay.tsx | 55 ------ src/i18n/locales/en.json | 8 +- src/i18n/locales/fr.json | 8 +- tests/App.observerInvestigation.test.tsx | 110 +++++++++++ tests/App.packetUrlContract.test.tsx | 4 +- tests/components/ModalOverlay.test.tsx | 18 ++ .../observers/ObserverDetailPanel.test.tsx | 7 +- .../packets/PacketAnalyzerDrawer.test.tsx | 7 + 16 files changed, 331 insertions(+), 235 deletions(-) create mode 100644 src/features/InvestigationPanels.tsx delete mode 100644 src/features/nodes/NodeDetailOverlay.tsx delete mode 100644 src/features/packets/PacketAnalyzerOverlay.tsx create mode 100644 tests/App.observerInvestigation.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 18e6622..46cc58c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; -import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useLocation, useNavigate, useSearchParams, type Location } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion"; @@ -18,15 +18,11 @@ import { AppShell } from "./components/AppShell"; import { SplashScreen } from "./components/SplashScreen"; import { PacketList } from "./features/packets/PacketList"; import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer"; -import { PacketAnalyzerOverlay } from "./features/packets/PacketAnalyzerOverlay"; -import { PacketPathMapModal } from "./features/map/PacketPathMapModal"; import { NodeTable } from "./features/nodes/NodeTable"; import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel"; -import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay"; +import { InvestigationPanel, type Investigation, type InvestigationTarget } from "./features/InvestigationPanels"; 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"; @@ -92,13 +88,14 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { // history. function RegionUrlSync() { const { selection } = useRegionSelection(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { const next = selectionToParams(selection, searchParams); if (next.toString() === searchParams.toString()) return; - setSearchParams(next, { replace: true }); - }, [selection, searchParams, setSearchParams]); + setSearchParams(next, { replace: true, state: location.state }); + }, [selection, searchParams, setSearchParams, location.state]); return null; } @@ -151,10 +148,14 @@ function TabLoading({ tab }: { tab: string }) { return ; } -function AppInner() { +function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: { + observerVisit?: Location; onObserverDashboard: (id: string) => void; + onReturn: () => void; onExitVisit: () => void; +}) { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); + const location = useLocation(); // 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. @@ -169,37 +170,39 @@ function AppInner() { const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); - // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts - 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) - const [overlayPacket, setOverlayPacket] = useState<{ hash: string; observationId?: number } | null>(null); - const overlayPacketHash = overlayPacket?.hash ?? null; - const setOverlayPacketHash = useCallback((hash: string | null, observationId?: number) => setOverlayPacket(hash ? { hash, observationId } : null), []); - // packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map") - const [pathMapDetail, setPathMapDetail] = useState(null); - // Frozen together: the restore must fetch the hash the link asked for, even if the user clicks a - // different row before it resolves. + const [panels, setPanels] = useState([]); + const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]); + const [panelScene, setPanelScene] = useState(scene); + // Browser Back to another entity ends its panels; dashboard visits freeze this location instead. + if (panelScene !== scene) { setPanelScene(scene); setPanels([]); } + const dashboardFocus = useRef(null); + const openPanel = useCallback((target: InvestigationTarget) => { + const key = target.kind === "packet" ? `packet:${target.hash.toLowerCase()}:${target.observationId ?? ""}` : target.kind === "path" ? `path:${target.detail.packetHash.toLowerCase()}:${target.selectedKey ?? "all"}` : `${target.kind}:${target.id}`; + setPanels(previous => { + const existing = previous.findIndex(panel => panel.key === key); + return existing < 0 ? [...previous, { key, target }] : previous.slice(0, existing + 1); + }); + }, [setPanels]); + const viewNode = useCallback((id: string) => openPanel({ kind: "node", id }), [openPanel]); + const viewObserver = useCallback((id: string) => openPanel({ kind: "observer", id }), [openPanel]); + const viewPacket = useCallback((hash: string, observationId?: number) => openPanel({ kind: "packet", hash, observationId }), [openPanel]); + const handleViewPath = useCallback((detail: PacketDetail, key?: string) => openPanel({ kind: "path", detail, selectedKey: key }), [openPanel]); const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") })); - const [pathMapInitialKey, setPathMapInitialKey] = useState(null); - const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); - const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); - const handlePathLinkRestore = useCallback((detail: PacketDetail, key: string) => { - setPathMapDetail(detail); - setPathMapInitialKey(key); - }, []); + useEffect(() => { + if (!observerVisit && dashboardFocus.current?.isConnected) dashboardFocus.current.focus(); + }, [observerVisit]); - // Reuse the fetched detail; a report action can isolate its observer's path. - const handleViewPath = useCallback((detail: PacketDetail, key?: string) => { - setPathMapDetail(detail); - setPathMapInitialKey(key ?? null); - }, []); + const closePanel = (index: number) => { + setPanels(previous => previous.slice(0, index)); + if (panels[index]?.target.kind === "path" && searchParams.has("path")) setSearchParams(previous => { + const next = new URLSearchParams(previous); next.delete("path"); return next; + }, { replace: true }); + }; const selectObservation = useCallback((id: number | null) => { setSelectedObservationId(id); @@ -221,15 +224,14 @@ function AppInner() { }, [setSearchParams]); const handleTabChange = (tab: string) => { - setOverlayNodeId(null); - setOverlayPacketHash(null); - setPathMapDetail(null); + setPanels([]); + dashboardFocus.current = null; + onExitVisit(); // On mobile a detail panel (and the analyzer) fills the screen, so leaving its tab must close it; // desktop side panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this. if (isMobile) { setSelectedObservationId(null); setSelectedNodeId(null); - setQuickObserverId(null); } setSearchParams((prev) => { const next = new URLSearchParams(prev); @@ -250,11 +252,10 @@ function AppInner() { const clearSelection = useCallback(() => { setSelectedNodeId(null); - setOverlayNodeId(null); - setOverlayPacketHash(null); - setQuickObserverId(null); - setPathMapDetail(null); - }, [setOverlayPacketHash]); + setPanels([]); + dashboardFocus.current = null; + onExitVisit(); + }, [onExitVisit, setSelectedNodeId, setPanels]); // 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 @@ -270,21 +271,12 @@ function AppInner() { const handleCloseNode = useCallback(() => { setSelectedNodeId(null); dropSelectionParam("node"); - }, [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); - return observerDestination(next, id); - }); - }, - [setSearchParams, setOverlayPacketHash], - ); + }, [dropSelectionParam, setSelectedNodeId]); + + const handleViewObserverStats = useCallback((id: string) => { + dashboardFocus.current = document.activeElement as HTMLElement | null; + onObserverDashboard(id); + }, [onObserverDashboard]); useEffect(() => { // Region slugs can't be expanded yet (region details load async) — connect with the directly @@ -306,10 +298,10 @@ function AppInner() { ), Nodes: , Observers: , - Routes: , + 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 - Traces: , + Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , Analytics: , Map: , @@ -318,16 +310,16 @@ function AppInner() { return ( - + } /> - -
    + +
  • )}
- }
Payload types · {range}} diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx index a7fe975..5b1b012 100644 --- a/tests/components/CopyLinkButton.test.tsx +++ b/tests/components/CopyLinkButton.test.tsx @@ -73,3 +73,11 @@ describe("CopyLinkButton", () => { expect(copied.searchParams.get("tab")).toBe("Map"); }); }); + +it("retains only an explicit allowlist when building a focused investigation link", () => { + window.history.replaceState({}, "", "/?regions=ontario&futureFilter=wrong&observer=old&tab=Routes"); + render(); + fireEvent.click(screen.getByRole("button")); + const copied = new URL(writeText.mock.calls[0][0]); + expect(Object.fromEntries(copied.searchParams)).toEqual({ regions: "ontario", tab: "Packets", hash: "abc", path: "all" }); +}); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 94463fa..99ba4b5 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MeshTab } from "../../../src/features/stats/MeshTab"; import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; @@ -44,15 +44,6 @@ function mount(range: StatsRange = "24h") { } async function loaded() { await waitFor(() => expect(screen.getAllByTestId("chart")).toHaveLength(6)); await screen.findByText("#old"); } -it("offers an observer button alongside the canvas leaderboard", async () => { - const { onSelectObserver } = mount(); await loaded(); - fireEvent.click(screen.getByText("Inspect an observer")); - const button = screen.getByRole("button", { name: /^Old observer/ }); - button.focus(); - expect(button).toHaveFocus(); - fireEvent.click(button); - expect(onSelectObserver).toHaveBeenCalledWith("observer-id"); -}); it("hides all previous-region values and sparklines until their own requests resolve", async () => { const { rerender, container } = mount(); await loaded(); From c0c11829bc6013ff90294557c3150844a5cfa09c Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:21:10 -0400 Subject: [PATCH 26/45] fix(navigation): retain observation callback dependencies --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 5140e90..d0a5294 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -211,7 +211,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: if (id == null) next.delete("observation"); else next.set("observation", String(id)); return next; }, { replace: true }); - }, [setSearchParams]); + }, [setSearchParams, setSelectedObservationId]); 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. From cad20038b788b497f82c098449b1e1e640a60445 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 19:30:43 -0400 Subject: [PATCH 27/45] feat(channels): show and filter recorded transport scopes --- docs/translations.md | 5 + src/api/client.ts | 6 +- src/components/LoadingPill.tsx | 7 +- src/features/channels/ChannelList.tsx | 69 ++++++++++--- src/features/channels/MessagePanel.tsx | 97 +++++++++++++------ src/features/channels/types.ts | 3 + src/i18n/locales/en.json | 26 +++++ src/i18n/locales/fr.json | 26 +++++ .../channels/MessagePanel.scope.test.tsx | 97 +++++++++++++++++++ tests/features/channels/MessagePanel.test.tsx | 3 +- 10 files changed, 288 insertions(+), 51 deletions(-) create mode 100644 tests/features/channels/MessagePanel.scope.test.tsx diff --git a/docs/translations.md b/docs/translations.md index 9a5dfd3..b747d28 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -15,6 +15,11 @@ locations in both languages. Packet bytes, identifiers and routing values stay u and existing UTC/date/number formatting, including automatic chart time labels, are unchanged; selecting French does not mean every screen is translated yet. +The channel message panel also translates scope labels, evidence help, retained-history/key +availability states, pagination and packet-inspection actions. Scope names, hashes and message +bodies remain unchanged. The channel directory and its existing search controls are separate +translation follow-ups. + ## Add a language 1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`. diff --git a/src/api/client.ts b/src/api/client.ts index 48df375..4990248 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -142,14 +142,16 @@ export function getChannels(params?: { iatas?: string[]; limit?: number; cursor? // a CursorPage so MessagePanel can load older history on demand via useInfiniteQuery. export async function getChannelMessagesPage( channelId: number, - params?: { iatas?: string[]; cursor?: number; limit?: number }, + params?: { iatas?: string[]; cursor?: number; limit?: number; scope?: string }, ): Promise> { const limit = params?.limit ?? DEFAULT_PAGE_SIZE; - const page = await request<{ items: ChannelMessage[] }>(`/channels/${channelId}/messages`, { + const page = await request<{ items: ChannelMessage[]; nextCursor?: number | null; hasMore?: boolean }>(`/channels/${channelId}/messages`, { iatas: iatasParam(params?.iatas), cursor: params?.cursor, limit, + scope: params?.scope || undefined, }); + if (typeof page.hasMore === "boolean") return { items: page.items, nextCursor: page.nextCursor ?? null, hasMore: page.hasMore }; return toCursorPage(page.items, limit, (m) => m.id); } diff --git a/src/components/LoadingPill.tsx b/src/components/LoadingPill.tsx index d8ca1f0..258d5b9 100644 --- a/src/components/LoadingPill.tsx +++ b/src/components/LoadingPill.tsx @@ -3,12 +3,13 @@ interface LoadingPillProps { error?: boolean; count: number; // rows loaded so far noun: string; // plural entity name, e.g. "nodes" / "observers" + label?: string; // optional complete localized phrase position?: string; // corner placement (the parent must be `relative`) } // Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while // pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle. -export function LoadingPill({ loading, error, count, noun, position = "bottom-3 left-3" }: LoadingPillProps) { +export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) { if (!loading && !error) return null; const tone = loading ? "text-text-muted" : "text-danger"; const dot = loading ? "bg-primary animate-pulse" : "bg-danger"; @@ -18,11 +19,11 @@ export function LoadingPill({ loading, error, count, noun, position = "bottom-3 className={`absolute ${position} z-10 flex items-center gap-2 px-2.5 py-1 bg-bg-surface border border-border-subtle rounded-md font-mono text-[11px] ${tone} shadow-lg`} > - {loading + {label ?? (loading ? `Loading ${noun}… (${count})` : count > 0 ? `Some ${noun} failed to load (${count} shown)` - : `Failed to load ${noun}`} + : `Failed to load ${noun}`)}
); } diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index 12180f2..c12ac0b 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,8 +1,8 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getChannels } from "../../api/client"; import { isRateLimited } from "../../api/rate-limit"; -import { MAX_INFINITE_PAGES } from "../../lib/constants"; +import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants"; import { useRegion } from "../../hooks/useRegion"; import { useIsMobile } from "../../hooks/useMediaQuery"; import { useWsChannelMessageHandler } from "../../hooks/useWsHandlers"; @@ -20,6 +20,14 @@ interface ChannelListProps { onAnalyze: (hash: string | null) => void; } +function appendMessages(old: InfiniteData> | undefined, messages: ChannelMessage[]) { + if (!old) return old; + const known = new Set(old.pages.flatMap((page) => page.items.map((message) => message.packetHash))); + const added = messages.filter((message) => !known.has(message.packetHash)); + if (!added.length) return old; + return { ...old, pages: old.pages.map((page, index) => index === 0 ? { ...page, items: [...page.items, ...added] } : page) }; +} + export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const { iatas, regionKey } = useRegion(); const isMobile = useIsMobile(); @@ -27,20 +35,42 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const [selection, setSelection] = useState(null); const selectedId = selection?.id ?? null; const [heardCounts, setHeardCounts] = useState>({}); + const [messageScope, setMessageScope] = useState(""); const [search, setSearch] = useState(""); const [searchField, setSearchField] = useState("name"); const [keyFilter, setKeyFilter] = useState(""); const [hashtagFilter, setHashtagFilter] = useState(""); const queryClient = useQueryClient(); const refreshPending = useRef(false); + const pendingMessages = useRef(new Map()); + const messagesOverflowed = useRef(false); + const messageKey = useMemo(() => ["channel-messages", selectedId, regionKey, messageScope], [selectedId, regionKey, messageScope]); + const messageFetching = useIsFetching({ queryKey: messageKey, exact: true }); + + useEffect(() => { + if (messageFetching) return; + if (messagesOverflowed.current && !isRateLimited()) { + messagesOverflowed.current = false; + pendingMessages.current.clear(); + void queryClient.resetQueries({ queryKey: messageKey, exact: true }); + } else if (pendingMessages.current.size && queryClient.getQueryData(messageKey)) { + // Merge live arrivals after history settles so its older snapshot cannot erase them. + const queued = [...pendingMessages.current.values()]; + pendingMessages.current.clear(); + queryClient.setQueryData>>(messageKey, (old) => appendMessages(old, queued)); + } + }, [messageFetching, messageKey, queryClient]); const prevRegion = useRef(regionKey); useEffect(() => { if (prevRegion.current !== regionKey) { prevRegion.current = regionKey; refreshPending.current = false; + pendingMessages.current.clear(); + messagesOverflowed.current = false; setSelection(null); setHeardCounts({}); + setMessageScope(""); setSearch(""); setKeyFilter(""); setHashtagFilter(""); @@ -74,10 +104,18 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { }, [data]); const handleSelect = useCallback((id: number) => { + pendingMessages.current.clear(); + messagesOverflowed.current = false; setSelection(channels.find((ch) => ch.id === id) ?? null); setHeardCounts({}); }, [channels]); + const handleScopeChange = useCallback((scope: string) => { + pendingMessages.current.clear(); + messagesOverflowed.current = false; + setMessageScope(scope); + }, []); + // "Public" pinned first, then named channels, then unnamed by most recent const sortedChannels = useMemo( () => @@ -104,7 +142,8 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const key = ["channels", regionKey]; const cached = queryClient.getQueryData>(key); const cachedChannels = cached?.pages.flatMap((p) => p.items) ?? []; - const known = cachedChannels.find((ch) => ch.channelHash === data.channelHash); + const matchesChannel = (ch: ChannelSummary) => data.channelId !== undefined ? ch.id === data.channelId : ch.channelHash === data.channelHash; + const known = cachedChannels.find(matchesChannel); if (known) { // Display timestamps may change; the server's page cursors must not. queryClient.setQueryData>(key, (old) => old && ({ @@ -119,25 +158,25 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { } const selected = cachedChannels.find((ch) => ch.id === selectedId) ?? selection; - if (selected && data.channelHash === selected.channelHash) { - // same message, multiple observer paths — count the reach + if (selected && matchesChannel(selected) && (!messageScope || data.scope === messageScope)) { + if (queryClient.isFetching({ queryKey: messageKey, exact: true }) || !queryClient.getQueryData(messageKey)) { + if (pendingMessages.current.size >= LIVE_BUFFER_CAP && !pendingMessages.current.has(data.packetHash)) { + messagesOverflowed.current = true; + } else pendingMessages.current.set(data.packetHash, data); + } + // A repeated WS delivery is not another observer; retained counts remain authoritative. setHeardCounts((prev) => ({ ...prev, - [data.packetHash]: (prev[data.packetHash] ?? 0) + 1, + [data.packetHash]: Math.max(prev[data.packetHash] ?? 0, data.observationCount ?? 1), })); // append to the newest InfiniteData page; MessagePanel re-sorts by sentAt, so the page is arbitrary queryClient.setQueryData>>( - ["channel-messages", selectedId, regionKey], - (old) => { - if (!old) return old; - if (old.pages.some((p) => p.items.some((msg) => msg.packetHash === data.packetHash))) return old; - const pages = old.pages.map((p, i) => (i === 0 ? { ...p, items: [...p.items, data] } : p)); - return { ...old, pages }; - }, + messageKey, + (old) => appendMessages(old, [data]), ); } }, - [queryClient, selectedId, selection, regionKey], + [queryClient, selectedId, selection, regionKey, messageScope, messageKey], ); useWsChannelMessageHandler(wsManager, handleChannelMessage); @@ -193,6 +232,8 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { heardCounts={heardCounts} iatas={iatas} regionKey={regionKey} + scope={messageScope} + onScopeChange={handleScopeChange} onAnalyze={onAnalyze} onBack={isMobile ? () => setSelection(null) : undefined} /> diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index 1bc0a53..e767b73 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -1,9 +1,14 @@ import { useMemo, useRef, useLayoutEffect, useState, useCallback } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { getChannelMessagesPage } from "../../api/client"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { LoadingPill } from "../../components/LoadingPill"; +import { ScopeTag } from "../../components/ScopeTag"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { useScopes } from "../../hooks/useScopes"; +import { MAX_INFINITE_PAGES } from "../../lib/constants"; import { channelDisplayName } from "./types"; import type { ChannelSummary, ChannelMessage } from "./types"; @@ -22,8 +27,10 @@ function senderColor(name: string): string { return SENDER_COLORS[Math.abs(h) % SENDER_COLORS.length] ?? "text-primary"; } -// keyed on packetHash by the caller — live WS messages carry no id (REST ones do) +// Packet hash also identifies messages delivered by older servers without a live ID. function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heardCount?: number; onAnalyze?: (hash: string) => void }) { + const { t } = useTranslation(); + const scopeLabel = msg.scope ?? t(msg.scopeStatus === "unscoped" ? "channelMessages.unscoped" : msg.scopeStatus === "unknown" ? "channelMessages.unknownScope" : "channelMessages.unavailableScope"); // REST carries the server-side total; the live WS counter augments it during the session const reach = Math.max(msg.observationCount ?? 0, heardCount ?? 0); return ( @@ -31,12 +38,16 @@ function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heard className={`px-3 py-2${onAnalyze ? " cursor-pointer hover:bg-bg-surface transition-colors" : ""}`} onClick={onAnalyze ? () => onAnalyze(msg.packetHash) : undefined} > -
- +
+ {msg.senderName} {reach > 0 && ×{reach}} + {msg.scope ? {scopeLabel} : {scopeLabel}} + {onAnalyze && }
{msg.content}
@@ -51,20 +62,31 @@ interface MessagePanelProps { onAnalyze?: (packetHash: string) => void; // mobile-only: renders a back button, since here the panel replaces the channel list onBack?: () => void; + scope?: string; + onScopeChange?: (scope: string) => void; } -export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack }: MessagePanelProps) { - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ["channel-messages", channel?.id, regionKey], - queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50 }), - getNextPageParam: (last) => last.nextCursor ?? undefined, +export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack, scope = "", onScopeChange }: MessagePanelProps) { + const { t } = useTranslation(); + const scopeNames = useScopes(); + const { data, isLoading, isError, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["channel-messages", channel?.id, regionKey, scope], + queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50, scope }), + getNextPageParam: (last) => last.hasMore ? last.nextCursor ?? undefined : undefined, initialPageParam: undefined as number | undefined, enabled: channel !== null, staleTime: 30_000, + maxPages: MAX_INFINITE_PAGES, }); // flatten order is irrelevant — the ascending sort below restores chat order from newest-first pages - const messages = useMemo(() => data?.pages.flatMap((p) => p.items), [data]); + const messages = useMemo(() => { + const unique = new Map(); + for (const page of data?.pages ?? []) { + for (const message of page.items) if (!unique.has(message.packetHash)) unique.set(message.packetHash, message); + } + return [...unique.values()]; + }, [data]); const sorted = useMemo( () => [...(messages ?? [])].sort((a, b) => a.sentAt - b.sentAt), @@ -74,7 +96,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const bottomRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(null); - const scrollAnchor = useRef<{ channelId?: number; count: number }>({ count: 0 }); + const viewKey = `${channel?.id ?? ""}:${scope}`; + const scrollAnchor = useRef<{ view?: string; count: number }>({ count: 0 }); // pre-prepend scroll metrics, captured while a load-older fetch is in flight const prepend = useRef<{ pending: boolean; prevHeight: number; prevTop: number }>({ pending: false, @@ -83,9 +106,9 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze }); // reset scroll tracking on channel switch — adjust state during render, not in an effect - const [prevChannelId, setPrevChannelId] = useState(channel?.id); - if (prevChannelId !== channel?.id) { - setPrevChannelId(channel?.id); + const [prevView, setPrevView] = useState(viewKey); + if (prevView !== viewKey) { + setPrevView(viewKey); setUserScrolled(false); } @@ -93,10 +116,10 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const el = scrollContainerRef.current; const anchor = scrollAnchor.current; - if (anchor.channelId !== channel?.id) { + if (anchor.view !== viewKey) { // first batch for this channel — jump to the bottom once it lands if (isLoading) return; - anchor.channelId = channel?.id; + anchor.view = viewKey; anchor.count = sorted.length; prepend.current = { pending: false, prevHeight: 0, prevTop: 0 }; if (el) el.scrollTop = el.scrollHeight; @@ -115,7 +138,14 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down } anchor.count = sorted.length; - }, [sorted.length, channel?.id, isLoading, userScrolled]); + }, [sorted.length, viewKey, isLoading, userScrolled]); + + const loadOlder = useCallback(() => { + if (!hasNextPage || isFetching) return; + const el = scrollContainerRef.current; + prepend.current = { pending: true, prevHeight: el?.scrollHeight ?? 0, prevTop: el?.scrollTop ?? 0 }; + void fetchNextPage(); + }, [hasNextPage, isFetching, fetchNextPage]); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; @@ -123,16 +153,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; setUserScrolled(!atBottom); // near the top: pull the next older page, capturing metrics so the prepend can hold position - if (el.scrollTop < 40 && hasNextPage && !isFetchingNextPage) { - prepend.current = { pending: true, prevHeight: el.scrollHeight, prevTop: el.scrollTop }; - fetchNextPage(); - } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + if (el.scrollTop < 40) loadOlder(); + }, [loadOlder]); if (!channel) { return (
- Select a channel + {t("channelMessages.select")}
); } @@ -145,7 +172,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
{channel.keyKnown ? ( - key known + {t("channelMessages.keyKnown")} ) : ( - no key + {t("channelMessages.noKey")} )} - {channel.isHashtag && hashtag} + {channel.isHashtag && {t("channelMessages.hashtag")}}
{!channel.keyKnown && (
- Key not known, messages may not be decrypted + {t("channelMessages.keyMissing")}
)} +
+ {onScopeChange && ({ value: name, label: name }))} align="left" />} +
{t("channelMessages.aboutScopes")}

{t("channelMessages.scopeHelp")}

+
+ {isError &&
{t("channelMessages.error")}
} +
{isLoading ? (
- Loading... + {t("channelMessages.loading")}
) : messages && messages.length > 0 ? (
+ {hasNextPage && } {sorted.map((msg) => ( ))} @@ -192,13 +227,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
) : (
- No messages + {t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
)}
{/* floats over the panel (not the scroll area) so older-page fetches don't shift it */} - +
); } diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index 927ba65..cfc1a4b 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -33,4 +33,7 @@ export interface ChannelMessage { content: string; sentAt: number; // epoch ms, from the sender's embedded timestamp observationCount?: number; + channelId?: number; // supplied by live events; channel history already has an ID-specific endpoint + scope?: string | null; // absent on older servers, null when no name was recorded + scopeStatus?: "matched" | "unscoped" | "unknown" | "unavailable"; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e406ec2..c95a379 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -383,6 +383,32 @@ "brief": "Prefix matches, not confirmed physical routes. Raw reports may expire.", "definition": "How these reports are matched and counted", "close": "Close route reports" + }, + "channelMessages": { + "select": "Select a channel", + "back": "Back to channels", + "hash": "hash: {{hash}}", + "keyKnown": "key known", + "noKey": "no key", + "hashtag": "hashtag", + "keyMissing": "No decryption key is configured for this channel.", + "scope": "Transport scope", + "allScopes": "All scopes", + "aboutScopes": "About scope labels", + "scopeHelp": "Labels describe the first recorded packet, not the channel key. Other receptions may differ; inspect the packet for its reports. Only retained messages are shown.", + "unscoped": "No transport scope", + "unknownScope": "Unresolved scope", + "unavailableScope": "Scope unavailable", + "inspect": "Inspect packet from {{sender}}", + "packet": "Packet", + "loading": "Loading messages…", + "loadingOlder": "Loading older messages… ({{count}} shown)", + "loadOlder": "Load older messages", + "empty": "No retained messages for this channel.", + "emptyScope": "No retained messages match {{scope}}.", + "emptyNoKey": "No decrypted messages are available without a known channel key.", + "error": "Could not load messages.", + "retry": "Retry" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 6776906..71cb926 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -387,6 +387,32 @@ "brief": "Préfixes correspondants, pas des trajets physiques confirmés. Les rapports bruts peuvent expirer.", "definition": "Correspondance et comptage des rapports", "close": "Fermer les rapports du trajet" + }, + "channelMessages": { + "select": "Choisir un canal", + "back": "Retour aux canaux", + "hash": "empreinte : {{hash}}", + "keyKnown": "clé connue", + "noKey": "sans clé", + "hashtag": "mot-dièse", + "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", + "scope": "Portée de transport", + "allScopes": "Toutes les portées", + "aboutScopes": "À propos des portées", + "scopeHelp": "Les étiquettes décrivent le premier paquet enregistré, pas la clé du canal. D’autres réceptions peuvent différer ; inspectez le paquet pour consulter ses observations. Seuls les messages conservés sont affichés.", + "unscoped": "Sans portée de transport", + "unknownScope": "Portée non résolue", + "unavailableScope": "Portée indisponible", + "inspect": "Inspecter le paquet de {{sender}}", + "packet": "Paquet", + "loading": "Chargement des messages…", + "loadingOlder": "Chargement des messages plus anciens… ({{count}} affichés)", + "loadOlder": "Charger les messages plus anciens", + "empty": "Aucun message conservé pour ce canal.", + "emptyScope": "Aucun message conservé ne correspond à {{scope}}.", + "emptyNoKey": "Aucun message déchiffré n’est disponible sans clé de canal connue.", + "error": "Impossible de charger les messages.", + "retry": "Réessayer" } } } diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx new file mode 100644 index 0000000..5ad4ee9 --- /dev/null +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MessagePanel } from "../../../src/features/channels/MessagePanel"; +import { ChannelList } from "../../../src/features/channels/ChannelList"; +import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { getChannelMessagesPage, getChannels } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client",()=>({getChannelMessagesPage:vi.fn(),getChannels:vi.fn(),getScopes:vi.fn(async()=>["#can","#yow"])})); +vi.mock("../../../src/hooks/useRegion",()=>({useRegion:()=>({iatas:["YOW"],regionKey:"YOW"})})); +let onMessage:(data:ChannelMessage)=>void; +vi.mock("../../../src/hooks/useWsHandlers",()=>({useWsChannelMessageHandler:(_m:unknown,fn:typeof onMessage)=>{onMessage=fn;}})); +const channel:ChannelSummary={id:1,name:"#test",channelHash:"11",keyKnown:true,isHashtag:true,lastSeen:1}; +const base={channelHash:"11",senderName:"Fixture",content:"test message",sentAt:1}; +const messages=[ + {...base,id:4,packetHash:"matched",scope:"#yow",scopeStatus:"matched" as const}, + {...base,id:3,packetHash:"unscoped",scope:null,scopeStatus:"unscoped" as const}, + {...base,id:2,packetHash:"unknown",scope:null,scopeStatus:"unknown" as const}, + {...base,id:1,packetHash:"legacy"}, +]; +const page=(items:ChannelMessage[],nextCursor:number|null=null)=>({items,nextCursor,hasMore:nextCursor!==null}); +function show(node:React.ReactNode){const client=new QueryClient({defaultOptions:{queries:{retry:false}}});return{client,...render({node})};} +beforeEach(()=>{ + vi.clearAllMocks(); + vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages)); + vi.mocked(getChannels).mockResolvedValue({items:[channel],hasMore:false,nextCursor:null}); + Element.prototype.scrollIntoView=vi.fn(); +}); + +describe("channel scope evidence",()=>{ + it("distinguishes matched, unscoped, unresolved and legacy metadata, with keyboard packet inspection",async()=>{ + const inspect=vi.fn(); + show(); + expect(await screen.findByText("#yow")).toBeInTheDocument(); + expect(screen.getByText("No transport scope")).toBeInTheDocument(); + expect(screen.getByText("Unresolved scope")).toBeInTheDocument(); + expect(screen.getByText("Scope unavailable")).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("button",{name:"Inspect packet from Fixture"})[0]!); + expect(inspect).toHaveBeenCalledTimes(1); + }); + it("switches French labels without refetching or changing protocol names",async()=>{ + show(); + await screen.findByText("#yow"); + await act(()=>i18n.changeLanguage("fr")); + expect(screen.getByText("Sans portée de transport")).toBeInTheDocument(); + expect(screen.getByText("Portée non résolue")).toBeInTheDocument(); + expect(screen.getByText("Portée indisponible")).toBeInTheDocument(); + expect(screen.getByText("#yow")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("isolates scope history and live messages, preserving page cursors and channel identity",async()=>{ + vi.mocked(getChannelMessagesPage).mockImplementation(async(_id,p)=>page(p?.scope==="#yow"?[messages[0]!]:messages,p?.scope==="#yow"?4:null)); + const {client}=show({}}/>); + fireEvent.click(await screen.findByRole("button",{name:/#test/})); + await screen.findByText("No transport scope"); + fireEvent.click(screen.getByRole("button",{name:/Transport scope.*All scopes/})); + fireEvent.click(screen.getByRole("option",{name:"#yow",exact:true})); + await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1,expect.objectContaining({scope:"#yow"}))); + await waitFor(()=>expect(screen.queryByText("No transport scope")).not.toBeInTheDocument()); + await waitFor(()=>expect(client.getQueryData(["channel-messages",1,"YOW","#yow"])).toBeDefined()); + const event={...base,id:5,channelId:1,packetHash:"live-yow",content:"in scope",scope:"#yow",scopeStatus:"matched" as const}; + act(()=>{onMessage({...event,packetHash:"other",scope:"#can",content:"wrong scope"});onMessage({...event,channelId:2,packetHash:"collision",content:"wrong channel"});onMessage(event);onMessage(event);}); + expect(await screen.findByText("in scope")).toBeInTheDocument(); + expect(screen.queryByText("wrong scope")).not.toBeInTheDocument(); + expect(screen.queryByText("wrong channel")).not.toBeInTheDocument(); + expect(screen.getAllByText("in scope")).toHaveLength(1); + const cache=client.getQueryData<{pages:ReturnType[];pageParams:unknown[]}>(["channel-messages",1,"YOW","#yow"])!; + expect(cache.pages[0]!.nextCursor).toBe(4);expect(cache.pageParams).toEqual([undefined]); + fireEvent.click(screen.getByRole("button",{name:"Load older messages"})); + await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1,expect.objectContaining({scope:"#yow",cursor:4}))); + }); + it("explains an empty filtered history",async()=>{ + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show({}}/>); + expect(await screen.findByText("No retained messages match #yow.")).toBeInTheDocument(); + }); + it("keeps messages received during history loading without an extra request",async()=>{ + let resolveHistory!:(value:ReturnType)=>void; + const live={...messages[0]!,id:5,channelId:1,packetHash:"during-read",content:"arrived during history"}; + vi.mocked(getChannelMessagesPage).mockImplementationOnce(()=>new Promise(resolve=>{resolveHistory=resolve;})).mockResolvedValue(page([live])); + show({}}/>); + fireEvent.click(await screen.findByRole("button",{name:/#test/})); + await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenCalledTimes(1)); + act(()=>{onMessage(live);onMessage(live);}); + await act(async()=>resolveHistory(page([]))); + expect(await screen.findByText("arrived during history")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("explains missing decryption keys without inventing a message scope",async()=>{ + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show(); + expect(await screen.findByText("No decrypted messages are available without a known channel key.")).toBeInTheDocument(); + expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index ca7c44c..de779cd 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MessagePanel } from "../../../src/features/channels/MessagePanel"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; -// live WS messages have no id — mirror that runtime shape in the page data +// Older live WS messages have no id — preserve that compatible runtime shape. const restMsg: ChannelMessage = { id: 1, packetHash: "ph-rest", @@ -40,6 +40,7 @@ const multiLineMsg: ChannelMessage = { }; vi.mock("../../../src/api/client", () => ({ + getScopes: vi.fn(async () => []), getChannelMessagesPage: vi.fn(() => Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB, multiLineMsg], nextCursor: null, hasMore: false }), ), From 19bbbd124982584841e76a4f6e14a61474c87e3a Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:53 -0400 Subject: [PATCH 28/45] fix: address release review findings for #89 --- src/features/channels/ChannelList.tsx | 6 +- src/features/channels/MessagePanel.tsx | 2 - .../channels/MessagePanel.scope.test.tsx | 190 +++++++++++++----- 3 files changed, 140 insertions(+), 58 deletions(-) diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index c12ac0b..282b73f 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -52,7 +52,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { if (messagesOverflowed.current && !isRateLimited()) { messagesOverflowed.current = false; pendingMessages.current.clear(); - void queryClient.resetQueries({ queryKey: messageKey, exact: true }); + void queryClient.invalidateQueries({ queryKey: messageKey, exact: true }); } else if (pendingMessages.current.size && queryClient.getQueryData(messageKey)) { // Merge live arrivals after history settles so its older snapshot cannot erase them. const queued = [...pendingMessages.current.values()]; @@ -164,12 +164,12 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { messagesOverflowed.current = true; } else pendingMessages.current.set(data.packetHash, data); } - // A repeated WS delivery is not another observer; retained counts remain authoritative. + // The WS event has no retained observation total; repeats do not add observers. setHeardCounts((prev) => ({ ...prev, [data.packetHash]: Math.max(prev[data.packetHash] ?? 0, data.observationCount ?? 1), })); - // append to the newest InfiniteData page; MessagePanel re-sorts by sentAt, so the page is arbitrary + // Append to the retained newest page; browsing older history must not evict it. queryClient.setQueryData>>( messageKey, (old) => appendMessages(old, [data]), diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index e767b73..89bc198 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -8,7 +8,6 @@ import { LoadingPill } from "../../components/LoadingPill"; import { ScopeTag } from "../../components/ScopeTag"; import { SelectDropdown } from "../../components/SelectDropdown"; import { useScopes } from "../../hooks/useScopes"; -import { MAX_INFINITE_PAGES } from "../../lib/constants"; import { channelDisplayName } from "./types"; import type { ChannelSummary, ChannelMessage } from "./types"; @@ -76,7 +75,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze initialPageParam: undefined as number | undefined, enabled: channel !== null, staleTime: 30_000, - maxPages: MAX_INFINITE_PAGES, }); // flatten order is irrelevant — the ascending sort below restores chat order from newest-first pages diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index 5ad4ee9..bed1637 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -8,90 +8,174 @@ import type { WsManager } from "../../../src/api/ws-manager"; import { getChannelMessagesPage, getChannels } from "../../../src/api/client"; import i18n from "../../../src/i18n"; -vi.mock("../../../src/api/client",()=>({getChannelMessagesPage:vi.fn(),getChannels:vi.fn(),getScopes:vi.fn(async()=>["#can","#yow"])})); -vi.mock("../../../src/hooks/useRegion",()=>({useRegion:()=>({iatas:["YOW"],regionKey:"YOW"})})); -let onMessage:(data:ChannelMessage)=>void; -vi.mock("../../../src/hooks/useWsHandlers",()=>({useWsChannelMessageHandler:(_m:unknown,fn:typeof onMessage)=>{onMessage=fn;}})); -const channel:ChannelSummary={id:1,name:"#test",channelHash:"11",keyKnown:true,isHashtag:true,lastSeen:1}; -const base={channelHash:"11",senderName:"Fixture",content:"test message",sentAt:1}; -const messages=[ - {...base,id:4,packetHash:"matched",scope:"#yow",scopeStatus:"matched" as const}, - {...base,id:3,packetHash:"unscoped",scope:null,scopeStatus:"unscoped" as const}, - {...base,id:2,packetHash:"unknown",scope:null,scopeStatus:"unknown" as const}, - {...base,id:1,packetHash:"legacy"}, +vi.mock("../../../src/api/client", () => ({ + getChannelMessagesPage: vi.fn(), + getChannels: vi.fn(), + getScopes: vi.fn(async () => ["#can", "#yow"]), +})); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }) })); +let onMessage: (data: ChannelMessage) => void; +vi.mock("../../../src/hooks/useWsHandlers", () => ({ + useWsChannelMessageHandler: (_m: unknown, fn: typeof onMessage) => { + onMessage = fn; + }, +})); +const channel: ChannelSummary = { + id: 1, + name: "#test", + channelHash: "11", + keyKnown: true, + isHashtag: true, + lastSeen: 1, +}; +const base = { channelHash: "11", senderName: "Fixture", content: "test message", sentAt: 1 }; +const messages = [ + { ...base, id: 4, packetHash: "matched", scope: "#yow", scopeStatus: "matched" as const }, + { ...base, id: 3, packetHash: "unscoped", scope: null, scopeStatus: "unscoped" as const }, + { ...base, id: 2, packetHash: "unknown", scope: null, scopeStatus: "unknown" as const }, + { ...base, id: 1, packetHash: "legacy" }, ]; -const page=(items:ChannelMessage[],nextCursor:number|null=null)=>({items,nextCursor,hasMore:nextCursor!==null}); -function show(node:React.ReactNode){const client=new QueryClient({defaultOptions:{queries:{retry:false}}});return{client,...render({node})};} -beforeEach(()=>{ +const page = (items: ChannelMessage[], nextCursor: number | null = null) => ({ + items, + nextCursor, + hasMore: nextCursor !== null, +}); +function show(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { client, ...render({node}) }; +} +beforeEach(() => { vi.clearAllMocks(); vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages)); - vi.mocked(getChannels).mockResolvedValue({items:[channel],hasMore:false,nextCursor:null}); - Element.prototype.scrollIntoView=vi.fn(); + vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null }); + Element.prototype.scrollIntoView = vi.fn(); }); -describe("channel scope evidence",()=>{ - it("distinguishes matched, unscoped, unresolved and legacy metadata, with keyboard packet inspection",async()=>{ - const inspect=vi.fn(); - show(); +describe("channel scope evidence", () => { + it("distinguishes matched, unscoped, unresolved and legacy metadata, with keyboard packet inspection", async () => { + const inspect = vi.fn(); + show(); expect(await screen.findByText("#yow")).toBeInTheDocument(); expect(screen.getByText("No transport scope")).toBeInTheDocument(); expect(screen.getByText("Unresolved scope")).toBeInTheDocument(); expect(screen.getByText("Scope unavailable")).toBeInTheDocument(); - fireEvent.click(screen.getAllByRole("button",{name:"Inspect packet from Fixture"})[0]!); + fireEvent.click(screen.getAllByRole("button", { name: "Inspect packet from Fixture" })[0]!); expect(inspect).toHaveBeenCalledTimes(1); }); - it("switches French labels without refetching or changing protocol names",async()=>{ - show(); + it("switches French labels without refetching or changing protocol names", async () => { + show(); await screen.findByText("#yow"); - await act(()=>i18n.changeLanguage("fr")); + await act(() => i18n.changeLanguage("fr")); expect(screen.getByText("Sans portée de transport")).toBeInTheDocument(); expect(screen.getByText("Portée non résolue")).toBeInTheDocument(); expect(screen.getByText("Portée indisponible")).toBeInTheDocument(); expect(screen.getByText("#yow")).toBeInTheDocument(); expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); }); - it("isolates scope history and live messages, preserving page cursors and channel identity",async()=>{ - vi.mocked(getChannelMessagesPage).mockImplementation(async(_id,p)=>page(p?.scope==="#yow"?[messages[0]!]:messages,p?.scope==="#yow"?4:null)); - const {client}=show({}}/>); - fireEvent.click(await screen.findByRole("button",{name:/#test/})); + it("isolates scope history and live messages, preserving page cursors and channel identity", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, p) => + page(p?.scope === "#yow" ? [messages[0]!] : messages, p?.scope === "#yow" ? 4 : null), + ); + const { client } = show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); await screen.findByText("No transport scope"); - fireEvent.click(screen.getByRole("button",{name:/Transport scope.*All scopes/})); - fireEvent.click(screen.getByRole("option",{name:"#yow",exact:true})); - await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1,expect.objectContaining({scope:"#yow"}))); - await waitFor(()=>expect(screen.queryByText("No transport scope")).not.toBeInTheDocument()); - await waitFor(()=>expect(client.getQueryData(["channel-messages",1,"YOW","#yow"])).toBeDefined()); - const event={...base,id:5,channelId:1,packetHash:"live-yow",content:"in scope",scope:"#yow",scopeStatus:"matched" as const}; - act(()=>{onMessage({...event,packetHash:"other",scope:"#can",content:"wrong scope"});onMessage({...event,channelId:2,packetHash:"collision",content:"wrong channel"});onMessage(event);onMessage(event);}); + fireEvent.click(screen.getByRole("button", { name: /Transport scope.*All scopes/ })); + fireEvent.click(screen.getByRole("option", { name: "#yow", exact: true })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow" })), + ); + await waitFor(() => expect(screen.queryByText("No transport scope")).not.toBeInTheDocument()); + await waitFor(() => expect(client.getQueryData(["channel-messages", 1, "YOW", "#yow"])).toBeDefined()); + const event = { + ...base, + id: 5, + channelId: 1, + packetHash: "live-yow", + content: "in scope", + scope: "#yow", + scopeStatus: "matched" as const, + }; + act(() => { + onMessage({ ...event, packetHash: "other", scope: "#can", content: "wrong scope" }); + onMessage({ ...event, channelId: 2, packetHash: "collision", content: "wrong channel" }); + onMessage(event); + onMessage(event); + }); expect(await screen.findByText("in scope")).toBeInTheDocument(); expect(screen.queryByText("wrong scope")).not.toBeInTheDocument(); expect(screen.queryByText("wrong channel")).not.toBeInTheDocument(); expect(screen.getAllByText("in scope")).toHaveLength(1); - const cache=client.getQueryData<{pages:ReturnType[];pageParams:unknown[]}>(["channel-messages",1,"YOW","#yow"])!; - expect(cache.pages[0]!.nextCursor).toBe(4);expect(cache.pageParams).toEqual([undefined]); - fireEvent.click(screen.getByRole("button",{name:"Load older messages"})); - await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1,expect.objectContaining({scope:"#yow",cursor:4}))); + const cache = client.getQueryData<{ pages: ReturnType[]; pageParams: unknown[] }>([ + "channel-messages", + 1, + "YOW", + "#yow", + ])!; + expect(cache.pages[0]!.nextCursor).toBe(4); + expect(cache.pageParams).toEqual([undefined]); + fireEvent.click(screen.getByRole("button", { name: "Load older messages" })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow", cursor: 4 })), + ); }); - it("explains an empty filtered history",async()=>{ + it("explains an empty filtered history", async () => { vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); - show({}}/>); + show( {}} />); expect(await screen.findByText("No retained messages match #yow.")).toBeInTheDocument(); }); - it("keeps messages received during history loading without an extra request",async()=>{ - let resolveHistory!:(value:ReturnType)=>void; - const live={...messages[0]!,id:5,channelId:1,packetHash:"during-read",content:"arrived during history"}; - vi.mocked(getChannelMessagesPage).mockImplementationOnce(()=>new Promise(resolve=>{resolveHistory=resolve;})).mockResolvedValue(page([live])); - show({}}/>); - fireEvent.click(await screen.findByRole("button",{name:/#test/})); - await waitFor(()=>expect(getChannelMessagesPage).toHaveBeenCalledTimes(1)); - act(()=>{onMessage(live);onMessage(live);}); - await act(async()=>resolveHistory(page([]))); + it("keeps messages received during history loading without an extra request", async () => { + let resolveHistory!: (value: ReturnType) => void; + const live = { ...messages[0]!, id: 5, channelId: 1, packetHash: "during-read", content: "arrived during history" }; + vi.mocked(getChannelMessagesPage) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + ) + .mockResolvedValue(page([live])); + show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); + await waitFor(() => expect(getChannelMessagesPage).toHaveBeenCalledTimes(1)); + act(() => { + onMessage(live); + onMessage(live); + }); + await act(async () => resolveHistory(page([]))); expect(await screen.findByText("arrived during history")).toBeInTheDocument(); expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); }); - it("explains missing decryption keys without inventing a message scope",async()=>{ + it("explains missing decryption keys without inventing a message scope", async () => { vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); - show(); - expect(await screen.findByText("No decrypted messages are available without a known channel key.")).toBeInTheDocument(); + show(); + expect( + await screen.findByText("No decrypted messages are available without a known channel key."), + ).toBeInTheDocument(); expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); }); }); + +it("preserves the newest/live page after more than twenty older pages", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { + const index = params?.cursor ?? 0; + return page( + [ + { + ...base, + id: 1000 - index, + packetHash: `history-${index}`, + content: `History ${index}`, + sentAt: 1000 - index, + }, + ], + index + 1, + ); + }); + show(); + await screen.findByText("History 0"); + for (let index = 1; index <= 21; index++) { + fireEvent.click(screen.getByRole("button", { name: /Load older/ })); + await screen.findByText(`History ${index}`); + } + expect(screen.getByText("History 0")).toBeInTheDocument(); +}); From 04d56bc3763744c582cfb625f2ef293171a9fc72 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 20:49:44 -0400 Subject: [PATCH 29/45] fix(packets): contain route labels and scope badges --- src/features/packets/PacketRow.tsx | 4 +++- src/features/packets/PacketTableRow.tsx | 11 ++++++----- src/features/packets/packet-grid.ts | 2 +- tests/features/packets/PacketTableRow.test.tsx | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 0ee9491..fbd2497 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -63,7 +63,9 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp {packet.scope && ( <> · - {packet.scope} + + {packet.scope} + )} · diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 95a430f..3cfa887 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -15,11 +15,12 @@ interface PacketTableRowProps { onToggle: () => void; } -// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the +// Fixed-height table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the // expansion instead, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { // ?? not ||, so a legitimate 0-hop direct packet still shows its count const pathLength = packet.latestObserver?.pathLength; + const routeName = packet.routeTypeName || "Unknown"; const na = n/a; return ( @@ -36,7 +37,7 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa type="button" onClick={onToggle} aria-expanded={expanded} - className="grid w-full items-center gap-x-3 px-3 py-1.5 text-left text-[11px] cursor-pointer" + className="grid h-9 w-full items-center gap-x-3 px-3 py-1 text-left text-[11px] cursor-pointer" style={{ gridTemplateColumns: GRID_TEMPLATE }} > @@ -50,9 +51,9 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa {PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName} - - {packet.routeTypeName || "Unknown"} - {packet.scope && {packet.scope}} + + {routeName} + {packet.scope && {packet.scope}} ×{packet.observationCount} {pathLength?.hopCount ?? na} diff --git a/src/features/packets/packet-grid.ts b/src/features/packets/packet-grid.ts index c077378..503a843 100644 --- a/src/features/packets/packet-grid.ts +++ b/src/features/packets/packet-grid.ts @@ -4,4 +4,4 @@ // ("HASH" vs "4AE77F09") — either silently drifts the two apart. // Endpoints take minmax(0,1fr) so they absorb every bit of squeeze and truncate, rather than // pushing IATA and Age off the edge on a narrow viewport. -export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem 4rem minmax(0,1fr) 3.5rem 5rem"; +export const GRID_TEMPLATE = "1.25rem 5rem 6rem 8rem 3rem 3rem 4rem minmax(0,1fr) 3.5rem 5rem"; diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index e537033..38e6476 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -43,7 +43,7 @@ describe("PacketTableRow", () => { expect(onToggle).toHaveBeenCalledOnce(); }); - it("is a single line, so the row height stays constant for the virtualizer", () => { + it("keeps packet content inside one row button", () => { const { container } = render( {}} />); expect(container.querySelectorAll("button")).toHaveLength(1); expect(screen.queryByText("latest")).not.toBeInTheDocument(); From fa64057c67fad158072ddf823b0edb0cbc4ad184 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:29:54 -0400 Subject: [PATCH 30/45] fix: address release review findings for #92 --- tests/features/packets/PacketTableRow.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 38e6476..079e5cd 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -124,3 +124,11 @@ describe("PacketTableRow", () => { expect(screen.getByText("Unknown")).toBeInTheDocument(); }); }); + +it("keeps long route labels inside their dedicated track beside a scope", () => { + render( {}} />); + const route = screen.getByText("TRANSPORT_FLOOD"); + expect(route).toHaveClass("truncate"); + expect(route.parentElement).toHaveClass("min-w-0"); + expect(screen.getByText("#ykf")).toBeInTheDocument(); +}); From 524ba5e7e1979f0aca972074a100f045933a4c50 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 19:00:45 -0400 Subject: [PATCH 31/45] fix(observers): describe partial live activity buckets --- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c95a379..5b7cea1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -275,7 +275,7 @@ "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.", + "windowHelp": "The latest bucket may be partial; 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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 71cb926..0895c29 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -279,7 +279,7 @@ "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.", + "windowHelp": "La dernière période peut être partielle ; 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", From d7b087a6a5d3bc22cb385462936fac82e7748069 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 19:15:46 -0400 Subject: [PATCH 32/45] fix(history): align visible periods with retained data --- src/features/observers/ObserverPage.tsx | 5 +++-- src/features/observers/observer-navigation.ts | 2 +- src/features/routes/RouteEvidencePanel.tsx | 9 +++++---- src/features/stats/CompareObserversTab.tsx | 8 ++++++-- src/features/stats/StatsOverview.tsx | 7 +++++-- src/features/stats/StatsSubHeader.tsx | 2 +- src/features/stats/types.ts | 3 ++- src/features/stats/useTelemetry.ts | 3 +++ src/i18n/locales/en.json | 10 +++++++--- src/i18n/locales/fr.json | 10 +++++++--- tests/App.observerInvestigation.test.tsx | 4 ++-- .../features/observers/ObserverPage.test.tsx | 5 +++-- .../observers/observer-navigation.test.ts | 4 ++-- .../routes/RouteEvidencePanel.test.tsx | 19 +++++++++++++++--- .../stats/CompareObserversTab.test.tsx | 15 ++++++++++++-- tests/features/stats/StatsSubHeader.test.tsx | 20 ++++++++++++++++--- .../stats/analytics-navigation.test.tsx | 13 ++++++++++-- tests/features/stats/useTelemetry.test.ts | 1 + 18 files changed, 105 insertions(+), 35 deletions(-) diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 84658ef..95d21e0 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -26,9 +26,10 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: 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 range = observerRange(params.get("range")); const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); + if (id) share.searchParams.set("range", range); 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), visitOptions); const compare = (observer: string) => { const clickedAt = Date.now(); @@ -60,7 +61,7 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: } diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index b31869b..39a7c7c 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -1,6 +1,6 @@ import type { StatsRange } from "../stats/types"; -export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; +export const observerRange = (value: string | null): StatsRange => value === "24h" ? "24h" : "3d"; export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index 6f81da2..5b270f0 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -21,14 +21,14 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on 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 range = ["3d", "7d", "30d"].includes(params.get("routeRange") ?? "") ? "3d" : "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 > openedAt || 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 > 3 * 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), + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "3d" ? "72h" : "24h", limit: 50 }, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (last, pages) => pages.length < 10 && last.hasMore ? last.nextPageCursor : undefined, enabled: !invalid, @@ -41,7 +41,8 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on 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 })} />}>
- setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> + setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> +

{t("observerCompare.retainedWindow")}

{fixed &&

{t("routeEvidence.shared")}

} {first &&

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

}
diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 7605afb..0f3a6b6 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { Card } from "./cards"; @@ -16,6 +17,7 @@ function validation(value: Selection): string | null { if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { return "Choose a valid start and a later end time."; } + if (value.until - value.since > 3 * 86_400_000) return "observerCompare.windowLimit"; return null; } @@ -61,6 +63,7 @@ function ObserverSelect({ label, value, onChange }: { label: string; value: stri } function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) { + const { t } = useTranslation(); const [a, setA] = useState(initial?.observerA ?? ""); const [b, setB] = useState(initial?.observerB ?? ""); const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); @@ -85,13 +88,14 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC setUntil(e.target.value)} className={fieldClass} />
- {error &&

{error}

} + {error &&

{t(error, { defaultValue: error })}

} ); } export function CompareObserversTab() { + const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const [params, setParams] = useSearchParams(); const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; @@ -137,7 +141,7 @@ export function CompareObserversTab() { return (
-

Compare distinct flood packets reported by two observers over any retained time period.

+

{t("observerCompare.retainedWindow")}

{supplied && !valid &&

This comparison link has invalid or missing values. Choose observers and dates below.

} params.get(key)).join("|")} initial={selection} onCompare={compare} />
diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index c2ebd88..a596cae 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; @@ -15,10 +16,10 @@ import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; -const RANGES: StatsRange[] = ["24h", "7d", "30d"]; +const RANGES: StatsRange[] = ["24h", "3d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); -const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); +const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "3d"); interface StatsOverviewProps { wsManager: WsManager; @@ -30,6 +31,7 @@ interface StatsOverviewProps { // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); @@ -61,6 +63,7 @@ export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewP
+ {["mesh", "traffic", "signal", "paths", "talkers"].includes(tab) &&

{t("stats.archiveHelp")}

} {tab === "mesh" && } {tab === "traffic" && } {tab === "signal" && } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 083bfe3..c95357a 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -90,7 +90,7 @@ const TAB_OPTIONS = [ { value: "graph", icon: }, ]; -const RANGES: StatsRange[] = ["24h", "7d", "30d"]; +const RANGES: StatsRange[] = ["24h", "3d", "30d"]; interface Props { tab: StatsTab; diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 7beff78..c5ac853 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -166,10 +166,11 @@ export interface ObserverActivity { // Sub-tab + time-range identifiers shared across the Stats page. export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; -export type StatsRange = "24h" | "7d" | "30d"; +export type StatsRange = "24h" | "3d" | "7d" | "30d"; export const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, + "3d": 3 * 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index a65e309..383ee1a 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -5,6 +5,7 @@ import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { "24h": "24h", + "3d": "72h", "7d": "168h", "30d": "720h", }; @@ -13,6 +14,7 @@ const RANGE_PARAM: Record = { // charts don't drown in points. const INTERVAL_PARAM: Record = { "24h": "1h", + "3d": "1h", "7d": "6h", "30d": "24h", }; @@ -31,6 +33,7 @@ export function useObserver(observerId: string | null, poll = false) { // Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. const ACTIVITY_INTERVAL: Record = { "24h": "15m", + "3d": "1h", "7d": "1h", "30d": "6h", }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b7cea1..0589adb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -66,8 +66,10 @@ "ranges": { "24h": "24h", "7d": "7d", - "30d": "30d" - } + "30d": "30d", + "3d": "3d" + }, + "archiveHelp": "Hourly analytics keep summaries for up to 30 days. Packet details and overlap use retained raw records; older summary history accumulates after archiving is enabled." }, "signal": { "title": "RF / Signal", @@ -312,7 +314,9 @@ "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." + "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.", + "windowLimit": "Choose a period of 3 days or less for retained packet records.", + "retainedWindow": "Packet details and overlap use retained raw records, with a maximum 3-day window." }, "investigation": { "title": "Reception evidence", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0895c29..47d158d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -67,8 +67,10 @@ "ranges": { "24h": "24 h", "7d": "7 j", - "30d": "30 j" - } + "30d": "30 j", + "3d": "3 j" + }, + "archiveHelp": "Les statistiques horaires conservent des résumés jusqu’à 30 jours. Les détails et les recoupements utilisent les paquets bruts conservés ; l’historique s’accumule après l’activation de l’archivage." }, "signal": { "title": "RF / Signal", @@ -316,7 +318,9 @@ "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." + "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.", + "windowLimit": "Choisissez une période de 3 jours ou moins pour les paquets conservés.", + "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés, sur une période maximale de 3 jours." }, "investigation": { "title": "Rapports de réception", diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index 9237d6e..ac0191d 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -81,10 +81,10 @@ describe("observer investigation return", () => { fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "roof" } }); screen.getByTestId("route-scroll").scrollTop = 180; click("Route observer"); click("Open dashboard"); - expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 3d"); expect(window.location.search).toContain("tab=Observers&observer=o1"); expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); - fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "30d" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); click("Compare with…"); expect(window.location.search).toContain("compareWith="); click("Back to Routes"); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index 93cc494..d53089e 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -65,7 +65,7 @@ describe("Observer destination", () => { 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(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 3d"); expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); expect(screen.getByRole("status").textContent).toContain("iata=YOW"); fireEvent.click(screen.getByText("Browser back")); @@ -76,7 +76,8 @@ describe("Observer destination", () => { }); 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"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 3d"); + expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "3d"]); 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 index 2c2ca87..691cb03 100644 --- a/tests/features/observers/observer-navigation.test.ts +++ b/tests/features/observers/observer-navigation.test.ts @@ -2,9 +2,9 @@ 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); + expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("3d");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"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=3d");expect(observerRange("invalid")).toBe("3d"); }); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx index a1c7b2d..511558a 100644 --- a/tests/features/routes/RouteEvidencePanel.test.tsx +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -26,6 +26,19 @@ function mount(url = "/?tab=Routes") { } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); describe("retained route evidence", () => { + it("normalizes an old month link to 72 hours and offers only retained periods", async () => { + mount("/?routeRange=30d"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything()); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7 days" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "30 days" })).not.toBeInTheDocument(); + }); + it("does not request a shared raw-evidence period longer than three days", async () => { + mount("/?routeSince=1700000000000&routeUntil=1700345600000"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); it("opens the exact report and observer, names the saved route, and pins cursor pages", async () => { mount(); await screen.findByText("Garden"); @@ -51,9 +64,9 @@ describe("retained route evidence", () => { 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"); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything())); + expect(screen.getByText(/routeRange=3d/)).not.toHaveTextContent("routeSince"); }); it("does not replace an invalid shared window with an unrelated default", async () => { mount("/?routeSince=bad"); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx index 4b5b434..c7e29aa 100644 --- a/tests/features/stats/CompareObserversTab.test.tsx +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -35,6 +35,17 @@ beforeEach(() => { }); describe("observer comparison", () => { + it("rejects a raw packet comparison beyond three days", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + expect(await screen.findByRole("alert")).toHaveTextContent("3 days or less"); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { mount(); await screen.findAllByRole("option", { name: /Rooftop/ }); @@ -42,11 +53,11 @@ describe("observer comparison", () => { fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); - fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-02-15T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-03T00:00" } }); expect(getObserverComparison).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Compare" })); await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], { - observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-02-15T00:00").getTime(), + observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-01-03T00:00").getTime(), }, expect.any(AbortSignal))); expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`); }); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index 2d0efc3..751e172 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -6,14 +6,28 @@ import i18n from "../../../src/i18n"; afterEach(() => vi.restoreAllMocks()); describe("translated analytics controls", () => { + it("limits observer periods while keeping archived analytics ranges", async () => { + const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; + const { rerender } = render(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "30d" })).not.toBeInTheDocument(); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("button", { name: "3 j" })); + expect(props.onRangeChange).toHaveBeenCalledWith("3d"); + rerender(); + expect(screen.queryByRole("button", { name: "7 j" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); + }); + it("keeps section and range identifiers when labels change", async () => { const onTabChange = vi.fn(), onRangeChange = vi.fn(); - render(); - expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + render(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); await act(() => i18n.changeLanguage("fr")); expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); const range = screen.getByRole("group", { name: "Période" }); - expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); + expect(within(range).getByRole("button", { name: "3 j" })).toHaveAttribute("aria-pressed", "true"); expect(onTabChange).not.toHaveBeenCalled(); expect(onRangeChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 2a523c2..667de69 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -11,6 +11,15 @@ vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh c vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } +it("normalizes old seven-day links and explains retained summary history", () => { + render(); + expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.getByText(/Hourly analytics keep summaries for up to 30 days/)).toBeInTheDocument(); +}); + it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { render(); expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); @@ -24,8 +33,8 @@ it("opens Signal from a shared URL and retains regional state when changing rang render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "7d" })); - expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); }); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts index 9dd576f..d1c7016 100644 --- a/tests/features/stats/useTelemetry.test.ts +++ b/tests/features/stats/useTelemetry.test.ts @@ -5,6 +5,7 @@ import { ApiError } from "../../../src/api/client"; describe("activityParamsFor", () => { it("pairs each stats range with the bucket the charts are sized for", () => { expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); + expect(activityParamsFor("3d")).toEqual({ range: "72h", interval: "1h" }); expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); }); From 70029238b751a1360dc477add61ca4005a6d1772 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:43:10 -0400 Subject: [PATCH 33/45] test(history): reconcile observer fixtures with the unified destination --- tests/features/observers/ObserverComparison.test.tsx | 6 +++--- tests/features/stats/StatsSubHeader.test.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx index 72b6444..119445d 100644 --- a/tests/features/observers/ObserverComparison.test.tsx +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -11,7 +11,7 @@ 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 } }; +const activity: ObserverActivity = { range: "72h", interval: "1h", windowStart: until - 259_200_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 => ({ @@ -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(() => anchor ?? until) }; + const props = { observerA: observer, activityA: data, range: "3d" 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 }; } @@ -35,7 +35,7 @@ it("aligns activity and retained overlap to the primary observer's effective win view(); expect(await screen.findByText("23")).toBeInTheDocument(); expect(screen.getByText("19")).toBeInTheDocument(); - expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); + expect(getObserverActivity).toHaveBeenCalledWith(B, "72h", "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(); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index 751e172..52e3d8b 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -6,12 +6,13 @@ import i18n from "../../../src/i18n"; afterEach(() => vi.restoreAllMocks()); describe("translated analytics controls", () => { - it("limits observer periods while keeping archived analytics ranges", async () => { + it("keeps archived analytics ranges without the legacy Observer destination", async () => { const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; - const { rerender } = render(); + const { rerender } = render(); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "30d" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument(); await act(() => i18n.changeLanguage("fr")); fireEvent.click(screen.getByRole("button", { name: "3 j" })); expect(props.onRangeChange).toHaveBeenCalledWith("3d"); From 041a000d9c23c325a65290907fadaa017623132b Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 06:30:36 -0400 Subject: [PATCH 34/45] docs(observers): identify the click-time callback for purity checks --- src/features/observers/ObserverPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 95d21e0..31e8ed3 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -32,6 +32,7 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); const select = (observer: string | null) => setParams(observerDestination(params, observer, range), visitOptions); const compare = (observer: string) => { + // eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback. const clickedAt = Date.now(); setActionTime(clickedAt); setParams(old => { From c115c083a3fda05425e61a1819926d5576c3a6c7 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 21:36:43 -0400 Subject: [PATCH 35/45] fix(ui): simplify monitoring and keep observers alongside charts --- src/App.tsx | 2 +- src/features/channels/MessagePanel.tsx | 1 - src/features/observers/ObserverPage.tsx | 15 +++--- src/features/observers/ObserverSummary.tsx | 8 ++-- src/features/observers/ObserverTable.tsx | 19 ++++---- src/features/stats/MeshTab.tsx | 11 +++-- src/features/stats/ObserverTab.tsx | 48 +------------------ src/features/stats/StatsOverview.tsx | 3 -- src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- src/lib/constants.ts | 2 +- .../ObserverSummary.compact.test.tsx | 21 ++++++++ tests/features/stats/MeshTab.test.tsx | 11 ++++- tests/features/stats/ObserverTab.test.tsx | 13 ++++- tests/lib/constants.test.ts | 4 +- 15 files changed, 81 insertions(+), 83 deletions(-) create mode 100644 tests/features/observers/ObserverSummary.compact.test.tsx diff --git a/src/App.tsx b/src/App.tsx index d0a5294..8c98e73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -161,7 +161,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; - const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); + const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index 89bc198..2cf8faa 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -202,7 +202,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
{onScopeChange && ({ value: name, label: name }))} align="left" />} -
{t("channelMessages.aboutScopes")}

{t("channelMessages.scopeHelp")}

{isError &&
{t("channelMessages.error")}
} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 31e8ed3..9b85cd7 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -18,14 +18,12 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: const { t } = useTranslation(); const now = useTick(60_000); const id = params.get("observer"); - const [directoryVisited, setDirectoryVisited] = useState(!id); const [actionTime, setActionTime] = useState(() => Date.now()); const hourAt = (time: number) => Math.floor(time / 3_600_000) * 3_600_000; const comparing = params.has("compareWith"); const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : hourAt(actionTime); const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= Math.max(now, actionTime); const until = validAnchor ? anchor : null; - if (!id && !directoryVisited) setDirectoryVisited(true); const range = observerRange(params.get("range")); const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); if (id) share.searchParams.set("range", range); @@ -57,7 +55,7 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: {(id || onReturn) &&
{onReturn && } {id && <> - + }
} - {/* Keep the directory mounted so Back restores filters, sorting and scroll. */} - {directoryVisited &&
- -
} - {id &&
+
+ + {id &&
{t("common.loading")}

}>
} +
; } diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index fb4ee1b..d5513dc 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -10,9 +10,6 @@ export function ObserverSummary({ observer, activity, points, pending = false }: const { t, i18n } = useTranslation(); const now = useTick(); const summary = activity?.summary; const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; - const lastArrival = Math.max(summary?.latestRecordedAt ?? 0, ...observer.brokers.map(b => b.lastPacketAt || 0)); - const trafficKnown = !!summary && lastArrival > 0; - const trafficFresh = trafficKnown && now - lastArrival < 300_000; const stats = observer.statusMetadata?.stats; const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; @@ -28,8 +25,9 @@ export function ObserverSummary({ observer, activity, points, pending = false }:

{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}
diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 21eab8e..8247d76 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getObserversPage, getBrokers } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -23,6 +24,7 @@ import type { WsObserverStatus } from "../../types/ws"; const observerId = (o: ObserverSummary) => o.id; // stable id accessor for the paged hook's dedup interface ObserverTableProps { + compact?: boolean; wsManager: WsManager; selectedObserverId: string | null; onSelectObserver: (id: string | null) => void; @@ -95,7 +97,8 @@ function renderObserverCard(obs: ObserverSummary) { ); } -export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) { +export function ObserverTable({ compact = false, wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) { + const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); @@ -164,11 +167,11 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, patchInfinitePages(old, (items) => patchObserverSummary(items, data) ?? items), ); // refresh detail panel if it's showing this observer - if (selectedObserverId === data.observerId) { + if (!compact && selectedObserverId === data.observerId) { queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] }); } }, - [queryClient, queryKey, selectedObserverId], + [queryClient, queryKey, selectedObserverId, compact], ); useWsObserverStatusHandler(wsManager, handleObserverStatus); @@ -176,7 +179,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, return (
- setSearch(event.target.value)} className="m-2 min-h-11 min-w-0 rounded border border-border bg-bg-base px-3 text-sm" /> : + />} o.id} selectedKey={selectedObserverId} - onSelect={onSelectObserver} + onSelect={id => { if (!compact || id !== null) onSelectObserver(id); }} isLoading={isLoading} emptyLabel="No observers" defaultSort={{ header: "Name" }} @@ -208,7 +211,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver,
- {selectedObserverId && ( + {selectedObserverId && !compact && ( onSelectObserver(null)} diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 90b248d..7a0cc23 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,9 +1,10 @@ +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; -import { Card, ChartCard, StatCard } from "./cards"; +import { ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; import type { WsManager } from "../../api/ws-manager"; @@ -35,6 +36,7 @@ interface MeshTabProps { } export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); useLiveOverview(wsManager); const overview = useStatsOverview(); @@ -158,7 +160,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { - Scopes · selected region · retained data}> +
+ {t("stats.tabs.scopes")} +
{scopes.isError ? (
Failed to load
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? ( @@ -187,7 +191,8 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { )} - +
+
); diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index ad2d1a7..c101d38 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; import { formatRadioParts } from "../../lib/formatters"; -import { ObserverPicker } from "../observers/ObserverPicker"; import { ObserverComparison } from "../observers/ObserverComparison"; import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; @@ -35,8 +34,8 @@ interface ObserverTabProps { wsManager: WsManager; 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(); +export function ObserverTab({ range, selectedObserverId, wsManager, comparison }: ObserverTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); const observer = useObserver(selectedObserverId, true); @@ -136,8 +135,6 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan {t("observerPage.recording")}

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

{t("observerPage.scopeNote")}

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

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

- )} -

{t("observerPage.windowHelp")}

-
{comparison && { const nextUntil = comparison.onRefresh(); void observer.refetch(); @@ -207,33 +190,6 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan {t("observerPage.retry")} )} - {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")}

diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index a596cae..7e4d198 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,6 +1,5 @@ import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; @@ -31,7 +30,6 @@ interface StatsOverviewProps { // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); - const { t } = useTranslation(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); @@ -63,7 +61,6 @@ export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewP
- {["mesh", "traffic", "signal", "paths", "talkers"].includes(tab) &&

{t("stats.archiveHelp")}

} {tab === "mesh" && } {tab === "traffic" && } {tab === "signal" && } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0589adb..999ec3f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -290,7 +290,8 @@ "day": "day", "trafficUnknown": "Packet freshness unavailable", "returnTo": "Back to {{page}}", - "directory": "All observers" + "directory": "All observers", + "sidebarSearch": "Search observers" }, "observerCompare": { "open": "Compare with…", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 47d158d..ec00f6d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -294,7 +294,8 @@ "day": "jour", "trafficUnknown": "Fraîcheur des paquets inconnue", "returnTo": "Retour à « {{page}} »", - "directory": "Tous les observateurs" + "directory": "Tous les observateurs", + "sidebarSearch": "Rechercher les observateurs" }, "observerCompare": { "open": "Comparer avec…", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9783749..753b324 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["Observers", "Packets", "Channels", "Map", "Nodes", "Routes", "Traces", "Analytics"] as const; // Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser; // the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx new file mode 100644 index 0000000..189f95f --- /dev/null +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ObserverSummary } from "../../../src/features/observers/ObserverSummary"; +import type { Observer } from "../../../src/features/observers/types"; + +const observer = { id: "observer", displayName: "Roof", iata: "YKF", brokers: [], lastStatusAt: Date.now() - 600_000 } as unknown as Observer; + +describe("compact observer header", () => { + it("uses an accessible stale icon and omits the traffic text badge", () => { + render(); + expect(screen.getByRole("img", { name: "Status is stale" })).toHaveClass("text-warn"); + expect(screen.queryByText("Status is stale")).not.toBeInTheDocument(); + expect(screen.queryByText(/No recent recorded packets|Packet freshness unavailable/)).not.toBeInTheDocument(); + }); + it("distinguishes recent and absent status without inventing telemetry", () => { + const { rerender } = render(); + expect(screen.getByRole("img", { name: "Recent status" })).toHaveClass("text-green"); + rerender(); + expect(screen.getByRole("img", { name: "No status report" })).toHaveClass("text-text-muted"); + }); +}); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 99ba4b5..3fc0daf 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -29,7 +29,7 @@ beforeEach(() => { }); afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); -function card(title: string) { return screen.getByText(title).parentElement!.parentElement!; } +function card(title: string) { return title === "Scopes · selected region · retained data" ? screen.getByText("Scopes", { exact: true }).closest("details")! : screen.getByText(title).parentElement!.parentElement!; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); @@ -154,3 +154,12 @@ it("distinguishes initial loading from successful empty results and zero totals" expect(screen.getAllByText("No data")).toHaveLength(7); expect(within(card("Total packets")).getByText("0")).toBeInTheDocument(); }); + +it("keeps the scope list collapsed until requested", async () => { + mount(); await loaded(); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(scopeList).not.toHaveAttribute("open"); + fireEvent.click(screen.getByText("Scopes", { exact: true })); + expect(scopeList).toHaveAttribute("open"); + expect(within(scopeList).getByText("#old")).toBeVisible(); +}); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index 2976015..7006f09 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -206,7 +206,7 @@ describe("Observer dashboard hierarchy", () => { }); }); -it("distinguishes packet arrivals from last deduplicated record and status freshness", () => { +it("keeps packet metrics without the removed traffic text badge", () => { const now = Date.now(); observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; activityResult.data = { @@ -220,5 +220,14 @@ it("distinguishes packet arrivals from last deduplicated record and status fresh }, }; renderTab(); - expect(screen.getByText("Recent packet traffic")).toBeInTheDocument(); + expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); + expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); +}); + +it("omits the duplicate observer picker and marked explanatory sections", () => { + renderTab(); + expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); + expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); + expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); }); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 328fdd6..25c9f57 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); }); From 905753d44b2692e5f850f95c7ecd5f4aaeed612b Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 22:03:15 -0400 Subject: [PATCH 36/45] fix(ui): coalesce sidebar search and refresh imported borders --- src/components/SearchBar.tsx | 13 +++++---- src/features/map/useMapBordersData.ts | 9 +++--- src/features/observers/ObserverTable.tsx | 7 +++-- tests/components/SearchBar.compact.test.tsx | 20 +++++++++++++ .../map/useMapBordersData.refresh.test.tsx | 29 +++++++++++++++++++ .../stats/analytics-navigation.test.tsx | 4 +-- 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 tests/components/SearchBar.compact.test.tsx create mode 100644 tests/features/map/useMapBordersData.refresh.test.tsx diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index ceaeef7..9190fdd 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -13,11 +13,13 @@ interface SearchBarProps { fields: SearchFieldOption[]; field: string; onFieldChange: (field: string) => void; + hideField?: boolean; + inputLabel?: string; } // debounced search input with field dropdown -export function SearchBar({ value, onChange, fields, field, onFieldChange }: SearchBarProps) { +export function SearchBar({ value, onChange, fields, field, onFieldChange, hideField = false, inputLabel }: SearchBarProps) { const [localValue, setLocalValue] = useState(value); const [prevValue, setPrevValue] = useState(value); const debounceRef = useRef | null>(null); @@ -56,7 +58,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea return (
- ( @@ -92,7 +94,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea {f.label} ))} - + }
handleChange(e.target.value)} - placeholder={`Search by ${(currentField?.label ?? "").toLowerCase()}...`} - className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded-r-sm pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors" + placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`} + className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "min-h-11 rounded-sm text-base md:text-sm" : "rounded-r-sm text-[11px]"}`} /> {localValue && (
- {compact ? setSearch(event.target.value)} className="m-2 min-h-11 min-w-0 rounded border border-border bg-bg-base px-3 text-sm" /> :
: } o.id} selectedKey={selectedObserverId} diff --git a/tests/components/SearchBar.compact.test.tsx b/tests/components/SearchBar.compact.test.tsx new file mode 100644 index 0000000..2d56d21 --- /dev/null +++ b/tests/components/SearchBar.compact.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { SearchBar } from "../../src/components/SearchBar"; + +describe("compact observer search", () => { + it("omits the field menu and coalesces typing before querying", () => { + vi.useFakeTimers(); + try { + const change = vi.fn(); + render( {}} hideField inputLabel="Search observers" />); + const input = screen.getByRole("textbox", { name: "Search observers" }); + fireEvent.change(input, { target: { value: "Ro" } }); + fireEvent.change(input, { target: { value: "Roof" } }); + expect(change).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(300)); + expect(change).toHaveBeenCalledExactlyOnceWith("Roof"); + expect(screen.queryByRole("button", { name: /Name/ })).not.toBeInTheDocument(); + } finally { vi.useRealTimers(); } + }); +}); diff --git a/tests/features/map/useMapBordersData.refresh.test.tsx b/tests/features/map/useMapBordersData.refresh.test.tsx new file mode 100644 index 0000000..5add918 --- /dev/null +++ b/tests/features/map/useMapBordersData.refresh.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useMapBordersData } from "../../../src/features/map/useMapBordersData"; +import type { IataBorder } from "../../../src/api/client"; + +const state = vi.hoisted(() => ({ data: null as IataBorder | null, updated: 0 })); +vi.mock("@tanstack/react-query", () => ({ useQueries: () => [{ data: state.data, dataUpdatedAt: state.updated }] })); + +const border = (lng: number): IataBorder => ({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[lng, 0], [lng + 1, 0], [lng + 1, 1], [lng, 0]]] } }); + +describe("border refresh", () => { + it("replaces an already-loaded polygon when its query refreshes", () => { + state.data = border(0); state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.data = border(5); state.updated = 2; + rerender(); + expect(result.current).not.toBe(previous); + expect(result.current.features[0]?.geometry).toEqual(border(5).geometry); + }); + it("adds a configured border after an earlier empty response", () => { + state.data = null; state.updated = 3; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + expect(result.current.features).toHaveLength(0); + state.data = border(10); state.updated = 4; + rerender(); + expect(result.current.features).toHaveLength(1); + }); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 667de69..4b65d4d 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -11,13 +11,13 @@ vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh c vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } -it("normalizes old seven-day links and explains retained summary history", () => { +it("normalizes old seven-day links without the removed retention banner", () => { render(); expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); - expect(screen.getByText(/Hourly analytics keep summaries for up to 30 days/)).toBeInTheDocument(); + expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); }); it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { From da552cbb8e334e54aa43644f0bdddfcdc34cd0be Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 22:08:03 -0400 Subject: [PATCH 37/45] fix: type the border cache freshness callback --- src/features/map/useMapBordersData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts index aa846c6..34132f3 100644 --- a/src/features/map/useMapBordersData.ts +++ b/src/features/map/useMapBordersData.ts @@ -25,7 +25,7 @@ export function useMapBordersData(iataCodes: string[], enabled: boolean): Border queryKey: ["iata-border", iata], queryFn: () => getIataBorder(iata), enabled, - staleTime: (query) => query.state.data ? 3_600_000 : 60_000, + staleTime: (query: { state: { data: unknown } }) => query.state.data ? 3_600_000 : 60_000, })), }); From 9676bf25123f17c33e27e4dbbdb03bc372b497f3 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 22:17:46 -0400 Subject: [PATCH 38/45] chore: prepare web version 1.3.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed18e9a..4576296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "1.3.1", + "version": "1.3.2", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 284061c..99d8eb9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "1.3.1", + "version": "1.3.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { From 3513f17c6f538bff794e8d795aaff1e18b5c7ea4 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 04:55:20 -0400 Subject: [PATCH 39/45] fix(observers): contain the compact sidebar width --- src/features/observers/ObserverTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 0d1b995..9c1c20c 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -180,7 +180,7 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, useWsObserverStatusHandler(wsManager, handleObserverStatus); return ( -
+
{compact ?
: Date: Wed, 30 Sep 2026 05:11:49 -0400 Subject: [PATCH 40/45] fix(observers): make sidebar selections keyboard accessible --- src/features/observers/ObserverTable.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 9c1c20c..1ed8369 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -74,7 +74,11 @@ const COLUMNS: Column[] = [ }, ]; -const COMPACT_COLUMNS = [COLUMNS[0]!]; +const COMPACT_COLUMNS: Column[] = [{ + ...COLUMNS[0]!, + className: "max-w-0", + cell: (obs) => , +}]; function renderObserverCard(obs: ObserverSummary) { const status = deriveObserverStatus(obs); From c952fbf06f1ae8861acaad43e8f08ee35471f8f4 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:24:13 -0400 Subject: [PATCH 41/45] perf(observers): skip unused sidebar broker options --- src/features/observers/ObserverTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 1ed8369..b298424 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -120,6 +120,7 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, + enabled: !compact, staleTime: 60_000, }); From eb241ca78b320364aa0eaf1c35e41f45a2362023 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:53:45 -0400 Subject: [PATCH 42/45] perf(map): load the path canvas only when requested --- src/features/map/PacketPathMap.tsx | 1 + src/features/map/PacketPathMapModal.tsx | 9 ++++++--- tests/features/map/PacketPathMapModal.test.tsx | 18 +++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index ef41217..2c02b68 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -1,6 +1,7 @@ // src/features/map/PacketPathMap.tsx import { useEffect, useRef, useState } from "react"; import { Map as MapLibreMap, NavigationControl, AttributionControl, Popup, LngLatBounds } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; import "./maplibre-worker"; import type { GeoJSONSource, diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index 3df24af..a00dec8 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { PacketDetail } from "../../types/api"; import { ModalOverlay } from "../../components/ModalOverlay"; @@ -6,9 +6,10 @@ import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import { formatPropagation } from "../../lib/formatters"; import { buildPacketPaths } from "./packet-path"; -import { PacketPathMap } from "./PacketPathMap"; import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; +const PacketPathMap = lazy(() => import("./PacketPathMap").then(module => ({ default: module.PacketPathMap }))); + // Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route; // clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it // returns the user exactly where they were. @@ -64,7 +65,9 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey, inacti {paths.length === 0 && !unavailable &&

{t("investigation.unmappable")}

}
- + {t("common.loading")}

}> + +
diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index a443f11..fe49f0d 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -34,18 +34,18 @@ const detail = { } as unknown as PacketDetail; describe("PacketPathMapModal", () => { - it("lists All paths plus a row per observer and starts on All", () => { + it("lists All paths plus a row per observer and starts on All", async () => { render( {}} />); expect(screen.getByText("All paths")).toBeInTheDocument(); expect(screen.getByText("Alpha")).toBeInTheDocument(); expect(screen.getByText("Bravo")).toBeInTheDocument(); - expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); }); - it("isolates a path when its row is clicked", () => { + it("isolates a path when its row is clicked", async () => { render( {}} />); fireEvent.click(screen.getByText("Bravo")); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); it("shows each observer's propagation", () => { @@ -61,17 +61,17 @@ describe("PacketPathMapModal", () => { expect(onClose).toHaveBeenCalledOnce(); }); - it("pre-selects the observer from initialSelectedKey", () => { + it("pre-selects the observer from initialSelectedKey", async () => { render( {}} initialSelectedKey="obs-bravo" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); - it("does not silently substitute all paths for an unavailable selected path", () => { + it("does not silently substitute all paths for an unavailable selected path", async () => { render( {}} initialSelectedKey="nope" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("nope"); + expect(await screen.findByTestId("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(await screen.findByTestId("mini-map")).toHaveTextContent("all"); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); From 42c1c08e9dce9ca7a61b9c49163356647f5eb94a Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 29 Sep 2026 20:35:48 -0400 Subject: [PATCH 43/45] feat: add My Atlas saved node monitoring cards --- .env.example | 2 +- README.md | 4 + docs/my-atlas.md | 62 ++++++++ src/App.tsx | 2 + src/api/client.ts | 14 +- src/components/BottomNav.tsx | 2 +- src/features/atlas/AtlasCard.tsx | 113 +++++++++++++++ src/features/atlas/MyAtlasPage.tsx | 72 ++++++++++ src/features/atlas/atlas.ts | 66 +++++++++ src/features/atlas/useSavedAtlas.ts | 29 ++++ src/features/nodes/types.ts | 1 + src/i18n/locales/en.json | 76 +++++++++- src/i18n/locales/fr.json | 76 +++++++++- src/lib/constants.ts | 2 +- tests/components/BottomNav.test.tsx | 8 ++ tests/features/atlas/MyAtlasPage.test.tsx | 164 ++++++++++++++++++++++ tests/features/atlas/atlas.test.ts | 39 +++++ tests/lib/constants.test.ts | 4 +- 18 files changed, 723 insertions(+), 13 deletions(-) create mode 100644 docs/my-atlas.md create mode 100644 src/features/atlas/AtlasCard.tsx create mode 100644 src/features/atlas/MyAtlasPage.tsx create mode 100644 src/features/atlas/atlas.ts create mode 100644 src/features/atlas/useSavedAtlas.ts create mode 100644 tests/features/atlas/MyAtlasPage.test.tsx create mode 100644 tests/features/atlas/atlas.test.ts diff --git a/.env.example b/.env.example index 91b7213..ea3747c 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # VITE_MAP_ZOOM=3.2 # Hide tabs completely (comma list, case-insensitive). Unset = show all. -# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# Options: Packets,MyAtlas,Channels,Map,Nodes,Observers,Routes,Traces,Analytics # VITE_DISABLED_TABS=Map,Traces # Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none. diff --git a/README.md b/README.md index 64e3957..55bdf04 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Real-time LoRa mesh packet analyzer. Desktop-first, dark-mode-primary, dense inf Built with React 19, TypeScript, Tailwind CSS 4, TanStack Query, and TanStack Virtual. +[My Atlas](docs/my-atlas.md) provides saved node cards with compact reception activity +bars, received-signal meters, and expandable observer evidence. Selections persist +in the visitor's browser; English and French are supported. + ## Deployment ### 1. Copy the `docker/` folder to your server diff --git a/docs/my-atlas.md b/docs/my-atlas.md new file mode 100644 index 0000000..e3b6985 --- /dev/null +++ b/docs/my-atlas.md @@ -0,0 +1,62 @@ +# My Atlas + +Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The +canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does +not publish someone else's saved nodes. + +Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve +nodes. **Manage card** moves or removes a card. The selected nodes, their order +and the 24-hour/3-day activity window are stored in this browser on this origin +under `beacon-my-atlas-v1`. There is no account or cross-device synchronization. +Clearing site data removes the collection. When browser storage is unavailable, +the page explains that edits may only last for the current visit. + +Cards use the current full node identity, not an ambiguous short path prefix. +Names refresh from the node record. If an old server ID disappears or points at +a different key, the page searches for the exact full key before loading reports. +An unavailable node stays in the collection and remains removable. + +## Reading the cards + +- **Received reports** are stored receptions of packets originating from the exact + node key, after server deduplication. The page loads the latest 200 reports per + node, then filters that sample to the selected rolling window. Counts are not + a total of all node traffic. The card flags when more reports are available. +- Each bar covers one hour of the rolling window. Blank bars mean no reports in + the loaded sample; they do not prove silence or packet loss. **Statistics & + chart values** exposes the numeric hourly values and sample definition. +- **Mean SNR / RSSI** average finite, available measurements from that sample. + The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR + with a real RSSI is included. These are last-hop measurements at the observers, + not a measurement of the origin's radio health. Meters include numbers and + accessible values; missing measurements display a dash. +- Freshness uses the server's `stale` verdict. It does not infer online/offline + status from the report count or invent a local threshold. +- **Heard by** lazily opens the reporting observers for the latest loaded packet + in the selected window. It is not lifetime reach or all observers of the node. + Repeated reports from the same observer are grouped. Observer buttons open + inspection; the packet action opens the exact packet and observed paths. +- **Open node** reuses Beacon's node inspection. Statistics also show existing + neighbour count, radio settings, default scope and full public key. + +## Cost and compatibility + +The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency. +No query runs for an empty collection before a valid search. Search is debounced, +limited to eight results and rejects invalid key input before issuing a request. +Each saved card normally makes two requests (node plus 200-report page) per +minute while the page is active and the document is visible. Error refreshes stop +until Retry or a later visit. A changed server ID can require two extra identity +requests. A single expanded Heard by panel uses the existing packet-detail cache. +Requests consume TanStack Query's abort signal when the page leaves. + +No server schema, retention settings, MQTT behavior or authentication changes are +needed. Existing raw-retention limits still apply. Longer-lived analytics remain +separate. English and French labels ship together. Deployers can hide the page +with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration. + +The focused tests cover persistence, corrupt/blocked storage, cross-tab changes, +exact-key recovery, rename display, bounded/lazy loading, missing signals, +expired evidence, range selection, order/removal and both languages. Release QA +also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, +and the matching public source offer. diff --git a/src/App.tsx b/src/App.tsx index 8c98e73..cae83b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ defau // Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. const StatsOverview = lazy(() => import("./features/stats/StatsOverview").then((m) => ({ default: m.StatsOverview }))); +const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then((m) => ({ default: m.MyAtlasPage }))); // global singletons @@ -287,6 +288,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: }, []); const tabContent: Record = { + MyAtlas: , Packets: ( { - return request(`/packets/${packetHash}`); +export function getPacketDetail(packetHash: string, signal?: AbortSignal): Promise { + return request(`/packets/${packetHash}`, undefined, signal); } export function getIatas(): Promise { @@ -266,6 +266,7 @@ export function getNodesPage( supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, + signal?: AbortSignal, ): Promise> { return request("/nodes", { iatas: iatasParam(iatas), @@ -277,7 +278,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } // Paginated /observers, mirroring getNodesPage; used by the Observers table. @@ -296,18 +297,19 @@ export function getObserversPage( }); } -export function getNode(nodeId: string): Promise { - return request(`/nodes/${nodeId}`); +export function getNode(nodeId: string, signal?: AbortSignal): Promise { + return request(`/nodes/${nodeId}`, undefined, signal); } export function getNodeObservations( nodeId: string, params?: { cursor?: number; limit?: number }, + signal?: AbortSignal, ): Promise> { return request(`/nodes/${nodeId}/observations`, { cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, - }); + }, signal); } export function getNodeNeighbors(nodeId: string): Promise { diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index ab2896a..5c9fc43 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -5,7 +5,7 @@ import { ENABLED_TABS } from "../lib/constants"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; -const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const; +const OVERFLOW_TABS = ["MyAtlas", "Observers", "Routes", "Traces", "Analytics"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx new file mode 100644 index 0000000..1ddd095 --- /dev/null +++ b/src/features/atlas/AtlasCard.tsx @@ -0,0 +1,113 @@ +import { useId } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getPacketDetail } from "../../api/client"; +import { Timestamp } from "../../components/Timestamp"; +import { useTick } from "../../hooks/useTick"; +import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas"; + +export interface AtlasActions { + onViewNode: (id: string) => void; + onViewObserver: (id: string) => void; + onAnalyzePacket: (hash: string, observationId?: number) => void; +} + +function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) { + const { t, i18n } = useTranslation(); + const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; + const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min)))); + return
+
{label}{value === null ? "—" : text}
+
+ {Array.from({ length: 16 }, (_, index) =>
+
; +} + +export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & { + pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void; + onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean; +}) { + const { t, i18n } = useTranslation(); + const now = useTick(60_000); + const id = useId(); + const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false, + refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false }); + const node = query.data?.node; + const sample = summarizeReports(query.data?.page.items ?? [], range, now); + const latest = sample.reports[0]; + const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false }); + const detail = heard.data; + const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; + const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : []; + const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase(); + const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); + const maximum = Math.max(1, ...sample.bins); + const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
+
+

+ {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} +
+
+
+
+ {query.isPending ?

{t("common.loading")}

: query.isError &&

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

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

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

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

{t("atlas.noReports")}

} +
+ + +
+ {heardOpen &&
+

{t("atlas.latestAdvert")}

+ {!latest ?

{t("atlas.noReports")}

: heard.isPending ?

{t("common.loading")}

: heard.isError || !correctOrigin ?

{t("atlas.heardUnavailable")}

: <> +

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

+
    {observers.map(row =>
  • {row.iata}
  • )}
+ + } +
} +
+ {t("atlas.statistics")} +
+

{t("atlas.definition")}

+

{t("atlas.signalDefinition")}

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

{t("atlas.capped")}

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

{node.publicKey}

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

{t("atlas.updated")}

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

{t("tabs.MyAtlas")}

{t("atlas.subtitle")}

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

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

+ {ready &&
+ {search.isPending ?

{t("common.loading")}

: search.isError ?

{t("common.loadFailed")}

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

{t("atlas.noMatches")}

} +
    {search.data?.items.map(node => { + const selected = saved.nodes.some(pin => pin.publicKey === node.publicKey.toLowerCase()); + const name = node.name || node.publicKey.slice(0, 12).toUpperCase(); + return
  • +

    {name}

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

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

{t("atlas.refine")}

} + } +
} +

{t("atlas.storageHelp")}

+
+

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

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+ {saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { + const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; + if (from < 0 || to < 0 || to >= nodes.length) return value; + [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + return { ...value, nodes }; + })} {...actions} />)} +
} +
+
; +} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts new file mode 100644 index 0000000..7662b65 --- /dev/null +++ b/src/features/atlas/atlas.ts @@ -0,0 +1,66 @@ +import { getNode, getNodeObservations, getNodesPage } from "../../api/client"; +import type { Node, NodeObservation } from "../nodes/types"; + +export const ATLAS_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; +export const REPORT_LIMIT = 200; +export type AtlasRange = "24h" | "3d"; +export interface AtlasPin { id: string; publicKey: string; name: string } +export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } + +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; + const nodes: AtlasPin[] = []; + for (const pin of value.nodes) { + if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) || + typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; + const publicKey = pin.publicKey.toLowerCase(); + if (nodes.some(node => node.publicKey === publicKey)) continue; + nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); + if (nodes.length === ATLAS_LIMIT) break; + } + return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; + } catch { return empty; } +} + +// Resolve by full identity after a server database reset; never adopt a different node at an old ID. +export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { + let node: Node | undefined; + try { node = await getNode(pin.id, signal); } + catch (error) { if (!(error instanceof Error && "status" in error && error.status === 404)) throw error; } + if (node?.publicKey.toLowerCase() !== pin.publicKey) { + const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); + const match = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey); + if (!match) throw new Error("Atlas identity unavailable"); + node = await getNode(match.id, signal); + } + if (node.publicKey.toLowerCase() !== pin.publicKey) throw new Error("Atlas identity changed"); + const page = await getNodeObservations(node.id, { limit: REPORT_LIMIT }, signal); + return { node, page }; +} + +export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number) { + const hours = range === "3d" ? 72 : 24; + const since = now - hours * 3_600_000; + const seen = new Set(); + const reports = rows.filter(row => { + if (seen.has(row.id) || row.heardAt <= since || row.heardAt > now) return false; + seen.add(row.id); + return Number.isFinite(row.heardAt); + }).sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); + const bins = Array(hours).fill(0); + for (const row of reports) { + const index = Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)); + bins[index] = (bins[index] ?? 0) + 1; + } + const signal = reports.filter(row => !(row.rssi === 0 && row.snr === 0)); + const mean = (values: (number | undefined)[]) => { + const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + }; + return { reports, bins, since, snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; +} diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts new file mode 100644 index 0000000..f71dadb --- /dev/null +++ b/src/features/atlas/useSavedAtlas.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; +import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; + +export function useSavedAtlas() { + const [initial] = useState(() => { + try { return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; } + catch { return { value: parseAtlas(null), failed: true }; } + }); + const [saved, setSaved] = useState(initial.value); + const [storageFailed, setStorageFailed] = useState(initial.failed); + const current = useRef(initial.value); + useEffect(() => { + const receive = (event: StorageEvent) => { + if (event.key !== ATLAS_KEY && event.key !== null) return; + current.current = parseAtlas(event.newValue); + setSaved(current.current); + }; + window.addEventListener("storage", receive); + return () => window.removeEventListener("storage", receive); + }, []); + const update = (change: (value: SavedAtlas) => SavedAtlas) => { + const next = change(current.current); + current.current = next; + setSaved(next); + try { localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); setStorageFailed(false); } + catch { setStorageFailed(true); } + }; + return { saved, update, storageFailed }; +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 40886a6..d95da86 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -22,6 +22,7 @@ export interface NodeSummary { observerId?: string; // Server verdict against its configured local border union; absence is unknown/disabled. possiblyForeign?: boolean; + stale?: boolean; // server-configured freshness threshold; absent on older servers } export interface Node extends NodeSummary { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 999ec3f..ab9f130 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,8 @@ "Observers": "Observers", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analytics" + "Analytics": "Analytics", + "MyAtlas": "My Atlas" }, "navigation": { "primary": "Primary", @@ -414,6 +415,79 @@ "emptyNoKey": "No decrypted messages are available without a known channel key.", "error": "Could not load messages.", "retry": "Retry" + }, + "atlas": { + "subtitle": "Your nodes, at a glance.", + "range": "Activity window", + "ranges": { + "24h": "24 hours", + "3d": "3 days" + }, + "addNodes": "Add nodes", + "searchBy": "Search", + "name": "Name", + "publicKey": "Public key", + "search": "Find a node to save", + "namePlaceholder": "Search by node name…", + "keyPlaceholder": "Public key or hex prefix…", + "savedCount": "{{count}} / {{limit}} saved", + "searchHelp": "Search all regions. Enter at least two characters; a public key uses hexadecimal characters.", + "limitReached": "All {{limit}} card spaces are in use. Remove a card to add another.", + "noMatches": "No matching nodes. Try a different name or key.", + "add": "+ Add", + "added": "Saved", + "addName": "Add {{name}}", + "addedName": "{{name}} is saved", + "refine": "Showing the first eight matches. Refine your search to find more.", + "localOnly": "Saved in this browser · All regions", + "storageFailed": "Browser saving is unavailable. Your changes work for this visit but may not be kept.", + "emptyTitle": "Build your own view of the mesh", + "emptyDescription": "Find a repeater, companion or other node above and add a card. Your selection and card order will be here when you return.", + "roles": { + "0": "Unknown", + "1": "Companion", + "2": "Repeater", + "3": "Room", + "4": "Sensor" + }, + "unavailable": "Unavailable", + "recent": "Recently seen", + "stale": "Not seen recently", + "freshnessUnknown": "Freshness unknown", + "refreshFailed": "Refresh failed; showing the last loaded sample.", + "nodeUnavailable": "This saved node could not be loaded. It may have expired or the server may be unavailable.", + "retry": "Retry", + "activity": "Received activity", + "reports": "Received reports", + "activityLabel": "{{count}} received reports in the loaded {{range}} sample. Hourly values are available under Statistics.", + "now": "Now", + "sample": "Loaded sample · up to {{limit}} recent reports", + "snr": "Mean SNR", + "rssi": "Mean RSSI", + "noReports": "No retained reports in this window. This does not establish whether the node is offline.", + "heardBy": "Heard by", + "openNode": "Open node", + "latestAdvert": "Observers of the latest loaded packet", + "heardUnavailable": "The packet details are unavailable or have expired. Try another retained packet from the node profile.", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "openAdvert": "Open packet and observed paths", + "statistics": "Statistics & chart values", + "definition": "Each report is one stored packet reception originating from this full node key, using server deduplication. These are sampled reports, not a total of all node traffic. Blank bars mean no loaded reports; older raw history may have expired.", + "signalDefinition": "Signal meters average available last-hop measurements at the receiving observers. They do not measure the originating node’s radio health. The hourly bars use a rolling window, ending now.", + "capped": "More reports exist than this card loads. Counts and averages cover only the loaded sample.", + "hops": "Mean hops", + "neighbors": "Known neighbours", + "radio": "Radio settings", + "scope": "Default scope", + "chartValues": "Hourly values", + "hour": "Hour starting", + "updated": "Sample loaded", + "manage": "Manage card", + "earlier": "Move earlier", + "later": "Move later", + "remove": "Remove card", + "storageHelp": "Saved in this browser on this site, across all regions. No account needed. Clearing site data removes your cards." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ec00f6d..49b3e51 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -13,7 +13,8 @@ "Observers": "Observateurs", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analyses" + "Analytics": "Analyses", + "MyAtlas": "Mon Atlas" }, "navigation": { "primary": "Navigation principale", @@ -418,6 +419,79 @@ "emptyNoKey": "Aucun message déchiffré n’est disponible sans clé de canal connue.", "error": "Impossible de charger les messages.", "retry": "Réessayer" + }, + "atlas": { + "subtitle": "Vos nœuds en un coup d’œil.", + "range": "Période d’activité", + "ranges": { + "24h": "24 heures", + "3d": "3 jours" + }, + "addNodes": "Ajouter des nœuds", + "searchBy": "Recherche", + "name": "Nom", + "publicKey": "Clé publique", + "search": "Trouver un nœud à enregistrer", + "namePlaceholder": "Rechercher par nom de nœud…", + "keyPlaceholder": "Clé publique ou préfixe hexadécimal…", + "savedCount": "{{count}} / {{limit}} enregistrés", + "searchHelp": "Recherchez dans toutes les régions. Saisissez au moins deux caractères ; une clé publique utilise des caractères hexadécimaux.", + "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", + "noMatches": "Aucun nœud correspondant. Essayez un autre nom ou une autre clé.", + "add": "+ Ajouter", + "added": "Enregistré", + "addName": "Ajouter {{name}}", + "addedName": "{{name}} est enregistré", + "refine": "Les huit premiers résultats sont affichés. Précisez votre recherche pour en trouver d’autres.", + "localOnly": "Enregistré dans ce navigateur · Toutes les régions", + "storageFailed": "L’enregistrement dans le navigateur est indisponible. Vos modifications fonctionnent pour cette visite, mais risquent de ne pas être conservées.", + "emptyTitle": "Créez votre propre vue du réseau", + "emptyDescription": "Cherchez un répéteur, un compagnon ou un autre nœud ci-dessus et ajoutez une fiche. Votre sélection et l’ordre des fiches seront conservés pour votre prochaine visite.", + "roles": { + "0": "Inconnu", + "1": "Compagnon", + "2": "Répéteur", + "3": "Salon", + "4": "Capteur" + }, + "unavailable": "Indisponible", + "recent": "Vu récemment", + "stale": "Pas vu récemment", + "freshnessUnknown": "Actualité inconnue", + "refreshFailed": "Échec de l’actualisation ; affichage du dernier échantillon chargé.", + "nodeUnavailable": "Ce nœud enregistré n’a pas pu être chargé. Il a peut-être expiré ou le serveur est indisponible.", + "retry": "Réessayer", + "activity": "Activité reçue", + "reports": "Réceptions", + "activityLabel": "{{count}} réceptions dans l’échantillon chargé de {{range}}. Les valeurs horaires sont disponibles sous Statistiques.", + "now": "Maintenant", + "sample": "Échantillon chargé · jusqu’à {{limit}} réceptions récentes", + "snr": "SNR moyen", + "rssi": "RSSI moyen", + "noReports": "Aucune réception conservée pour cette période. Cela ne permet pas de conclure que le nœud est hors ligne.", + "heardBy": "Entendu par", + "openNode": "Ouvrir le nœud", + "latestAdvert": "Observateurs du dernier paquet chargé", + "heardUnavailable": "Les détails du paquet sont indisponibles ou ont expiré. Essayez un autre paquet conservé depuis le profil du nœud.", + "observers_one": "{{count}} observateur ayant signalé le paquet", + "observers_other": "{{count}} observateurs ayant signalé le paquet", + "openAdvert": "Ouvrir le paquet et les chemins observés", + "statistics": "Statistiques et valeurs du graphique", + "definition": "Chaque réception correspond à un paquet provenant de cette clé de nœud complète, après déduplication par le serveur. Il s’agit d’un échantillon, pas du trafic total du nœud. Les barres vides indiquent l’absence de réceptions chargées ; l’historique brut plus ancien peut avoir expiré.", + "signalDefinition": "Les indicateurs font la moyenne des mesures disponibles du dernier saut aux observateurs récepteurs. Ils ne mesurent pas l’état de la radio du nœud d’origine. Les barres horaires couvrent une période glissante se terminant maintenant.", + "capped": "D’autres réceptions existent au-delà de celles chargées. Les nombres et moyennes portent uniquement sur l’échantillon chargé.", + "hops": "Sauts moyens", + "neighbors": "Voisins connus", + "radio": "Réglages radio", + "scope": "Portée par défaut", + "chartValues": "Valeurs horaires", + "hour": "Début de l’heure", + "updated": "Échantillon chargé", + "manage": "Gérer la fiche", + "earlier": "Déplacer avant", + "later": "Déplacer après", + "remove": "Retirer la fiche", + "storageHelp": "Enregistré dans ce navigateur sur ce site, toutes régions confondues. Aucun compte requis. Effacer les données du site supprime vos fiches." } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 753b324..65065fc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Observers", "Packets", "Channels", "Map", "Nodes", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["Observers", "MyAtlas", "Packets", "Channels", "Map", "Nodes", "Routes", "Traces", "Analytics"] as const; // Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser; // the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index be2c039..33b3352 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,6 +4,14 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { + it("offers My Atlas in More and preserves its canonical identifier in French", async () => { + const onTabChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("More")); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("menuitem", { name: "Mon Atlas" })); + expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); + }); it("translates an open More sheet without changing the selected tab identifier", async () => { const onTabChange = vi.fn(); render(); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx new file mode 100644 index 0000000..3c19fec --- /dev/null +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -0,0 +1,164 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; +import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import * as api from "../../../src/api/client"; +import type { Node } from "../../../src/features/nodes/types"; +import type { PacketDetail } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn() })); +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; +const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Second" }; +const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; +const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 }; +const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; +const clients: QueryClient[] = []; +function mount(active = true) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + return render(); +} +function save(nodes = [pin]) { localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range: "24h" })); } +beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); + vi.mocked(api.getNode).mockImplementation(async id => id === second.id ? { ...node, ...second } : node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); + vi.mocked(api.getPacketDetail).mockResolvedValue({ packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ + { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, + { observerId: "observer-a", observerName: "Home listener", id: 2, heardAt: 2, iata: "YKF" }, + { observerId: "observer-b", observerName: "Hill listener", id: 3, heardAt: 3, iata: "YKF" }, + ] } as PacketDetail); +}); +afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); + +describe("My Atlas", () => { + it("does not scan nodes or packets before a search or selection", () => { + mount(); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("adds a search result once, persists it across visits, and displays its current name", async () => { + const view = mount(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "repeater" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + view.unmount(); mount(); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("loads heard-by details only on demand, deduplicates observers and links to the evidence", async () => { + save(); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(await screen.findByText("2 reporting observers")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(actions.onViewObserver).toHaveBeenCalledWith("observer-a"); + fireEvent.click(screen.getByRole("button", { name: "Open packet and observed paths" })); expect(actions.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + fireEvent.click(screen.getByRole("button", { name: "Open node" })); expect(actions.onViewNode).toHaveBeenCalledWith(pin.id); + expect(screen.getByRole("meter", { name: "Mean SNR: 0 dB" })).toHaveAttribute("aria-valuenow", "0"); + }); + it("keeps only one heard-by section open and saves reordering, removal and the raw-history range", async () => { + save([pin, second]); mount(); + const first = await screen.findByRole("article", { name: "Renamed repeater" }); + const next = await screen.findByRole("article", { name: "Second" }); + fireEvent.click(within(first).getByRole("button", { name: "Heard by" })); + fireEvent.click(within(next).getByRole("button", { name: "Heard by" })); + expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(within(first).getByText("Manage card")); + fireEvent.click(within(first).getByRole("button", { name: "Move later" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + fireEvent.click(screen.getByRole("button", { name: "3 days" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); + fireEvent.click(within(first).getByRole("button", { name: "Remove card" })); + expect(screen.queryByRole("article", { name: "Renamed repeater" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /7 days|30 days/ })).not.toBeInTheDocument(); + }); + it("uses French labels and keeps working when browser writes fail", async () => { + save(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + await act(() => i18n.changeLanguage("fr")); mount(); + await screen.findByRole("article", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3 jours" })); + expect(screen.getByRole("button", { name: "3 jours" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/L’enregistrement dans le navigateur est indisponible/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Entendu par" })).toBeInTheDocument(); + }); + it("honours changes made in another tab and keeps corrupt storage from crashing the page", async () => { + localStorage.setItem(ATLAS_KEY, "{"); mount(); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: ATLAS_KEY, newValue: JSON.stringify({ version: 1, nodes: [pin], range: "3d" }) }))); + await screen.findByRole("article", { name: "Renamed repeater" }); + expect(screen.getByRole("button", { name: "3 days" })).toHaveAttribute("aria-pressed", "true"); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not query a retained but inactive page", () => { + save(); mount(false); + expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("explains expired packet evidence", async () => { + save(); vi.mocked(api.getPacketDetail).mockRejectedValue(new Error("expired")); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + }); + it("keeps an unavailable node removable without rendering zeros as real readings", async () => { + save(); vi.mocked(api.getNode).mockRejectedValue(new Error("unavailable")); mount(); + await screen.findByText(/This saved node could not be loaded/); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Manage card")); + fireEvent.click(screen.getByRole("button", { name: "Remove card" })); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not expose observer links for a different full origin identity", async () => { + save(); vi.mocked(api.getPacketDetail).mockResolvedValue({ originPubkey: second.publicKey, observations: [] } as unknown as PacketDetail); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + expect(screen.queryByRole("button", { name: "Open packet and observed paths" })).not.toBeInTheDocument(); + }); + it("aborts in-flight requests when leaving the page", () => { + save(); vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); + const view = mount(); const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; + expect(signal.aborted).toBe(false); + view.unmount(); expect(signal.aborted).toBe(true); + }); + it("shows missing signal values without meters for an empty retained sample", async () => { + save(); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(screen.getByRole("img", { name: "Mean SNR: Unavailable" })).toBeInTheDocument(); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("never queries an invalid public-key search as an unfiltered node list", async () => { + mount(); fireEvent.click(screen.getByRole("button", { name: /Search.*Name/ })); + fireEvent.click(screen.getByRole("option", { name: "Public key" })); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "not-a-key" } }); + await act(() => new Promise(resolve => setTimeout(resolve, 350))); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); + +describe("identity recovery", () => { + it("recovers a changed server ID using the exact full public key", async () => { + vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, id: second.id }); + const result = await loadAtlasNode(pin); + expect(result.node.id).toBe(second.id); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, undefined); + }); + it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + await expect(loadAtlasNode(pin)).rejects.toThrow("Atlas identity unavailable"); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + vi.mocked(api.getNodesPage).mockClear(); vi.mocked(api.getNode).mockRejectedValueOnce(new Error("network")); + await expect(loadAtlasNode(pin)).rejects.toThrow("network"); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts new file mode 100644 index 0000000..35e1c01 --- /dev/null +++ b/tests/features/atlas/atlas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import type { NodeObservation } from "../../../src/features/nodes/types"; + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const now = Date.UTC(2026, 8, 29, 12); +const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); + +describe("saved Atlas", () => { + it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + }); + it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { + for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; + const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); + expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.range).toBe("24h"); + }); +}); + +describe("origin report sample", () => { + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { + const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; + const sample = summarizeReports(rows, "24h", now); + expect(sample.reports.map(r => r.id)).toEqual([1, 5, 2]); + expect(sample.bins.reduce((a, b) => a + b, 0)).toBe(3); + expect(sample.bins[23]).toBe(2); + expect(sample.bins[0]).toBe(1); + expect(summarizeReports(rows, "3d", now).reports).toHaveLength(4); + }); + it("does not turn missing signal into zero and averages real zero SNR samples", () => { + const sample = summarizeReports([report(1, 1), report(2, 2, { rssi: 0, snr: 0 }), report(3, 3, { rssi: -110, snr: 0, hopCount: 0 }), report(4, 4, { rssi: -90, snr: 10, hopCount: 4 }), report(5, 5, { rssi: NaN, snr: Infinity })], "24h", now); + expect(sample.snr).toBe(5); + expect(sample.rssi).toBe(-100); + expect(sample.hops).toBe(2); + expect(summarizeReports([], "24h", now).snr).toBeNull(); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 25c9f57..0415010 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", + "Observers", "MyAtlas", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", + "Observers", "MyAtlas", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); }); From 66ae0cc222a6580b52f62051025453a992956cc6 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 05:04:46 -0400 Subject: [PATCH 44/45] refine Atlas copy and post-release navigation placement --- src/features/atlas/MyAtlasPage.tsx | 1 - src/i18n/locales/en.json | 3 +-- src/i18n/locales/fr.json | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index 69f756a..ed5beb4 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -56,7 +56,6 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti {search.data?.hasMore &&

{t("atlas.refine")}

} }
} -

{t("atlas.storageHelp")}

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

{empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab9f130..72e6dc3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -486,8 +486,7 @@ "manage": "Manage card", "earlier": "Move earlier", "later": "Move later", - "remove": "Remove card", - "storageHelp": "Saved in this browser on this site, across all regions. No account needed. Clearing site data removes your cards." + "remove": "Remove card" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 49b3e51..76ab668 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -490,8 +490,7 @@ "manage": "Gérer la fiche", "earlier": "Déplacer avant", "later": "Déplacer après", - "remove": "Retirer la fiche", - "storageHelp": "Enregistré dans ce navigateur sur ce site, toutes régions confondues. Aucun compte requis. Effacer les données du site supprime vos fiches." + "remove": "Retirer la fiche" } } } From d1eb973b18d34ff4ff712992d9f5a63297431646 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 2 Oct 2026 08:53:29 -0400 Subject: [PATCH 45/45] feat(atlas): add saved node cards on the 2.0 baseline --- .env.example | 2 +- docs/my-atlas.md | 62 ++++++++ src/App.tsx | 2 + src/api/client.ts | 14 +- src/components/BottomNav.tsx | 9 +- src/features/atlas/AtlasCard.tsx | 113 +++++++++++++++ src/features/atlas/MyAtlasPage.tsx | 71 ++++++++++ src/features/atlas/atlas.ts | 66 +++++++++ src/features/atlas/useSavedAtlas.ts | 29 ++++ src/features/nodes/types.ts | 1 + src/i18n/locales/en.json | 75 +++++++++- src/i18n/locales/fr.json | 75 +++++++++- src/lib/constants.ts | 2 +- tests/components/BottomNav.test.tsx | 11 ++ tests/features/atlas/MyAtlasPage.test.tsx | 164 ++++++++++++++++++++++ tests/features/atlas/atlas.test.ts | 39 +++++ tests/lib/constants.test.ts | 4 +- 17 files changed, 722 insertions(+), 17 deletions(-) create mode 100644 docs/my-atlas.md create mode 100644 src/features/atlas/AtlasCard.tsx create mode 100644 src/features/atlas/MyAtlasPage.tsx create mode 100644 src/features/atlas/atlas.ts create mode 100644 src/features/atlas/useSavedAtlas.ts create mode 100644 tests/features/atlas/MyAtlasPage.test.tsx create mode 100644 tests/features/atlas/atlas.test.ts diff --git a/.env.example b/.env.example index 800e481..8941de8 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # VITE_MAP_ZOOM=3.2 # Hide tabs completely (comma list, case-insensitive). Unset = show all. -# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# Options: Packets,MyAtlas,Channels,Map,Nodes,Observers,Routes,Traces,Analytics # VITE_DISABLED_TABS=Map,Traces # Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none. diff --git a/docs/my-atlas.md b/docs/my-atlas.md new file mode 100644 index 0000000..e3b6985 --- /dev/null +++ b/docs/my-atlas.md @@ -0,0 +1,62 @@ +# My Atlas + +Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The +canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does +not publish someone else's saved nodes. + +Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve +nodes. **Manage card** moves or removes a card. The selected nodes, their order +and the 24-hour/3-day activity window are stored in this browser on this origin +under `beacon-my-atlas-v1`. There is no account or cross-device synchronization. +Clearing site data removes the collection. When browser storage is unavailable, +the page explains that edits may only last for the current visit. + +Cards use the current full node identity, not an ambiguous short path prefix. +Names refresh from the node record. If an old server ID disappears or points at +a different key, the page searches for the exact full key before loading reports. +An unavailable node stays in the collection and remains removable. + +## Reading the cards + +- **Received reports** are stored receptions of packets originating from the exact + node key, after server deduplication. The page loads the latest 200 reports per + node, then filters that sample to the selected rolling window. Counts are not + a total of all node traffic. The card flags when more reports are available. +- Each bar covers one hour of the rolling window. Blank bars mean no reports in + the loaded sample; they do not prove silence or packet loss. **Statistics & + chart values** exposes the numeric hourly values and sample definition. +- **Mean SNR / RSSI** average finite, available measurements from that sample. + The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR + with a real RSSI is included. These are last-hop measurements at the observers, + not a measurement of the origin's radio health. Meters include numbers and + accessible values; missing measurements display a dash. +- Freshness uses the server's `stale` verdict. It does not infer online/offline + status from the report count or invent a local threshold. +- **Heard by** lazily opens the reporting observers for the latest loaded packet + in the selected window. It is not lifetime reach or all observers of the node. + Repeated reports from the same observer are grouped. Observer buttons open + inspection; the packet action opens the exact packet and observed paths. +- **Open node** reuses Beacon's node inspection. Statistics also show existing + neighbour count, radio settings, default scope and full public key. + +## Cost and compatibility + +The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency. +No query runs for an empty collection before a valid search. Search is debounced, +limited to eight results and rejects invalid key input before issuing a request. +Each saved card normally makes two requests (node plus 200-report page) per +minute while the page is active and the document is visible. Error refreshes stop +until Retry or a later visit. A changed server ID can require two extra identity +requests. A single expanded Heard by panel uses the existing packet-detail cache. +Requests consume TanStack Query's abort signal when the page leaves. + +No server schema, retention settings, MQTT behavior or authentication changes are +needed. Existing raw-retention limits still apply. Longer-lived analytics remain +separate. English and French labels ship together. Deployers can hide the page +with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration. + +The focused tests cover persistence, corrupt/blocked storage, cross-tab changes, +exact-key recovery, rename display, bounded/lazy loading, missing signals, +expired evidence, range selection, order/removal and both languages. Release QA +also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, +and the matching public source offer. diff --git a/src/App.tsx b/src/App.tsx index 82424da..8c8dc80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import type { PacketDetail } from "./types/api"; // Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the // first time someone opens the Map tab instead of bloating the initial bundle. +const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage }))); const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView }))); // Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. @@ -297,6 +298,7 @@ function AppInner() { }, []); const tabContent: Record = { + MyAtlas: , Packets: ( { - return request(`/packets/${packetHash}`); +export function getPacketDetail(packetHash: string, signal?: AbortSignal): Promise { + return request(`/packets/${packetHash}`, undefined, signal); } export function getIatas(): Promise { @@ -283,6 +283,7 @@ export function getNodesPage( supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, + signal?: AbortSignal, ): Promise> { if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/nodes", { @@ -295,7 +296,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } // Paginated /observers, mirroring getNodesPage; used by the Observers table. @@ -315,18 +316,19 @@ export function getObserversPage( }); } -export function getNode(nodeId: string): Promise { - return request(`/nodes/${nodeId}`); +export function getNode(nodeId: string, signal?: AbortSignal): Promise { + return request(`/nodes/${nodeId}`, undefined, signal); } export function getNodeObservations( nodeId: string, params?: { cursor?: number; limit?: number }, + signal?: AbortSignal, ): Promise> { return request(`/nodes/${nodeId}/observations`, { cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, - }); + }, signal); } export function getNodeNeighbors(nodeId: string): Promise { diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index ab2896a..333bfc5 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -4,13 +4,13 @@ import { BottomSheet } from "./BottomSheet"; import { ENABLED_TABS } from "../lib/constants"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. -const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; -const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; switch (name) { + case "MyAtlas": + return ; case "Packets": return ( @@ -105,9 +105,8 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) { const { t } = useTranslation(); const [sheetOpen, setSheetOpen] = useState(false); - const enabled = new Set(ENABLED_TABS); - const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t)); - const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t)); + const primary = ENABLED_TABS.slice(0, 4); + const overflow = ENABLED_TABS.slice(4); const overflowActive = overflow.includes(activeTab); const pick = (tab: string) => { diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx new file mode 100644 index 0000000..1ddd095 --- /dev/null +++ b/src/features/atlas/AtlasCard.tsx @@ -0,0 +1,113 @@ +import { useId } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getPacketDetail } from "../../api/client"; +import { Timestamp } from "../../components/Timestamp"; +import { useTick } from "../../hooks/useTick"; +import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas"; + +export interface AtlasActions { + onViewNode: (id: string) => void; + onViewObserver: (id: string) => void; + onAnalyzePacket: (hash: string, observationId?: number) => void; +} + +function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) { + const { t, i18n } = useTranslation(); + const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; + const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min)))); + return
+
{label}{value === null ? "—" : text}
+
+ {Array.from({ length: 16 }, (_, index) =>
+
; +} + +export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & { + pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void; + onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean; +}) { + const { t, i18n } = useTranslation(); + const now = useTick(60_000); + const id = useId(); + const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false, + refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false }); + const node = query.data?.node; + const sample = summarizeReports(query.data?.page.items ?? [], range, now); + const latest = sample.reports[0]; + const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false }); + const detail = heard.data; + const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; + const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : []; + const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase(); + const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); + const maximum = Math.max(1, ...sample.bins); + const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
+
+

+ {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} +
+
+
+
+ {query.isPending ?

{t("common.loading")}

: query.isError &&

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

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

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

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

{t("atlas.noReports")}

} +
+ + +
+ {heardOpen &&
+

{t("atlas.latestAdvert")}

+ {!latest ?

{t("atlas.noReports")}

: heard.isPending ?

{t("common.loading")}

: heard.isError || !correctOrigin ?

{t("atlas.heardUnavailable")}

: <> +

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

+
    {observers.map(row =>
  • {row.iata}
  • )}
+ + } +
} +
+ {t("atlas.statistics")} +
+

{t("atlas.definition")}

+

{t("atlas.signalDefinition")}

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

{t("atlas.capped")}

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

{node.publicKey}

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

{t("atlas.updated")}

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

{t("tabs.MyAtlas")}

{t("atlas.subtitle")}

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

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

+ {ready &&
+ {search.isPending ?

{t("common.loading")}

: search.isError ?

{t("common.loadFailed")}

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

{t("atlas.noMatches")}

} +
    {search.data?.items.map(node => { + const selected = saved.nodes.some(pin => pin.publicKey === node.publicKey.toLowerCase()); + const name = node.name || node.publicKey.slice(0, 12).toUpperCase(); + return
  • +

    {name}

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

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

{t("atlas.refine")}

} + } +
} +
+

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

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+ {saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { + const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; + if (from < 0 || to < 0 || to >= nodes.length) return value; + [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + return { ...value, nodes }; + })} {...actions} />)} +
} +
+
; +} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts new file mode 100644 index 0000000..7662b65 --- /dev/null +++ b/src/features/atlas/atlas.ts @@ -0,0 +1,66 @@ +import { getNode, getNodeObservations, getNodesPage } from "../../api/client"; +import type { Node, NodeObservation } from "../nodes/types"; + +export const ATLAS_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; +export const REPORT_LIMIT = 200; +export type AtlasRange = "24h" | "3d"; +export interface AtlasPin { id: string; publicKey: string; name: string } +export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } + +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; + const nodes: AtlasPin[] = []; + for (const pin of value.nodes) { + if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) || + typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; + const publicKey = pin.publicKey.toLowerCase(); + if (nodes.some(node => node.publicKey === publicKey)) continue; + nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); + if (nodes.length === ATLAS_LIMIT) break; + } + return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; + } catch { return empty; } +} + +// Resolve by full identity after a server database reset; never adopt a different node at an old ID. +export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { + let node: Node | undefined; + try { node = await getNode(pin.id, signal); } + catch (error) { if (!(error instanceof Error && "status" in error && error.status === 404)) throw error; } + if (node?.publicKey.toLowerCase() !== pin.publicKey) { + const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); + const match = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey); + if (!match) throw new Error("Atlas identity unavailable"); + node = await getNode(match.id, signal); + } + if (node.publicKey.toLowerCase() !== pin.publicKey) throw new Error("Atlas identity changed"); + const page = await getNodeObservations(node.id, { limit: REPORT_LIMIT }, signal); + return { node, page }; +} + +export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number) { + const hours = range === "3d" ? 72 : 24; + const since = now - hours * 3_600_000; + const seen = new Set(); + const reports = rows.filter(row => { + if (seen.has(row.id) || row.heardAt <= since || row.heardAt > now) return false; + seen.add(row.id); + return Number.isFinite(row.heardAt); + }).sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); + const bins = Array(hours).fill(0); + for (const row of reports) { + const index = Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)); + bins[index] = (bins[index] ?? 0) + 1; + } + const signal = reports.filter(row => !(row.rssi === 0 && row.snr === 0)); + const mean = (values: (number | undefined)[]) => { + const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + }; + return { reports, bins, since, snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; +} diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts new file mode 100644 index 0000000..f71dadb --- /dev/null +++ b/src/features/atlas/useSavedAtlas.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; +import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; + +export function useSavedAtlas() { + const [initial] = useState(() => { + try { return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; } + catch { return { value: parseAtlas(null), failed: true }; } + }); + const [saved, setSaved] = useState(initial.value); + const [storageFailed, setStorageFailed] = useState(initial.failed); + const current = useRef(initial.value); + useEffect(() => { + const receive = (event: StorageEvent) => { + if (event.key !== ATLAS_KEY && event.key !== null) return; + current.current = parseAtlas(event.newValue); + setSaved(current.current); + }; + window.addEventListener("storage", receive); + return () => window.removeEventListener("storage", receive); + }, []); + const update = (change: (value: SavedAtlas) => SavedAtlas) => { + const next = change(current.current); + current.current = next; + setSaved(next); + try { localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); setStorageFailed(false); } + catch { setStorageFailed(true); } + }; + return { saved, update, storageFailed }; +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 40886a6..d95da86 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -22,6 +22,7 @@ export interface NodeSummary { observerId?: string; // Server verdict against its configured local border union; absence is unknown/disabled. possiblyForeign?: boolean; + stale?: boolean; // server-configured freshness threshold; absent on older servers } export interface Node extends NodeSummary { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cf5e1f5..61cddbc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,8 @@ "Observers": "Observers", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analytics" + "Analytics": "Analytics", + "MyAtlas": "My Atlas" }, "navigation": { "primary": "Primary", @@ -901,6 +902,78 @@ }, "units": { "perDay": "{{value}}/d" + }, + "atlas": { + "subtitle": "Your nodes, at a glance.", + "range": "Activity window", + "ranges": { + "24h": "24 hours", + "3d": "3 days" + }, + "addNodes": "Add nodes", + "searchBy": "Search", + "name": "Name", + "publicKey": "Public key", + "search": "Find a node to save", + "namePlaceholder": "Search by node name…", + "keyPlaceholder": "Public key or hex prefix…", + "savedCount": "{{count}} / {{limit}} saved", + "searchHelp": "Search all regions. Enter at least two characters; a public key uses hexadecimal characters.", + "limitReached": "All {{limit}} card spaces are in use. Remove a card to add another.", + "noMatches": "No matching nodes. Try a different name or key.", + "add": "+ Add", + "added": "Saved", + "addName": "Add {{name}}", + "addedName": "{{name}} is saved", + "refine": "Showing the first eight matches. Refine your search to find more.", + "localOnly": "Saved in this browser · All regions", + "storageFailed": "Browser saving is unavailable. Your changes work for this visit but may not be kept.", + "emptyTitle": "Build your own view of the mesh", + "emptyDescription": "Find a repeater, companion or other node above and add a card. Your selection and card order will be here when you return.", + "roles": { + "0": "Unknown", + "1": "Companion", + "2": "Repeater", + "3": "Room", + "4": "Sensor" + }, + "unavailable": "Unavailable", + "recent": "Recently seen", + "stale": "Not seen recently", + "freshnessUnknown": "Freshness unknown", + "refreshFailed": "Refresh failed; showing the last loaded sample.", + "nodeUnavailable": "This saved node could not be loaded. It may have expired or the server may be unavailable.", + "retry": "Retry", + "activity": "Received activity", + "reports": "Received reports", + "activityLabel": "{{count}} received reports in the loaded {{range}} sample. Hourly values are available under Statistics.", + "now": "Now", + "sample": "Loaded sample · up to {{limit}} recent reports", + "snr": "Mean SNR", + "rssi": "Mean RSSI", + "noReports": "No retained reports in this window. This does not establish whether the node is offline.", + "heardBy": "Heard by", + "openNode": "Open node", + "latestAdvert": "Observers of the latest loaded packet", + "heardUnavailable": "The packet details are unavailable or have expired. Try another retained packet from the node profile.", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "openAdvert": "Open packet and observed paths", + "statistics": "Statistics & chart values", + "definition": "Each report is one stored packet reception originating from this full node key, using server deduplication. These are sampled reports, not a total of all node traffic. Blank bars mean no loaded reports; older raw history may have expired.", + "signalDefinition": "Signal meters average available last-hop measurements at the receiving observers. They do not measure the originating node’s radio health. The hourly bars use a rolling window, ending now.", + "capped": "More reports exist than this card loads. Counts and averages cover only the loaded sample.", + "hops": "Mean hops", + "neighbors": "Known neighbours", + "radio": "Radio settings", + "scope": "Default scope", + "chartValues": "Hourly values", + "hour": "Hour starting", + "updated": "Sample loaded", + "manage": "Manage card", + "earlier": "Move earlier", + "later": "Move later", + "remove": "Remove card" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 6ad52b1..fff117f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -13,7 +13,8 @@ "Observers": "Observateurs", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analyses" + "Analytics": "Analyses", + "MyAtlas": "Mon Atlas" }, "navigation": { "primary": "Navigation principale", @@ -937,6 +938,78 @@ }, "units": { "perDay": "{{value}}/j" + }, + "atlas": { + "subtitle": "Vos nœuds en un coup d’œil.", + "range": "Période d’activité", + "ranges": { + "24h": "24 heures", + "3d": "3 jours" + }, + "addNodes": "Ajouter des nœuds", + "searchBy": "Recherche", + "name": "Nom", + "publicKey": "Clé publique", + "search": "Trouver un nœud à enregistrer", + "namePlaceholder": "Rechercher par nom de nœud…", + "keyPlaceholder": "Clé publique ou préfixe hexadécimal…", + "savedCount": "{{count}} / {{limit}} enregistrés", + "searchHelp": "Recherchez dans toutes les régions. Saisissez au moins deux caractères ; une clé publique utilise des caractères hexadécimaux.", + "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", + "noMatches": "Aucun nœud correspondant. Essayez un autre nom ou une autre clé.", + "add": "+ Ajouter", + "added": "Enregistré", + "addName": "Ajouter {{name}}", + "addedName": "{{name}} est enregistré", + "refine": "Les huit premiers résultats sont affichés. Précisez votre recherche pour en trouver d’autres.", + "localOnly": "Enregistré dans ce navigateur · Toutes les régions", + "storageFailed": "L’enregistrement dans le navigateur est indisponible. Vos modifications fonctionnent pour cette visite, mais risquent de ne pas être conservées.", + "emptyTitle": "Créez votre propre vue du réseau", + "emptyDescription": "Cherchez un répéteur, un compagnon ou un autre nœud ci-dessus et ajoutez une fiche. Votre sélection et l’ordre des fiches seront conservés pour votre prochaine visite.", + "roles": { + "0": "Inconnu", + "1": "Compagnon", + "2": "Répéteur", + "3": "Salon", + "4": "Capteur" + }, + "unavailable": "Indisponible", + "recent": "Vu récemment", + "stale": "Pas vu récemment", + "freshnessUnknown": "Actualité inconnue", + "refreshFailed": "Échec de l’actualisation ; affichage du dernier échantillon chargé.", + "nodeUnavailable": "Ce nœud enregistré n’a pas pu être chargé. Il a peut-être expiré ou le serveur est indisponible.", + "retry": "Réessayer", + "activity": "Activité reçue", + "reports": "Réceptions", + "activityLabel": "{{count}} réceptions dans l’échantillon chargé de {{range}}. Les valeurs horaires sont disponibles sous Statistiques.", + "now": "Maintenant", + "sample": "Échantillon chargé · jusqu’à {{limit}} réceptions récentes", + "snr": "SNR moyen", + "rssi": "RSSI moyen", + "noReports": "Aucune réception conservée pour cette période. Cela ne permet pas de conclure que le nœud est hors ligne.", + "heardBy": "Entendu par", + "openNode": "Ouvrir le nœud", + "latestAdvert": "Observateurs du dernier paquet chargé", + "heardUnavailable": "Les détails du paquet sont indisponibles ou ont expiré. Essayez un autre paquet conservé depuis le profil du nœud.", + "observers_one": "{{count}} observateur ayant signalé le paquet", + "observers_other": "{{count}} observateurs ayant signalé le paquet", + "openAdvert": "Ouvrir le paquet et les chemins observés", + "statistics": "Statistiques et valeurs du graphique", + "definition": "Chaque réception correspond à un paquet provenant de cette clé de nœud complète, après déduplication par le serveur. Il s’agit d’un échantillon, pas du trafic total du nœud. Les barres vides indiquent l’absence de réceptions chargées ; l’historique brut plus ancien peut avoir expiré.", + "signalDefinition": "Les indicateurs font la moyenne des mesures disponibles du dernier saut aux observateurs récepteurs. Ils ne mesurent pas l’état de la radio du nœud d’origine. Les barres horaires couvrent une période glissante se terminant maintenant.", + "capped": "D’autres réceptions existent au-delà de celles chargées. Les nombres et moyennes portent uniquement sur l’échantillon chargé.", + "hops": "Sauts moyens", + "neighbors": "Voisins connus", + "radio": "Réglages radio", + "scope": "Portée par défaut", + "chartValues": "Valeurs horaires", + "hour": "Début de l’heure", + "updated": "Échantillon chargé", + "manage": "Gérer la fiche", + "earlier": "Déplacer avant", + "later": "Déplacer après", + "remove": "Retirer la fiche" } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6701ea6..0f9bf2e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["MyAtlas", "Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; // Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser; // the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index be2c039..211ecd0 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,6 +4,17 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { + it("puts My Atlas first and preserves its canonical identifier in French", async () => { + const onTabChange = vi.fn(); + render(); + expect(screen.getAllByRole("tab")[0]).toHaveTextContent("My Atlas"); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("tab", { name: "Mon Atlas" })); + expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); + fireEvent.click(screen.getByText("Plus")); + fireEvent.click(screen.getByRole("menuitem", { name: "Nœuds" })); + expect(onTabChange).toHaveBeenCalledWith("Nodes"); + }); it("translates an open More sheet without changing the selected tab identifier", async () => { const onTabChange = vi.fn(); render(); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx new file mode 100644 index 0000000..3c19fec --- /dev/null +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -0,0 +1,164 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; +import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import * as api from "../../../src/api/client"; +import type { Node } from "../../../src/features/nodes/types"; +import type { PacketDetail } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn() })); +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; +const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Second" }; +const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; +const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 }; +const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; +const clients: QueryClient[] = []; +function mount(active = true) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + return render(); +} +function save(nodes = [pin]) { localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range: "24h" })); } +beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); + vi.mocked(api.getNode).mockImplementation(async id => id === second.id ? { ...node, ...second } : node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); + vi.mocked(api.getPacketDetail).mockResolvedValue({ packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ + { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, + { observerId: "observer-a", observerName: "Home listener", id: 2, heardAt: 2, iata: "YKF" }, + { observerId: "observer-b", observerName: "Hill listener", id: 3, heardAt: 3, iata: "YKF" }, + ] } as PacketDetail); +}); +afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); + +describe("My Atlas", () => { + it("does not scan nodes or packets before a search or selection", () => { + mount(); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("adds a search result once, persists it across visits, and displays its current name", async () => { + const view = mount(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "repeater" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + view.unmount(); mount(); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("loads heard-by details only on demand, deduplicates observers and links to the evidence", async () => { + save(); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(await screen.findByText("2 reporting observers")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(actions.onViewObserver).toHaveBeenCalledWith("observer-a"); + fireEvent.click(screen.getByRole("button", { name: "Open packet and observed paths" })); expect(actions.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + fireEvent.click(screen.getByRole("button", { name: "Open node" })); expect(actions.onViewNode).toHaveBeenCalledWith(pin.id); + expect(screen.getByRole("meter", { name: "Mean SNR: 0 dB" })).toHaveAttribute("aria-valuenow", "0"); + }); + it("keeps only one heard-by section open and saves reordering, removal and the raw-history range", async () => { + save([pin, second]); mount(); + const first = await screen.findByRole("article", { name: "Renamed repeater" }); + const next = await screen.findByRole("article", { name: "Second" }); + fireEvent.click(within(first).getByRole("button", { name: "Heard by" })); + fireEvent.click(within(next).getByRole("button", { name: "Heard by" })); + expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(within(first).getByText("Manage card")); + fireEvent.click(within(first).getByRole("button", { name: "Move later" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + fireEvent.click(screen.getByRole("button", { name: "3 days" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); + fireEvent.click(within(first).getByRole("button", { name: "Remove card" })); + expect(screen.queryByRole("article", { name: "Renamed repeater" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /7 days|30 days/ })).not.toBeInTheDocument(); + }); + it("uses French labels and keeps working when browser writes fail", async () => { + save(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + await act(() => i18n.changeLanguage("fr")); mount(); + await screen.findByRole("article", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3 jours" })); + expect(screen.getByRole("button", { name: "3 jours" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/L’enregistrement dans le navigateur est indisponible/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Entendu par" })).toBeInTheDocument(); + }); + it("honours changes made in another tab and keeps corrupt storage from crashing the page", async () => { + localStorage.setItem(ATLAS_KEY, "{"); mount(); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: ATLAS_KEY, newValue: JSON.stringify({ version: 1, nodes: [pin], range: "3d" }) }))); + await screen.findByRole("article", { name: "Renamed repeater" }); + expect(screen.getByRole("button", { name: "3 days" })).toHaveAttribute("aria-pressed", "true"); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not query a retained but inactive page", () => { + save(); mount(false); + expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("explains expired packet evidence", async () => { + save(); vi.mocked(api.getPacketDetail).mockRejectedValue(new Error("expired")); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + }); + it("keeps an unavailable node removable without rendering zeros as real readings", async () => { + save(); vi.mocked(api.getNode).mockRejectedValue(new Error("unavailable")); mount(); + await screen.findByText(/This saved node could not be loaded/); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Manage card")); + fireEvent.click(screen.getByRole("button", { name: "Remove card" })); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not expose observer links for a different full origin identity", async () => { + save(); vi.mocked(api.getPacketDetail).mockResolvedValue({ originPubkey: second.publicKey, observations: [] } as unknown as PacketDetail); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + expect(screen.queryByRole("button", { name: "Open packet and observed paths" })).not.toBeInTheDocument(); + }); + it("aborts in-flight requests when leaving the page", () => { + save(); vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); + const view = mount(); const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; + expect(signal.aborted).toBe(false); + view.unmount(); expect(signal.aborted).toBe(true); + }); + it("shows missing signal values without meters for an empty retained sample", async () => { + save(); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(screen.getByRole("img", { name: "Mean SNR: Unavailable" })).toBeInTheDocument(); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("never queries an invalid public-key search as an unfiltered node list", async () => { + mount(); fireEvent.click(screen.getByRole("button", { name: /Search.*Name/ })); + fireEvent.click(screen.getByRole("option", { name: "Public key" })); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "not-a-key" } }); + await act(() => new Promise(resolve => setTimeout(resolve, 350))); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); + +describe("identity recovery", () => { + it("recovers a changed server ID using the exact full public key", async () => { + vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, id: second.id }); + const result = await loadAtlasNode(pin); + expect(result.node.id).toBe(second.id); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, undefined); + }); + it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + await expect(loadAtlasNode(pin)).rejects.toThrow("Atlas identity unavailable"); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + vi.mocked(api.getNodesPage).mockClear(); vi.mocked(api.getNode).mockRejectedValueOnce(new Error("network")); + await expect(loadAtlasNode(pin)).rejects.toThrow("network"); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts new file mode 100644 index 0000000..35e1c01 --- /dev/null +++ b/tests/features/atlas/atlas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import type { NodeObservation } from "../../../src/features/nodes/types"; + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const now = Date.UTC(2026, 8, 29, 12); +const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); + +describe("saved Atlas", () => { + it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + }); + it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { + for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; + const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); + expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.range).toBe("24h"); + }); +}); + +describe("origin report sample", () => { + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { + const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; + const sample = summarizeReports(rows, "24h", now); + expect(sample.reports.map(r => r.id)).toEqual([1, 5, 2]); + expect(sample.bins.reduce((a, b) => a + b, 0)).toBe(3); + expect(sample.bins[23]).toBe(2); + expect(sample.bins[0]).toBe(1); + expect(summarizeReports(rows, "3d", now).reports).toHaveLength(4); + }); + it("does not turn missing signal into zero and averages real zero SNR samples", () => { + const sample = summarizeReports([report(1, 1), report(2, 2, { rssi: 0, snr: 0 }), report(3, 3, { rssi: -110, snr: 0, hopCount: 0 }), report(4, 4, { rssi: -90, snr: 10, hopCount: 4 }), report(5, 5, { rssi: NaN, snr: Infinity })], "24h", now); + expect(sample.snr).toBe(5); + expect(sample.rssi).toBe(-100); + expect(sample.hops).toBe(2); + expect(summarizeReports([], "24h", now).snr).toBeNull(); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 328fdd6..538dd1c 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", ]); });