mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-16 05:00:06 +00:00
feat(stats): add Paths and Hashes analytics (#57)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 & 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>;
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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]]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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]] }] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user