feat(stats): compare flood packets heard by two observers (#48)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-19 22:58:36 -04:00
1 parent d5d9c0870c
commit 88e2c0f319
7 files changed
+321 -6

No files matched your search

+11 -2
View File
@@ -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<ApiError> {
return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs);
}
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
async function request<T>(path: string, params?: Record<string, string | number | undefined>, signal?: AbortSignal): Promise<T> {
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<T>(path: string, params?: Record<string, string | number
}
}
const res = await fetch(url.toString());
const res = await (signal ? fetch(url.toString(), { signal }) : fetch(url.toString()));
if (!res.ok) throw await errorFrom(res);
noteRequestOk();
@@ -67,6 +68,14 @@ async function request<T>(path: string, params?: Record<string, string | number
// endpoint functions
export function getObserverComparison(
iatas: string[] | undefined,
params: { observerA: string; observerB: string; since: number; until: number },
signal?: AbortSignal,
): Promise<ObserverComparison> {
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;
+163
View File
@@ -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 (
<fieldset className="min-w-0 space-y-2">
<legend className="mb-2 text-sm text-text-muted">{label}</legend>
<input type="search" aria-label={`Search ${label}`} placeholder="Search all observers by name…"
value={search} onChange={(e) => setSearch(e.target.value)} className={fieldClass} />
<select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className={fieldClass}>
<option value="">Choose an observer</option>
{value && !rows.some((o) => o.id === value) && <option value={value}>{selected.data?.displayName ?? value}</option>}
{rows.map((o) => <option key={o.id} value={o.id}>{o.displayName ?? o.id.slice(0, 8)} · {o.iata} · {o.id.slice(0, 8)}</option>)}
</select>
<p className="text-xs text-text-dim" role="status">
{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."}
</p>
{options.isError && <button type="button" onClick={() => void options.refetch()} className="text-sm text-primary">Retry observer search</button>}
</fieldset>
);
}
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<string | null>(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 (
<form onSubmit={submit} className="space-y-4" noValidate>
<div className="grid gap-4 md:grid-cols-2">
<ObserverSelect label="Observer A" value={a} onChange={setA} />
<ObserverSelect label="Observer B" value={b} onChange={setB} />
<label className="min-w-0 space-y-2 text-sm text-text-muted"><span>Start (local time)</span>
<input type="datetime-local" step="0.001" value={since} onChange={(e) => setSince(e.target.value)} className={fieldClass} />
</label>
<label className="min-w-0 space-y-2 text-sm text-text-muted"><span>End (local time)</span>
<input type="datetime-local" step="0.001" value={until} onChange={(e) => setUntil(e.target.value)} className={fieldClass} />
</label>
</div>
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
<button type="submit" className="rounded border border-primary-dim bg-primary/10 px-4 py-2 text-sm font-semibold text-primary">Compare</button>
</form>
);
}
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 (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4">
<Card title="Compare observers">
<p className="mb-4 text-sm text-text-muted">Compare distinct flood packets reported by two observers over any retained time period.</p>
{supplied && !valid && <p role="alert" className="mb-3 text-sm text-danger">This comparison link has invalid or missing values. Choose observers and dates below.</p>}
<ComparisonForm key={keys.map((key) => params.get(key)).join("|")} initial={selection} onCompare={compare} />
</Card>
{selection && <Card title="Reported flood packets">
<p className="mb-2 break-words text-sm text-text-normal">A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}</p>
<p className="mb-3 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} to {new Date(selection.until).toLocaleString()} (local time; end excluded). Region: {iatas?.join(", ") || "All"}.</p>
{result.isFetching && <p role="status" className="text-sm text-text-muted">Comparing reported packets…</p>}
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">Retry comparison</button></div>}
{data && !result.isError && <>
<p className="mb-3 text-lg font-semibold text-text-bright">{data.totalPackets.toLocaleString()} distinct flood packets</p>
{data.totalPackets === 0 ? <p className="text-sm text-text-muted">No flood packets were reported by either observer in this period and region.</p> : <>
<div aria-hidden className="mb-4 flex h-5 overflow-hidden rounded">{groups.map((g) => <div key={g.name} style={{ width: `${g.count / data.totalPackets * 100}%`, background: g.color }} />)}</div>
<table className="w-full text-left text-sm tabular-nums" aria-label="Flood packet comparison">
<thead className="text-text-muted"><tr><th scope="col">Heard by</th><th scope="col" className="text-right">Packets</th><th scope="col" className="text-right">% of union</th></tr></thead>
<tbody>{groups.map((g) => <tr key={g.name} className="border-t border-border"><th scope="row" className="py-2 font-normal text-text-normal"><span aria-hidden className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: g.color }} />{g.name}</th><td className="text-right">{g.count.toLocaleString()}</td><td className="text-right">{(g.count / data.totalPackets * 100).toFixed(1)}%</td></tr>)}</tbody>
</table>
</>}
<p className="mt-4 text-xs text-text-dim">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.</p>
</>}
</Card>}
</div>
);
}
+3 -1
View File
@@ -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) {
<ObserverTab range={range} selectedObserverId={observerId} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
)}
{tab === "graph" && <NeighbourGraphTab />}
{tab === "compare" && <CompareObserversTab />}
</div>
</div>
);
+3 -2
View File
@@ -59,6 +59,7 @@ const TAB_OPTIONS = [
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
{ value: "compare", label: "Compare observers", icon: <ObserverIcon /> },
{ value: "graph", label: "Neighbour Graph", icon: <GraphIcon /> },
];
@@ -103,8 +104,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
/>
</div>
)}
{/* 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" && (
<Segmented
className="shrink-0"
options={RANGE_OPTIONS}
+14 -1
View File
@@ -3,6 +3,19 @@
import type { NodeIATA } from "../nodes/types";
// beacon-server /stats/observer-comparison: disjoint groups of distinct flood
// packet hashes, selected by reception time in [since, until).
export interface ObserverComparison {
observerA: string;
observerB: string;
since: number;
until: number;
totalPackets: number;
onlyA: number;
onlyB: number;
both: number;
}
export interface StatsOverview {
totalPackets: number;
totalObservations: number;
@@ -140,7 +153,7 @@ export interface ObserverActivity {
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "graph";
export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
+17
View File
@@ -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);
});
@@ -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 <output aria-label="Current URL">{useLocation().search}</output>; }
function mount(url = "?statsTab=compare") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><CompareObserversTab /><Location /></MemoryRouter></QueryClientProvider>);
}
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);
});
});