feat(stats): add Paths and Hashes analytics (#57)

This commit is contained in:
n30nex
2026-09-23 20:16:23 -04:00
committed by GitHub
parent 2405e1ac96
commit 929ba83cf1
12 changed files with 303 additions and 3 deletions
+5
View File
@@ -7,6 +7,7 @@ import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../featur
import type {
StatsOverview,
SignalStats,
PathStats,
ObserverComparison,
ObservationPoint,
PayloadBreakdownItem,
@@ -314,6 +315,10 @@ export function getSignalStats(since: number, until: number, iatas?: string[], s
return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal);
}
export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise<PathStats> {
return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal);
}
export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { Card, ChartCard, StatCard } from "./cards";
import { donutOption } from "./chartOptions";
import { tooltipStyle, useChartColors } from "./chartTheme";
import { pathHours, pathLengthOption, pathTrendOption } from "./paths";
import { usePathStats } from "./usePathStats";
import type { StatsRange } from "./types";
const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ");
export function PathsTab({ range }: { range: StatsRange }) {
const query = usePathStats(range), c = useChartColors();
const loading = query.isPending || query.isPlaceholderData;
const data = loading || query.isError ? undefined : query.data;
const hours = useMemo(() => pathHours(data), [data]);
const categories = useMemo(() => [
{ name: "Hash paths", value: data?.hashed ?? 0, color: c.primary },
{ name: "Empty", value: data?.empty ?? 0, color: c.textDim },
{ name: "Trace", value: data?.trace ?? 0, color: c.warn },
{ name: "Unclassified", value: data?.unclassified ?? 0, color: c.secondary },
], [data, c]);
const charts = useMemo(() => {
const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: `${bin.bytes}-byte`, value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), "HASH PATHS");
const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), "RECEPTIONS");
return {
width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below." } } },
coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside." } } },
lengths: pathLengthOption(data?.pathLengths ?? [], c), trend: pathTrendOption(hours, c),
};
}, [data, c, categories, hours]);
const state = { isLoading: loading, isError: query.isError };
const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0;
const largest = data?.pathLengths.at(-1)?.entries;
return <div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 className="text-lg font-semibold text-text-bright">Paths &amp; Hashes</h2><p className="text-sm text-text-muted">How received packets carry route hashes.</p></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">Refresh paths</button>
</div>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load path data. Try refreshing or choosing a shorter time period.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Reported receptions" value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={range} />
<StatCard label="With hash paths" value={data ? formatCount(data.hashed) : "—"} accent={c.green} />
<StatCard label="Multi-byte share" value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.secondary} />
<StatCard label="Most path entries" value={largest ?? "—"} accent={c.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.</p>
{data && <p className="text-xs text-text-muted">Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.</p>}
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Observed hash widths" option={charts.width} height={260} isEmpty={!data?.hashed} {...state} />
<ChartCard title="Received path entries" option={charts.lengths} height={260} isEmpty={!data || data.hashed + data.empty === 0} {...state} />
</div>
<ChartCard title="Hash-path receptions over time" option={charts.trend} height={260} isEmpty={!data?.hashed} {...state} />
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Path format coverage" option={charts.coverage} height={260} isEmpty={!data?.receptions} {...state} />
<Card title="What the counts include">
{!data ? <p className="py-4 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading paths…"}</p> : !data.receptions ? <p className="py-4 text-sm text-text-muted">No retained receptions in this window.</p> : <table aria-label="Path classification counts" className="w-full text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">Category</th><th scope="col" className="text-right">Receptions</th><th scope="col" className="text-right">Share</th></tr></thead>
<tbody>{categories.map((item) => <tr key={item.name} className="border-t border-border-subtle"><th scope="row" className="py-2 font-normal text-text-normal">{item.name}</th><td className="text-right text-text-bright">{item.value.toLocaleString()}</td><td className="text-right text-text-muted">{(100 * item.value / data.receptions).toFixed(1)}%</td></tr>)}</tbody>
</table>}
<p className="mt-3 text-xs leading-relaxed text-text-muted">Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.</p>
</Card>
</div>
{data && data.receptions > 0 && <details className="rounded-lg border border-border bg-bg-surface p-3.5">
<summary className="cursor-pointer text-sm font-semibold text-text-normal">Exact width, path-length and hourly values</summary>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<table aria-label="Hash width counts" className="w-full text-left font-mono text-xs"><thead className="text-text-muted"><tr><th scope="col" className="py-2">Bytes per hash</th><th scope="col" className="text-right">Receptions</th></tr></thead><tbody>{data.hashWidths.map((bin) => <tr key={bin.bytes} className="border-t border-border-subtle"><th scope="row" className="py-1.5 font-normal">{bin.bytes}</th><td className="text-right">{bin.receptions.toLocaleString()}</td></tr>)}</tbody></table>
<div className="max-h-[230px] overflow-auto"><table aria-label="Path entry counts" className="w-full text-left font-mono text-xs"><thead className="text-text-muted"><tr><th scope="col" className="py-2">Header entries</th><th scope="col" className="text-right">Receptions</th></tr></thead><tbody>{data.pathLengths.map((bin) => <tr key={bin.entries} className="border-t border-border-subtle"><th scope="row" className="py-1.5 font-normal">{bin.entries}</th><td className="text-right">{bin.receptions.toLocaleString()}</td></tr>)}</tbody></table></div>
</div>
<p className="my-3 text-xs text-text-muted">Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.</p>
<div className="max-h-[340px] overflow-auto"><table aria-label="Hourly path counts" className="w-full min-w-[650px] text-left font-mono text-xs"><thead className="text-text-muted"><tr>{["UTC hour", "Receptions", "1-byte", "2-byte", "3-byte", "Empty", "Trace", "Unclassified"].map((label) => <th key={label} scope="col" className="py-2">{label}</th>)}</tr></thead><tbody>{data.hourly.map((row) => <tr key={row.hour} className="border-t border-border-subtle"><th scope="row" className="py-2 font-normal">{utc(row.hour)}</th>{[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => <td key={i}>{n.toLocaleString()}</td>)}</tr>)}</tbody></table></div>
</details>}
</div>;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { TrafficTab } from "./TrafficTab";
import { SignalTab } from "./SignalTab";
import { PathsTab } from "./PathsTab";
import { ScopesTab } from "./ScopesTab";
import { TalkersTab } from "./TalkersTab";
import { ClockDriftTab } from "./ClockDriftTab";
@@ -13,7 +14,7 @@ import { CompareObserversTab } from "./CompareObserversTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";
const TABS: StatsTab[] = ["mesh", "traffic", "signal", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"];
const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "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 === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "traffic" && <TrafficTab range={range} />}
{tab === "signal" && <SignalTab range={range} />}
{tab === "paths" && <PathsTab range={range} />}
{tab === "scopes" && <ScopesTab />}
{tab === "talkers" && <TalkersTab range={range} />}
{tab === "clockdrift" && <ClockDriftTab />}
+8
View File
@@ -66,6 +66,13 @@ function ClockDriftIcon() {
);
}
function PathsIcon() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<circle cx="2.5" cy="3" r="1.5" /><circle cx="11.5" cy="11" r="1.5" />
<path d="M4 3h4a2 2 0 0 1 0 4H6a2 2 0 0 0 0 4h4" strokeLinecap="round" />
</svg>;
}
function GraphIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
@@ -83,6 +90,7 @@ const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "traffic", label: "Traffic", icon: <TrafficIcon /> },
{ value: "signal", label: "RF / Signal", icon: <SignalIcon /> },
{ value: "paths", label: "Paths & Hashes", icon: <PathsIcon /> },
{ value: "scopes", label: "Scopes", icon: <ScopesIcon /> },
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
+46
View File
@@ -0,0 +1,46 @@
import type { EChartsOption } from "echarts";
import type { PathLengthBin, PathStats } from "./types";
import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme";
const HOUR = 3_600_000;
export function pathHours(data: PathStats | undefined) {
if (!data || data.until <= data.since) return [];
const first = Math.floor(data.since / HOUR) * HOUR;
const rows = new Map(data.hourly.map((row) => [row.hour, row]));
return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => {
const hour = first + i * HOUR, row = rows.get(hour);
return { hour, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null };
});
}
export function pathLengthOption(bins: PathLengthBin[], c: ChartColors): EChartsOption {
const max = Math.min(63, Math.max(0, ...bins.map((bin) => bin.entries)));
const counts = new Map(bins.map((bin) => [bin.entries, bin.receptions]));
const entries = Array.from({ length: max + 1 }, (_, i) => i);
return {
animation: false,
aria: { enabled: true, label: { description: "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table." } },
grid: { left: 8, right: 12, top: 18, bottom: 38, containLabel: true },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) },
xAxis: { type: "category", data: entries, name: "Path entries", nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } },
yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } },
series: [{ name: "Receptions", type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }],
};
}
export function pathTrendOption(hours: ReturnType<typeof pathHours>, c: ChartColors): EChartsOption {
return {
animation: false, useUTC: true,
aria: { enabled: true, label: { description: "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." } },
legend: { top: 0, textStyle: { color: c.textNormal, fontSize: 10 }, itemWidth: 10, itemHeight: 10 },
grid: { left: 8, right: 18, top: 35, bottom: 15, containLabel: true },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) },
xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } },
yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } },
series: (["oneByte", "twoByte", "threeByte"] as const).map((field, i) => ({
name: `${i + 1}-byte`, type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4,
lineStyle: { width: 1.5, color: c.series[i] }, itemStyle: { color: c.series[i] }, areaStyle: { color: withAlpha(c.series[i]!, 0.32) },
data: hours.map((hour) => [hour.hour, hour[field]]),
})),
};
}
+27 -1
View File
@@ -153,7 +153,7 @@ export interface ObserverActivity {
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "traffic" | "signal" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
@@ -181,3 +181,29 @@ export interface SignalStats {
rssi: SignalMetric;
hourly: SignalHour[];
}
// beacon-server /stats/paths: retained receptions; the four categories partition the total.
export interface PathHashWidth { bytes: number; receptions: number }
export interface PathLengthBin { entries: number; receptions: number }
export interface PathHour {
hour: number;
receptions: number;
oneByte: number;
twoByte: number;
threeByte: number;
empty: number;
trace: number;
unclassified: number;
}
export interface PathStats {
since: number;
until: number;
receptions: number;
hashed: number;
empty: number;
trace: number;
unclassified: number;
hashWidths: PathHashWidth[];
pathLengths: PathLengthBin[];
hourly: PathHour[];
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { getPathStats } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { RANGE_MS, type StatsRange } from "./types";
export function usePathStats(range: StatsRange) {
const { iatas, regionKey, isResolved } = useRegion();
return useQuery({
queryKey: ["stats-paths", isResolved === false ? `${regionKey}:pending` : regionKey, range],
enabled: isResolved !== false,
queryFn: ({ signal }) => {
if (isResolved === false) throw new Error("Selected region is not available yet");
// Shared minute boundaries let viewers reuse server aggregates without changing the key every render.
const until = Math.floor(Date.now() / 60_000) * 60_000;
return getPathStats(until - RANGE_MS[range], until, iatas, signal);
},
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
+12 -1
View File
@@ -1,8 +1,19 @@
import { afterEach, expect, it, vi } from "vitest";
import { getStatsObservations, getStatsScopes, getSignalStats } from "../../src/api/client";
import { getStatsObservations, getStatsScopes, getSignalStats, getPathStats } from "../../src/api/client";
afterEach(() => vi.unstubAllGlobals());
it("sends path window bounds, regional filters and cancellation", async () => {
const fetcher = vi.fn<typeof fetch>(async () => ({ ok: true, json: async () => ({}) }) as Response);
vi.stubGlobal("fetch", fetcher);
const controller = new AbortController();
await getPathStats(0, 1000, ["YVR", "YYJ"], controller.signal);
const url = new URL(fetcher.mock.calls[0]![0] as string);
expect(url.pathname).toContain("/stats/paths");
expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" });
expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal });
});
it("forwards region and cancellation to both aggregate endpoints, omitting an empty global filter", async () => {
const fetcher = vi.fn<typeof fetch>(async () => ({ ok: true, json: async () => [] }) as Response);
vi.stubGlobal("fetch", fetcher);
+29
View File
@@ -0,0 +1,29 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { PathsTab } from "../../../src/features/stats/PathsTab";
import { usePathStats } from "../../../src/features/stats/usePathStats";
const query = { data: { since: 0, until: 3600000, receptions: 100, hashed: 60, empty: 20, trace: 10, unclassified: 10,
hashWidths: [{ bytes: 1, receptions: 20 }, { bytes: 2, receptions: 30 }, { bytes: 3, receptions: 10 }], pathLengths: [{ entries: 0, receptions: 20 }, { entries: 2, receptions: 60 }],
hourly: [{ hour: 0, receptions: 100, oneByte: 20, twoByte: 30, threeByte: 10, empty: 20, trace: 10, unclassified: 10 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() };
vi.mock("../../../src/features/stats/usePathStats", () => ({ usePathStats: vi.fn(() => query) }));
vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => <div data-testid="chart" /> }));
beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); });
it("uses only nonempty hash paths for multi-byte share, exposes categories and explains remaining routes", () => {
render(<PathsTab range="24h" />);
expect(usePathStats).toHaveBeenCalledWith("24h");
expect(screen.getByText("66.7%")).toBeInTheDocument();
expect(screen.getByText(/direct routes/i)).toHaveTextContent("remaining");
const table = screen.getByRole("table", { name: "Path classification counts" });
expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument();
expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh paths" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => {
query[state] = true; render(<PathsTab range="7d" />);
expect(screen.queryByText("66.7%")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter");
});
@@ -6,10 +6,20 @@ import type { WsManager } from "../../../src/api/ws-manager";
vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) => <p>Traffic range {range}</p> }));
vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) => <p>Signal range {range}</p> }));
vi.mock("../../../src/features/stats/PathsTab", () => ({ PathsTab: ({ range }: { range: string }) => <p>Paths range {range}</p> }));
vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () => <p>Mesh charts</p> }));
vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () => <p>Scope charts</p> }));
function Location() { return <output aria-label="Analytics URL">{useLocation().search}</output>; }
it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=paths&range=24h&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
expect(screen.getByText("Paths range 24h")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Paths & Hashes" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "30d" }));
expect(screen.getByText("Paths range 30d")).toBeInTheDocument();
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW");
});
it("opens Signal from a shared URL and retains regional state when changing range", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=24h&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
expect(screen.getByText("Signal range 24h")).toBeInTheDocument();
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { usePathStats } from "../../../src/features/stats/usePathStats";
import { getPathStats } from "../../../src/api/client";
import type { StatsRange } from "../../../src/features/stats/types";
const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/api/client", () => ({ getPathStats: vi.fn(() => new Promise(() => {})) }));
afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; });
it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => {
region.isResolved = false;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => usePathStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
expect(result.current.isPending).toBe(true);
expect(getPathStats).not.toHaveBeenCalled();
region.isResolved = true; rerender({ range: "24h" });
await waitFor(() => expect(getPathStats).toHaveBeenCalledOnce());
const first = vi.mocked(getPathStats).mock.calls[0]!;
expect(first[1] % 60_000).toBe(0);
expect(first[1] - first[0]).toBe(24 * 3_600_000);
expect(first[2]).toEqual(["YVR"]);
region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" });
await waitFor(() => expect(getPathStats).toHaveBeenCalledTimes(2));
expect(first[3]?.aborted).toBe(true);
const second = vi.mocked(getPathStats).mock.calls[1]!;
expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000);
expect(second[2]).toEqual(["YOW"]);
unmount(); expect(second[3]?.aborted).toBe(true); client.clear();
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { pathHours, pathLengthOption, pathTrendOption } from "../../../src/features/stats/paths";
import { readChartColors } from "../../../src/features/stats/chartTheme";
import type { PathStats } from "../../../src/features/stats/types";
const hour = 3_600_000;
const fixture: PathStats = { since: hour / 2, until: 3 * hour, receptions: 10, hashed: 6, empty: 2, trace: 1, unclassified: 1,
hashWidths: [{ bytes: 1, receptions: 2 }, { bytes: 2, receptions: 3 }, { bytes: 3, receptions: 1 }],
pathLengths: [{ entries: 0, receptions: 2 }, { entries: 2, receptions: 5 }, { entries: 63, receptions: 1 }],
hourly: [{ hour: 0, receptions: 8, oneByte: 2, twoByte: 3, threeByte: 1, empty: 1, trace: 1, unclassified: 0 }, { hour: 2 * hour, receptions: 2, oneByte: 0, twoByte: 0, threeByte: 0, empty: 1, trace: 0, unclassified: 1 }] };
describe("path analytics charts", () => {
it("keeps absent hours distinct from known zero hash-path hours and preserves response values", () => {
const before = JSON.stringify(fixture), hours = pathHours(fixture);
expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour]);
expect(hours.map((h) => h.oneByte)).toEqual([2, null, 0]);
expect(hours.map((h) => h.twoByte)).toEqual([3, null, 0]);
expect(JSON.stringify(fixture)).toBe(before);
expect(pathHours(undefined)).toEqual([]);
expect(pathHours({ ...fixture, until: 30 * 24 * hour + hour / 2 })).toHaveLength(721);
});
it("fills zero-count length bins while preserving empty-path counts and visible single-hour points", () => {
const c = readChartColors();
const length = pathLengthOption(fixture.pathLengths, c);
expect(length).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } });
const option = length as { xAxis: { data: number[] }; series: { data: { value: number }[] }[] };
expect(option.xAxis.data).toHaveLength(64);
expect(option.series[0]!.data[0]!.value).toBe(2);
expect(option.series[0]!.data[1]!.value).toBe(0);
expect(option.series[0]!.data.reduce((n, d) => n + d.value, 0)).toBe(8);
expect(pathTrendOption(pathHours(fixture), c)).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] });
});
});