From e65e8e42ec7bcd75029ebc636645eedc2b889cbf Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 26 Sep 2026 03:28:04 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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); +});