feat(stats): add hourly traffic and region-aware scope analytics (#53)

Adds the Traffic and Scopes analytics tabs. Includes the hourly traffic work from #52, which this PR contains in full.

Closes #50. Closes #51.
This commit is contained in:
n30nex
2026-09-20 11:41:43 -04:00
committed by GitHub
parent 3178713bdb
commit e01c090c8e
21 changed files with 534 additions and 20 deletions
+1 -1
View File
@@ -308,7 +308,7 @@ function AppInner() {
/>
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="relative flex flex-1 min-h-0">
<div key={activeTab} className="flex flex-1 min-h-0 fade-in">
<div key={activeTab} className="flex flex-1 min-h-0 min-w-0 fade-in">
<Suspense fallback={<EmptyState title={activeTab} subtitle="Loading…" />}>
{tabContent[activeTab]}
</Suspense>
+5 -5
View File
@@ -305,8 +305,8 @@ export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
}
export function getStatsObservations(iatas?: string[], since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since });
export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal);
}
export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
@@ -344,9 +344,9 @@ export function getClockDrift(iatas?: string[], limit = 100): Promise<ClockDrift
}
// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
// aggregate (packet/observer/node counts), reported globally regardless of the active region.
export function getStatsScopes(): Promise<ScopeStats[]> {
return request("/stats/scopes");
// aggregate (packet/observer/node counts), filtered by the selected IATAs.
export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise<ScopeStats[]> {
return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal);
}
export function getObserverTelemetry(
+2 -2
View File
@@ -143,10 +143,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
<ChartCard title="Node types · all time" height={208} option={typesOption} isLoading={nodeTypes.isLoading} isError={nodeTypes.isError} isEmpty={typeRows.length === 0} />
<ChartCard title="Radio presets · all time" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
<Card title={<>Scopes · all regions · all time</>}>
<Card title={<>Scopes · selected region · retained data</>}>
{scopes.isError ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Failed to load</div>
) : scopes.isLoading ? (
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Loading…</div>
) : scopeRows.length === 0 ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">No data</div>
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useState } from "react";
import { useScopes } from "./useStats";
import { useChartColors } from "./chartTheme";
import { Card, ChartCard, StatCard } from "./cards";
import { scopeChartOption, scopeSummary } from "./scopes";
import { formatCount } from "../../lib/formatters";
export function ScopesTab() {
const query = useScopes();
const colors = useChartColors();
const [search, setSearch] = useState("");
const loading = query.isPending || query.isLoading || query.isPlaceholderData;
const unavailable = loading || query.isError;
const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]);
const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]);
const totals = useMemo(() => scopeSummary(rows), [rows]);
const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors), [rows, colors]);
const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors), [rows, colors]);
const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors), [rows, colors]);
const value = (number: number) => unavailable ? "—" : formatCount(number);
const height = Math.max(180, Math.min(13, rows.length) * 28 + 16);
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">Transport scopes</h2><p className="text-sm text-text-muted">Retained data and memberships for the selected region.</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 scopes</button>
</div>
<label className="flex max-w-sm flex-col gap-1 text-xs text-text-muted">Find a scope
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="#bc, #east…" className="rounded border border-border bg-bg-raised px-3 py-2 text-base text-text-bright outline-none focus:border-primary sm:text-sm" />
</label>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load scopes. Try refreshing.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Scopes with data" value={value(totals.active)} accent={colors.secondary} />
<StatCard label="Scoped packets" value={value(totals.packets)} accent={colors.primary} />
<StatCard label="Observer memberships" value={value(totals.memberships)} accent={colors.green} />
<StatCard label="Default-scope nodes" value={value(totals.nodes)} accent={colors.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.</p>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Packets by scope" option={packets} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.packets} />
<ChartCard title="Observer memberships by scope" option={observers} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.memberships} />
<ChartCard title="Default-scope nodes" option={nodes} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.nodes} />
<Card title="Scope counts" right={<span className="text-[10px] text-text-muted">{unavailable ? "—" : `${rows.length} of ${all.length} scopes`}</span>}>
{unavailable ? <p className="py-6 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading scopes…"}</p> : !rows.length ? <p className="py-6 text-sm text-text-muted">{search ? "No scopes match this search." : "No scope data available."}</p> : (
<div className="max-h-[390px] overflow-auto"><table aria-label="Scope counts" className="w-full text-left font-mono text-[11px]">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">Scope</th><th scope="col" className="text-right">Packets</th><th scope="col" className="text-right">Observers</th><th scope="col" className="text-right">Nodes</th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.name} className="border-t border-border-subtle">
<th scope="row" className="max-w-32 break-all py-2 pr-2 font-normal text-text-normal">{row.name}</th>
<td className="text-right tabular-nums text-text-bright">{row.packetCount.toLocaleString()}</td><td className="text-right tabular-nums text-text-normal">{row.observerCount.toLocaleString()}</td><td className="text-right tabular-nums text-text-normal">{row.nodeCount.toLocaleString()}</td>
</tr>)}</tbody>
</table></div>
)}
<p className="mt-2 text-[11px] text-text-muted">Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.</p>
</Card>
</div>
</div>
);
}
+6 -2
View File
@@ -3,6 +3,8 @@ import { useSearchParams } from "react-router-dom";
import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { TrafficTab } from "./TrafficTab";
import { ScopesTab } from "./ScopesTab";
import { TalkersTab } from "./TalkersTab";
import { ClockDriftTab } from "./ClockDriftTab";
import { ObserverTab } from "./ObserverTab";
@@ -10,7 +12,7 @@ import { CompareObserversTab } from "./CompareObserversTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";
const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"];
const TABS: StatsTab[] = ["mesh", "traffic", "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");
@@ -51,10 +53,12 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} />
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "traffic" && <TrafficTab range={range} />}
{tab === "scopes" && <ScopesTab />}
{tab === "talkers" && <TalkersTab range={range} />}
{tab === "clockdrift" && <ClockDriftTab />}
{tab === "observer" && (
+3 -1
View File
@@ -56,6 +56,8 @@ function GraphIcon() {
const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "traffic", label: "Traffic", icon: <MeshIcon /> },
{ value: "scopes", label: "Scopes", icon: <GraphIcon /> },
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
@@ -105,7 +107,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
</div>
)}
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */}
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && (
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && (
<Segmented
className="shrink-0"
options={RANGE_OPTIONS}
+57
View File
@@ -0,0 +1,57 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useStatsObservations } from "./useStats";
import { tooltipStyle, useChartColors } from "./chartTheme";
import { donutOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "./traffic";
import type { StatsRange } from "./types";
export function TrafficTab({ range }: { range: StatsRange }) {
const query = useStatsObservations(range);
const colors = useChartColors();
const loading = query.isPending || query.isLoading || query.isPlaceholderData;
const unavailable = loading || query.isError;
const model = useMemo(() => trafficModel(unavailable ? [] : (query.data ?? []), range, query.dataUpdatedAt), [query.data, query.dataUpdatedAt, range, unavailable]);
const trend = useMemo(() => trafficTrendOption(model, colors), [model, colors]);
const heatmap = useMemo(() => trafficHeatmapOption(model, colors), [model, colors]);
const share = useMemo(() => {
const option = donutOption(model.series.map((area, i) => ({ name: area.name, value: area.total, color: colors.series[i] })), colors, formatCount(model.total), "RECEPTIONS");
return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: "Reception share by IATA. Exact counts and percentages follow in the table." } } };
}, [model, colors]);
const value = (number: number) => unavailable ? "—" : formatCount(number);
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">Traffic</h2><p className="text-sm text-text-muted">When and where observers report receptions.</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 traffic</button>
</div>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load traffic. Try refreshing.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Reported receptions" value={value(model.total)} accent={colors.primary} sublabel={range} />
<StatCard label="Reporting IATAs" value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} />
<StatCard label="Busiest hour" value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} />
<StatCard label="Hours with records" value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.</p>
<ChartCard title="Reception trend by IATA" right={<span className="text-[10px] text-text-muted">Available hourly records</span>} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<ChartCard title={`Hourly activity · ${range}`} option={heatmap} height={Math.max(150, model.days.length * 15 + 72)} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Reception share" option={share} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<Card title="Receptions by IATA">
{unavailable ? <p className="py-6 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading traffic…"}</p> : !model.areas.length ? <p className="py-6 text-sm text-text-muted">No retained receptions in this window.</p> : (
<div className="max-h-[300px] overflow-y-auto"><table aria-label="Receptions by IATA" className="w-full text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">IATA</th><th scope="col" className="text-right">Receptions</th><th scope="col" className="text-right">Share</th></tr></thead>
<tbody>{model.areas.map((area, i) => <tr key={area.name} className="border-t border-border-subtle">
<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: colors.series[Math.min(i, 7)] }} />{area.name}</th>
<td className="text-right tabular-nums text-text-bright">{area.total.toLocaleString()}</td><td className="text-right tabular-nums text-text-muted">{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%</td>
</tr>)}</tbody>
</table></div>
)}
<p className="mt-2 text-[11px] text-text-muted">Charts group smaller areas into “Other IATAs”; this table keeps every area.</p>
</Card>
</div>
</div>
);
}
+6 -1
View File
@@ -3,7 +3,7 @@
// the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps
// the core API directly instead.
import * as echarts from "echarts/core";
import { LineChart, BarChart, PieChart, GaugeChart, GraphChart } from "echarts/charts";
import { LineChart, BarChart, PieChart, GaugeChart, GraphChart, HeatmapChart } from "echarts/charts";
import {
GridComponent,
TitleComponent,
@@ -12,6 +12,8 @@ import {
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent,
AriaComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
@@ -21,6 +23,7 @@ echarts.use([
PieChart,
GaugeChart,
GraphChart,
HeatmapChart,
GridComponent,
TitleComponent,
TooltipComponent,
@@ -28,6 +31,8 @@ echarts.use([
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent,
AriaComponent,
CanvasRenderer,
]);
+25
View File
@@ -0,0 +1,25 @@
import type { ScopeStats } from "./types";
import { leaderboardOption } from "./chartOptions";
import { tooltipStyle, type ChartColors } from "./chartTheme";
import type { EChartsOption } from "./echarts-setup";
export type ScopeMetric = "packetCount" | "observerCount" | "nodeCount";
export function scopeSummary(rows: ScopeStats[]) {
return rows.reduce((summary, row) => ({
active: summary.active + (row.packetCount || row.observerCount || row.nodeCount ? 1 : 0),
packets: summary.packets + row.packetCount,
memberships: summary.memberships + row.observerCount,
nodes: summary.nodes + row.nodeCount,
}), { active: 0, packets: 0, memberships: 0, nodes: 0 });
}
export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors): EChartsOption {
const indices = new Map(rows.map((row) => row.name).sort().map((name, index) => [name, index]));
const ranked = rows.map((row) => ({ name: row.name, value: row[metric], color: colors.series[(indices.get(row.name) ?? 0) % colors.series.length] ?? colors.primary }))
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name));
const shown = ranked.slice(0, 12);
if (ranked.length > 12) shown.push({ name: "Other scopes", value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim });
return { ...leaderboardOption(shown, colors, 126), tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) },
aria: { enabled: true, label: { description: "Counts by transport scope. Exact values for all matching scopes are listed in the table." } } };
}
+90
View File
@@ -0,0 +1,90 @@
import type { EChartsOption } from "./echarts-setup";
import { tooltipStyle, type ChartColors } from "./chartTheme";
import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types";
const HOUR = 3_600_000;
const MONO = "JetBrains Mono, monospace";
export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf: number) {
const end = Math.floor(asOf / HOUR) * HOUR;
const length = RANGE_MS[range] / HOUR;
const start = end - (length - 1) * HOUR;
const buckets = new Map<number, Map<string, number>>();
const totals = new Map<string, number>();
for (const point of points) {
if (!Number.isFinite(point.hour) || point.hour < start || point.hour > end || !Number.isFinite(point.observationCount) || point.observationCount < 0) continue;
const hour = Math.floor(point.hour / HOUR) * HOUR;
const name = point.iata.trim().toUpperCase() || "Unassigned";
const bucket = buckets.get(hour) ?? new Map<string, number>();
bucket.set(name, (bucket.get(name) ?? 0) + point.observationCount);
buckets.set(hour, bucket);
totals.set(name, (totals.get(name) ?? 0) + point.observationCount);
}
const areas = [...totals].map(([name, total]) => ({ name, total })).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
const names = areas.slice(0, 7).map((area) => area.name);
const hours = Array.from({ length }, (_, i) => {
const hour = start + i * HOUR;
const bucket = buckets.get(hour);
return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null };
});
const series = names.map((name) => ({
name, total: totals.get(name) ?? 0,
values: hours.map(({ hour }) => buckets.has(hour) ? (buckets.get(hour)?.get(name) ?? 0) : null),
}));
if (areas.length > names.length) {
series.push({ name: "Other IATAs", total: areas.slice(7).reduce((sum, area) => sum + area.total, 0),
values: hours.map(({ hour, total }) => total === null ? null : total - names.reduce((sum, name) => sum + (buckets.get(hour)?.get(name) ?? 0), 0)) });
}
const days = [...new Set(hours.map(({ hour }) => new Date(hour).toISOString().slice(0, 10)))];
const heatmap: number[][] = [];
let peak: { hour: number; total: number } | null = null;
for (const { hour, total } of hours) {
if (total === null) continue;
heatmap.push([new Date(hour).getUTCHours(), days.indexOf(new Date(hour).toISOString().slice(0, 10)), total]);
if (!peak || total > peak.total) peak = { hour, total };
}
return { hours, days, heatmap, peak, areas, series, reportedHours: buckets.size, total: areas.reduce((sum, area) => sum + area.total, 0) };
}
export type TrafficModel = ReturnType<typeof trafficModel>;
export function trafficTrendOption(model: TrafficModel, c: ChartColors): EChartsOption {
const present = model.hours.filter((hour) => hour.total !== null);
const first = present[0]?.hour;
const last = present.at(-1)?.hour;
return {
animation: false, useUTC: true, backgroundColor: "transparent",
aria: { enabled: true, label: { description: "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table." } },
grid: { left: 54, right: 18, top: 42, bottom: 28 },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) },
legend: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: c.textNormal, fontSize: 10 }, inactiveColor: c.textDim },
xAxis: { type: "time", min: first === last && first !== undefined ? first - HOUR / 2 : first, max: first === last && last !== undefined ? last + HOUR / 2 : last,
axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } }, splitLine: { show: false } },
yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.border, opacity: 0.4 } } },
series: model.series.map((series, index) => ({
name: series.name, type: "line", stack: "receptions", connectNulls: false, smooth: false,
symbol: "circle", symbolSize: 5, showSymbol: present.length <= 2,
data: model.hours.map((hour, i) => [hour.hour, series.values[i]]),
lineStyle: { color: c.series[index], width: 1.5 }, itemStyle: { color: c.series[index] }, areaStyle: { color: c.series[index], opacity: 0.45 },
})),
};
}
export function trafficHeatmapOption(model: TrafficModel, c: ChartColors): EChartsOption {
return {
animation: false, backgroundColor: "transparent",
aria: { enabled: true, label: { description: "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record." } },
grid: { left: 58, right: 14, top: 8, bottom: 62 },
tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(c), formatter: (item: { value: unknown }) => {
const value = item.value as [number, number, number];
return `${model.days[value[1]]} ${String(value[0]).padStart(2, "0")}:00 UTC\n${value[2].toLocaleString()} receptions`;
} },
xAxis: { type: "category", data: Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0")),
axisLabel: { color: c.textMuted, fontSize: 9, interval: 2 }, axisTick: { show: false }, axisLine: { show: false }, splitArea: { show: true, areaStyle: { color: [c.bgRaised, c.bgSurface] } } },
yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false },
axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } },
visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130,
text: ["More", "Fewer"], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } },
series: [{ name: "Receptions", type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }],
};
}
+1 -1
View File
@@ -153,7 +153,7 @@ export interface ObserverActivity {
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsTab = "mesh" | "traffic" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
+16 -6
View File
@@ -37,10 +37,14 @@ export function useStatsOverview() {
}
export function useStatsObservations(range: StatsRange) {
const { iatas, regionKey } = useRegion();
const { iatas, regionKey, isResolved } = useRegion();
return useQuery({
queryKey: ["stats-observations", regionKey, range],
queryFn: () => getStatsObservations(iatas, sinceFor(range)),
queryKey: ["stats-observations", isResolved === false ? `${regionKey}:pending` : regionKey, range],
enabled: isResolved !== false,
queryFn: ({ signal }) => {
if (isResolved === false) throw new Error("Selected region is not available yet");
return getStatsObservations(iatas, sinceFor(range), signal);
},
...common,
// feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh
refetchInterval: 60_000,
@@ -121,11 +125,17 @@ export function useClockDrift(limit = 100) {
});
}
// scopes are reported globally by the backend (no region filter), so the key is region-independent
// Scope counts have no time window, but the selected region changes their membership.
export function useScopes() {
const { iatas, regionKey, isResolved } = useRegion();
return useQuery({
queryKey: ["stats-scopes"],
queryFn: getStatsScopes,
queryKey: ["stats-scopes", isResolved === false ? `${regionKey}:pending` : regionKey],
enabled: isResolved !== false,
queryFn: ({ signal }) => {
if (isResolved === false) throw new Error("Selected region is not available yet");
return getStatsScopes(iatas, signal);
},
...common,
refetchInterval: 60_000,
});
}
+2 -1
View File
@@ -80,6 +80,7 @@ export function useRegions(): RegionsData {
export interface RegionFilter {
iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions
regionKey: string; // stable query-key fragment ("*" = all)
isResolved?: boolean; // false while a selected region's member IATAs are unavailable
}
// The resolved geographic filter consumers pass to queries: the flattened IATA list plus a stable key.
@@ -89,6 +90,6 @@ export function useRegion(): RegionFilter {
return useMemo(() => {
const iatas = resolveIatas(selection, regionIatas);
return { iatas, regionKey: toRegionKey(iatas) };
return { iatas, regionKey: toRegionKey(iatas), isResolved: selection.regions.every((slug) => regionIatas.has(slug)) };
}, [selection, regionIatas]);
}
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, expect, it, vi } from "vitest";
import { getStatsObservations, getStatsScopes } from "../../src/api/client";
afterEach(() => vi.unstubAllGlobals());
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);
const controller = new AbortController();
await getStatsScopes(["YVR", "YOW"], controller.signal);
expect(new URL(fetcher.mock.calls[0]![0] as string).searchParams.get("iatas")).toBe("YVR,YOW");
expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal });
await getStatsScopes([]);
expect(new URL(fetcher.mock.calls[1]![0] as string).searchParams.has("iatas")).toBe(false);
await getStatsObservations(["YOW"], 1234, controller.signal);
const url = new URL(fetcher.mock.calls[2]![0] as string);
expect(url.pathname).toContain("/stats/observations");
expect(url.searchParams.get("since")).toBe("1234");
expect(url.searchParams.get("iatas")).toBe("YOW");
});
+34
View File
@@ -0,0 +1,34 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { ScopesTab } from "../../../src/features/stats/ScopesTab";
const query = { data: [
{ name: "#west", packetCount: 3, observerCount: 2, nodeCount: 2 },
{ name: "#east", packetCount: 2, observerCount: 2, nodeCount: 3 },
], isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() };
vi.mock("../../../src/features/stats/useStats", () => ({ useScopes: () => query }));
vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => <div data-testid="chart" /> }));
beforeEach(() => { vi.clearAllMocks(); query.isError = false; query.isPending = false; query.isPlaceholderData = false; });
it("shows exact scope counts, keeps membership semantics explicit and filters the whole view", () => {
render(<ScopesTab />);
const table = screen.getByRole("table", { name: "Scope counts" });
expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument();
expect(screen.getByText("Observer memberships")).toBeInTheDocument();
expect(screen.getByText(/An observer can appear/)).toBeInTheDocument();
expect(screen.getByText(/Retained data/)).toBeInTheDocument();
fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } });
expect(screen.queryByText("#east")).not.toBeInTheDocument();
expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh scopes" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it("hides stale values while a region change is pending and on errors", () => {
query.isPlaceholderData = true;
const { rerender } = render(<ScopesTab />);
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
query.isPlaceholderData = false; query.isError = true; rerender(<ScopesTab />);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes");
expect(screen.queryByText("#west")).not.toBeInTheDocument();
});
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { TrafficTab } from "../../../src/features/stats/TrafficTab";
import { useStatsObservations } from "../../../src/features/stats/useStats";
const query = { data: [{ hour: Date.UTC(2026, 8, 19, 12), iata: "YOW", observationCount: 1200, uniquePackets: 9999, activeObservers: 88 }], dataUpdatedAt: Date.UTC(2026, 8, 19, 12, 30), isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() };
vi.mock("../../../src/features/stats/useStats", () => ({ useStatsObservations: vi.fn(() => query) }));
vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => <div data-testid="chart" /> }));
beforeEach(() => {
query.isLoading = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false;
vi.clearAllMocks();
});
describe("Traffic page", () => {
it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => {
render(<TrafficTab range="24h" />);
expect(useStatsObservations).toHaveBeenCalledWith("24h");
const table = screen.getByRole("table", { name: "Receptions by IATA" });
expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument();
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
expect(screen.getByText(/Blank hours/)).toHaveTextContent("retained");
expect(screen.getByText(/UTC/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it("does not display previous-region data while a new query is loading", () => {
query.isPlaceholderData = true;
render(<TrafficTab range="7d" />);
expect(screen.queryByText("YOW")).not.toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
});
it("provides a visible error state and retry without stale chart values", () => {
query.isError = true;
render(<TrafficTab range="30d" />);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load traffic");
expect(screen.queryByText("YOW")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,30 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { expect, it, vi } from "vitest";
import { StatsOverview } from "../../../src/features/stats/StatsOverview";
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/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 Traffic from a shared URL and keeps unrelated selections when changing the range", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=traffic&range=24h&observerId=kept"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
expect(screen.getByText("Traffic range 24h")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Traffic" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "30d" }));
expect(screen.getByText("Traffic range 30d")).toBeInTheDocument();
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("observerId=kept");
fireEvent.click(screen.getByRole("button", { name: "Mesh" }));
expect(screen.getByText("Mesh charts")).toBeInTheDocument();
});
it("opens Scopes without a misleading range control and preserves the chosen range for Traffic", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes&range=30d&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
expect(screen.getByText("Scope charts")).toBeInTheDocument();
expect(screen.queryByRole("group", { name: "Time range" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Traffic" }));
expect(screen.getByText("Traffic range 30d")).toBeInTheDocument();
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW");
});
+29
View File
@@ -0,0 +1,29 @@
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 { useScopes } from "../../../src/features/stats/useStats";
import { getStatsScopes } from "../../../src/api/client";
const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR", isResolved: true };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/api/client", () => ({ getStatsScopes: vi.fn(() => Promise.resolve([])) }));
afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; });
it("sends the selected IATAs and fetches a separate global query when the filter is cleared", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { rerender, unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(AbortSignal)));
region.iatas = undefined; region.regionKey = "*"; rerender();
await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)));
expect(client.getQueryCache().findAll({ queryKey: ["stats-scopes"] })).toHaveLength(2);
unmount(); client.clear();
});
it("does not fetch a global fallback for unresolved selected regions", () => {
region.isResolved = false;
const client = new QueryClient();
const { unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
expect(getStatsScopes).not.toHaveBeenCalled();
unmount(); client.clear();
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, it } from "vitest";
import { scopeSummary, scopeChartOption } from "../../../src/features/stats/scopes";
import { readChartColors } from "../../../src/features/stats/chartTheme";
it("totals memberships explicitly and bounds charts without dropping the remainder", () => {
const rows = Array.from({ length: 20 }, (_, i) => ({ name: `#s${i}`, packetCount: i + 1, observerCount: 2, nodeCount: 1 }));
const original = JSON.stringify(rows);
expect(scopeSummary(rows)).toEqual({ active: 20, packets: 210, memberships: 40, nodes: 20 });
const option = scopeChartOption(rows, "packetCount", readChartColors());
expect(option).toMatchObject({ animation: false, tooltip: { renderMode: "richText" } });
expect(JSON.stringify(option)).toContain("Other scopes");
const bars = option as { series: { data: { value: number }[] }[] };
expect(bars.series[0]!.data.reduce((sum, row) => sum + row.value, 0)).toBe(210);
expect(JSON.stringify(rows)).toBe(original);
expect(scopeSummary([])).toEqual({ active: 0, packets: 0, memberships: 0, nodes: 0 });
});
@@ -0,0 +1,34 @@
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 { useStatsObservations } from "../../../src/features/stats/useStats";
import { getStatsObservations } from "../../../src/api/client";
const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/api/client", () => ({ getStatsObservations: vi.fn(() => new Promise(() => {})) }));
afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; });
it("waits for a selected region to resolve instead of making an unfiltered request", async () => {
region.isResolved = false;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { result, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
expect(result.current.isPending).toBe(true);
expect(getStatsObservations).not.toHaveBeenCalled();
unmount(); client.clear();
});
it("cancels the old region request and uses a new cache key when filters change", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { rerender, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
await waitFor(() => expect(getStatsObservations).toHaveBeenCalledOnce());
const first = vi.mocked(getStatsObservations).mock.calls[0]!;
expect(first[0]).toEqual(["YVR"]);
expect(first[1]).toBeGreaterThan(Date.now() - 24 * 3_600_000 - 5_000);
region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender();
await waitFor(() => expect(getStatsObservations).toHaveBeenCalledTimes(2));
expect(first[2]?.aborted).toBe(true);
expect(vi.mocked(getStatsObservations).mock.calls[1]?.[0]).toEqual(["YOW"]);
unmount(); client.clear();
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "../../../src/features/stats/traffic";
import { readChartColors } from "../../../src/features/stats/chartTheme";
import type { ObservationPoint } from "../../../src/features/stats/types";
const hour = 3_600_000;
const now = Date.UTC(2026, 8, 19, 12, 30);
const end = Date.UTC(2026, 8, 19, 12);
const point = (time: number, iata: string, count: number): ObservationPoint => ({ hour: time, iata, observationCount: count, uniquePackets: 100, activeObservers: 50 });
describe("traffic exploration", () => {
it("counts receptions, keeps missing hours null, and anchors the bounded window in UTC", () => {
const data = [point(end - 2 * hour, "YVR", 2), point(end - 2 * hour, "YOW", 3), point(end, "YVR", 7)];
const before = JSON.stringify(data);
const model = trafficModel(data, "24h", now);
expect(model.hours).toHaveLength(24);
expect(model.hours.at(-2)?.total).toBeNull();
expect(model.hours.at(-3)?.total).toBe(5);
expect(model.total).toBe(12);
expect(model.reportedHours).toBe(2);
expect(model.areas.map((area) => [area.name, area.total])).toEqual([["YVR", 9], ["YOW", 3]]);
expect(model.peak?.hour).toBe(end);
expect(model.peak?.total).toBe(7);
expect(model.days).toEqual(["2026-09-18", "2026-09-19"]);
expect(model.heatmap).toContainEqual([12, 1, 7]);
expect(model.heatmap).not.toContainEqual([11, 1, 0]);
expect(JSON.stringify(data)).toBe(before);
});
it("groups smaller IATAs without losing counts and excludes out-of-window/future rows", () => {
const data = Array.from({ length: 10 }, (_, i) => point(end, `X${i}`, i + 1));
data.push(point(end - 25 * hour, "OLD", 1_000), point(end + hour, "FUT", 1_000));
const model = trafficModel(data, "24h", now);
expect(model.total).toBe(55);
expect(model.series).toHaveLength(8);
expect(model.series.at(-1)?.name).toBe("Other IATAs");
expect(model.series.reduce((sum, series) => sum + series.total, 0)).toBe(55);
expect(model.series.reduce((sum, series) => sum + (series.values.at(-1) ?? 0), 0)).toBe(55);
expect(model.series.at(-1)?.total).toBe(6);
expect(trafficModel(data, "30d", now).hours).toHaveLength(720);
});
it("handles empty data without invented traffic and exposes safe, gap-preserving chart options", () => {
const model = trafficModel([], "7d", now);
expect(model.total).toBe(0);
expect(model.peak).toBeNull();
expect(model.heatmap).toEqual([]);
expect(model.hours.every((point) => point.total === null)).toBe(true);
const populated = trafficModel([point(end, "YOW", 1)], "24h", now);
const colors = readChartColors();
expect(trafficHeatmapOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, visualMap: { min: 0, max: 1 } });
expect(trafficTrendOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, series: [{ connectNulls: false }] });
});
});