mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-20 15:31:56 +00:00
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:
+1
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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." } } };
|
||||
}
|
||||
@@ -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 } } }],
|
||||
};
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user