Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../featur
import type {
StatsOverview,
SignalStats,
PathStats,
ObserverComparison,
ObservationPoint,
PayloadBreakdownItem,
Expand Down Expand Up @@ -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 });
}
Expand Down
75 changes: 75 additions & 0 deletions src/features/stats/PathsTab.tsx
Original file line number Diff line number Diff line change
@@ -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>;
}
4 changes: 3 additions & 1 deletion src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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 />}
Expand Down
8 changes: 8 additions & 0 deletions src/features/stats/StatsSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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 /> },
Expand Down
46 changes: 46 additions & 0 deletions src/features/stats/paths.ts
Original file line number Diff line number Diff line change
@@ -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]]),
})),
};
}
28 changes: 27 additions & 1 deletion src/features/stats/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand Down Expand Up @@ -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 changes: 22 additions & 0 deletions src/features/stats/usePathStats.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
13 changes: 12 additions & 1 deletion tests/api/analytics-client.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Loading
Loading