From 166f73340364d92fd6bdf566f7aaa4589b7edb62 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 05:52:24 -0400 Subject: [PATCH] feat(stats): compare flood packets heard by two observers --- src/api/client.ts | 13 +- src/features/stats/CompareObserversTab.tsx | 163 ++++++++++++++++++ src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 5 +- src/features/stats/types.ts | 15 +- tests/api/observer-comparison.test.ts | 17 ++ .../stats/CompareObserversTab.test.tsx | 110 ++++++++++++ 7 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 src/features/stats/CompareObserversTab.tsx create mode 100644 tests/api/observer-comparison.test.ts create mode 100644 tests/features/stats/CompareObserversTab.test.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 236d43d7..011408b3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,7 @@ import type { ObserverSummary, Observer, AdvertObservation } from "../features/o import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { StatsOverview, + ObserverComparison, ObservationPoint, PayloadBreakdownItem, TopNode, @@ -47,7 +48,7 @@ async function errorFrom(res: Response): Promise { return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs); } -async function request(path: string, params?: Record): Promise { +async function request(path: string, params?: Record, signal?: AbortSignal): Promise { const url = new URL(`${API_BASE}${path}`, window.location.origin); if (params) { for (const [key, value] of Object.entries(params)) { @@ -57,7 +58,7 @@ async function request(path: string, params?: Record(path: string, params?: Record { + return request("/stats/observer-comparison", { ...params, iatas: iatasParam(iatas) }, signal); +} + // The region filter travels as the comma-separated `iatas` param; undefined/empty means all regions. function iatasParam(iatas?: string[]): string | undefined { return iatas && iatas.length > 0 ? iatas.join(",") : undefined; diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx new file mode 100644 index 00000000..7605afbf --- /dev/null +++ b/src/features/stats/CompareObserversTab.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { Card } from "./cards"; + +type Selection = { observerA: string; observerB: string; since: number; until: number }; +const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal"; + +function validation(value: Selection): string | null { + if (!uuid.test(value.observerA) || !uuid.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { + return "Choose two different observers."; + } + if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { + return "Choose a valid start and a later end time."; + } + return null; +} + +function localTime(ms: number) { + const date = new Date(ms); + return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23); +} + +function ObserverSelect({ label, value, onChange }: { label: string; value: string; onChange: (id: string) => void }) { + const { iatas, regionKey } = useRegion(); + const [search, setSearch] = useState(""); + const [name, setName] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setName(search.trim()), 250); + return () => clearTimeout(timer); + }, [search]); + const options = useQuery({ + queryKey: ["comparison-observers", regionKey, name], + queryFn: () => getObserversPage(iatas, { name: name || undefined, limit: 50 }), + staleTime: 30_000, + }); + const selected = useQuery({ + queryKey: ["observer", value], queryFn: () => getObserver(value), + enabled: uuid.test(value), staleTime: 30_000, retry: false, + }); + const rows = options.data?.items ?? []; + return ( +
+ {label} + setSearch(e.target.value)} className={fieldClass} /> + +

+ {options.isPending ? "Loading observers…" : options.isError ? "Observer search failed. Try again." : options.data?.hasMore ? "Showing 50 observers. Search to find others." : rows.length === 0 ? "No observers match this search." : "Search includes all matching observers in the selected region."} +

+ {options.isError && } +
+ ); +} + +function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) { + const [a, setA] = useState(initial?.observerA ?? ""); + const [b, setB] = useState(initial?.observerB ?? ""); + const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); + const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000)); + const [error, setError] = useState(null); + function submit(e: FormEvent) { + e.preventDefault(); + const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() }; + const message = validation(value); + setError(message); + if (!message) onCompare(value); + } + return ( +
+
+ + + + +
+ {error &&

{error}

} + +
+ ); +} + +export function CompareObserversTab() { + const { iatas, regionKey } = useRegion(); + const [params, setParams] = useSearchParams(); + const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; + const supplied = keys.some((key) => params.has(key)); + const parsed: Selection = { + observerA: params.get("compareA") ?? "", observerB: params.get("compareB") ?? "", + since: Number(params.get("compareSince")), until: Number(params.get("compareUntil")), + }; + const valid = keys.every((key) => params.getAll(key).length === 1 && params.get(key) !== "") && !validation(parsed); + const selection = valid ? parsed : null; + const result = useQuery({ + queryKey: ["observer-comparison", regionKey, selection], + queryFn: ({ signal }) => getObserverComparison(iatas, selection!, signal), + enabled: selection !== null, + retry: false, staleTime: 30_000, refetchOnWindowFocus: false, + }); + const observerA = useQuery({ + queryKey: ["observer", selection?.observerA], queryFn: () => getObserver(selection!.observerA), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + const observerB = useQuery({ + queryKey: ["observer", selection?.observerB], queryFn: () => getObserver(selection!.observerB), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + function compare(value: Selection) { + if (selection && Object.entries(value).every(([key, v]) => selection[key as keyof Selection] === v)) { + void result.refetch(); + return; + } + setParams((old) => { + const next = new URLSearchParams(old); + next.set("compareA", value.observerA); next.set("compareB", value.observerB); + next.set("compareSince", String(value.since)); next.set("compareUntil", String(value.until)); + return next; + }); + } + const data = result.data; + const groups = data ? [ + { name: "Only A", count: data.onlyA, color: "var(--color-primary)" }, + { name: "Both", count: data.both, color: "var(--color-green)" }, + { name: "Only B", count: data.onlyB, color: "var(--color-secondary)" }, + ] : []; + return ( +
+ +

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

+ {supplied && !valid &&

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

} + params.get(key)).join("|")} initial={selection} onCompare={compare} /> +
+ {selection && +

A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}

+

{new Date(selection.since).toLocaleString()} to {new Date(selection.until).toLocaleString()} (local time; end excluded). Region: {iatas?.join(", ") || "All"}.

+ {result.isFetching &&

Comparing reported packets…

} + {result.isError &&

{result.error.message}

} + {data && !result.isError && <> +

{data.totalPackets.toLocaleString()} distinct flood packets

+ {data.totalPackets === 0 ?

No flood packets were reported by either observer in this period and region.

: <> +
{groups.map((g) =>
)}
+ + + {groups.map((g) => )} +
Heard byPackets% of union
{g.name}{g.count.toLocaleString()}{(g.count / data.totalPackets * 100).toFixed(1)}%
+ } +

Percentages use the union of packets heard by either observer. Repeated receptions count once. These counts reflect retained reports, not radio packet loss; an offline observer, broker interruption or expired history can affect the result.

+ } + } +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 3159c2e9..3f03b2ca 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -6,10 +6,11 @@ import { MeshTab } from "./MeshTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; import { ObserverTab } from "./ObserverTab"; +import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -60,6 +61,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { )} {tab === "graph" && } + {tab === "compare" && }
); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index b4bc6cad..713b5a48 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -59,6 +59,7 @@ const TAB_OPTIONS = [ { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, + { value: "compare", label: "Compare observers", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; @@ -103,8 +104,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props /> )} - {/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */} - {tab !== "graph" && tab !== "clockdrift" && ( + {/* Comparison has explicit dates; graph and clock drift have no rolling window. */} + {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && ( = { diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts new file mode 100644 index 00000000..ed346029 --- /dev/null +++ b/tests/api/observer-comparison.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getObserverComparison } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("sends the complete comparison scope, including an epoch-zero start, and consumes cancellation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ totalPackets: 0 }) }); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getObserverComparison(["YVR", "YYJ"], { observerA: "a", observerB: "b", since: 0, until: 1000 }, controller.signal); + const [url, options] = fetcher.mock.calls[0]; + expect(new URL(url).pathname).toContain("/stats/observer-comparison"); + expect(Object.fromEntries(new URL(url).searchParams)).toEqual({ observerA: "a", observerB: "b", since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(options.signal).toBe(controller.signal); + controller.abort(); + expect(options.signal.aborted).toBe(true); +}); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx new file mode 100644 index 00000000..4b5b434a --- /dev/null +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { CompareObserversTab } from "../../../src/features/stats/CompareObserversTab"; +import { getObserverComparison, getObserversPage, getObserver } from "../../../src/api/client"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ + getObserverComparison: vi.fn(), getObserversPage: vi.fn(), getObserver: vi.fn(), +})); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => null })); + +const a = "11111111-1111-1111-1111-111111111111"; +const b = "22222222-2222-2222-2222-222222222222"; +const query = `?tab=Analytics&statsTab=compare&compareA=${a}&compareB=${b}&compareSince=1000&compareUntil=2000`; +const counts = { observerA: a, observerB: b, since: 1000, until: 2000, totalPackets: 4, onlyA: 1, onlyB: 2, both: 1 }; + +function Location() { return {useLocation().search}; } +function mount(url = "?statsTab=compare") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render(); +} + +beforeEach(() => { + vi.resetAllMocks(); + region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getObserversPage).mockResolvedValue({ items: [ + { id: a, displayName: "Rooftop", iata: "YVR", status: "online" }, + { id: b, displayName: "Hilltop", iata: "YVR", status: "online" }, + ], hasMore: false, nextCursor: null }); + vi.mocked(getObserver).mockImplementation(async (id) => ({ id, displayName: id === a ? "Rooftop" : "Hilltop", iata: "YVR", status: "online", publicKey: "00", firstSeen: 0, lastSeen: 0, observationCount: 0, brokers: [] })); + vi.mocked(getObserverComparison).mockResolvedValue(counts); +}); + +describe("observer comparison", () => { + it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + expect(getObserverComparison).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-02-15T00:00" } }); + expect(getObserverComparison).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], { + observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-02-15T00:00").getTime(), + }, expect.any(AbortSignal))); + expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`); + }); + + it("restores a shared comparison and shows disjoint counts and union percentages", async () => { + mount(query); + const table = await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(within(table).getByRole("row", { name: /Only A.*1.*25.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Only B.*2.*50.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Both.*1.*25.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/Percentages use the union/)).toBeInTheDocument(); + }); + + it("rejects equal observers without sending an expensive query", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: a } }); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + expect(screen.getByRole("alert")).toHaveTextContent(/different observers/); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); + + it("renders an empty period without NaN percentages", async () => { + vi.mocked(getObserverComparison).mockResolvedValue({ ...counts, totalPackets: 0, onlyA: 0, onlyB: 0, both: 0 }); + mount(query); + expect(await screen.findByText(/No flood packets were reported/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("NaN"); + }); + + it("searches the server beyond the initial observer page", async () => { + mount(); + fireEvent.change(screen.getByLabelText("Search Observer A"), { target: { value: "Distant station" } }); + await waitFor(() => expect(getObserversPage).toHaveBeenCalledWith(["YVR"], { name: "Distant station", limit: 50 })); + }); + + it("keeps applied result labels when the draft selection changes", async () => { + mount(query); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(await screen.findByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: b } }); + expect(screen.getByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + }); + + it("rejects malformed shared dates without issuing a comparison", async () => { + mount(query.replace("compareUntil=2000", "compareUntil=bad")); + expect(screen.getByRole("alert")).toHaveTextContent(/invalid or missing/); + await screen.findAllByRole("option", { name: /Rooftop/ }); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); + + it("shows query failures and retries only when requested", async () => { + vi.mocked(getObserverComparison).mockRejectedValueOnce(new Error("comparison timed out; try a shorter time period")); + mount(query); + expect(await screen.findByRole("alert")).toHaveTextContent(/shorter time period/); + fireEvent.click(screen.getByRole("button", { name: "Retry comparison" })); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(getObserverComparison).toHaveBeenCalledTimes(2); + }); +});