diff --git a/src/App.tsx b/src/App.tsx index 950ac21..573822e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,7 +308,7 @@ function AppInner() { />
-
+
}> {tabContent[activeTab]} diff --git a/src/api/client.ts b/src/api/client.ts index 011408b..c91dbdf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -305,8 +305,8 @@ export function getStatsOverview(iatas?: string[]): Promise { return request("/stats/overview", { iatas: iatasParam(iatas) }); } -export function getStatsObservations(iatas?: string[], since?: number): Promise { - return request("/stats/observations", { iatas: iatasParam(iatas), since }); +export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise { + return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal); } export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { @@ -344,9 +344,9 @@ export function getClockDrift(iatas?: string[], limit = 100): Promise { - return request("/stats/scopes"); +// aggregate (packet/observer/node counts), filtered by the selected IATAs. +export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise { + return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal); } export function getObserverTelemetry( diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index c4e5e36..84d33c8 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -143,10 +143,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { - Scopes · all regions · all time}> + Scopes · selected region · retained data}> {scopes.isError ? (
Failed to load
- ) : scopes.isLoading ? ( + ) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? (
Loading…
) : scopeRows.length === 0 ? (
No data
diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx new file mode 100644 index 0000000..76b0270 --- /dev/null +++ b/src/features/stats/ScopesTab.tsx @@ -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 ( +
+
+

Transport scopes

Retained data and memberships for the selected region.

+ +
+ + {query.isError &&

Could not load scopes. Try refreshing.

} +
+ + + + +
+

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.

+
+ + + + {unavailable ? "—" : `${rows.length} of ${all.length} scopes`}}> + {unavailable ?

{query.isError ? "Data unavailable" : "Loading scopes…"}

: !rows.length ?

{search ? "No scopes match this search." : "No scope data available."}

: ( +
+ + {rows.map((row) => + + + )} +
ScopePacketsObserversNodes
{row.name}{row.packetCount.toLocaleString()}{row.observerCount.toLocaleString()}{row.nodeCount.toLocaleString()}
+ )} +

Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.

+
+
+
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 3f03b2c..f3ba1d9 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -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 ( -
+
{tab === "mesh" && } + {tab === "traffic" && } + {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } {tab === "observer" && ( diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 713b5a4..fc03ea8 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -56,6 +56,8 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, + { value: "traffic", label: "Traffic", icon: }, + { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, @@ -105,7 +107,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
)} {/* 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" && ( 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 ( +
+
+

Traffic

When and where observers report receptions.

+ +
+ {query.isError &&

Could not load traffic. Try refreshing.

} +
+ + area.total > 0).length)} accent={colors.green} /> + + +
+

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.

+ Available hourly records} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> + +
+ + + {unavailable ?

{query.isError ? "Data unavailable" : "Loading traffic…"}

: !model.areas.length ?

No retained receptions in this window.

: ( +
+ + {model.areas.map((area, i) => + + + )} +
IATAReceptionsShare
{area.name}{area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%
+ )} +

Charts group smaller areas into “Other IATAs”; this table keeps every area.

+
+
+
+ ); +} diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index b41cf57..aed9685 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -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, ]); diff --git a/src/features/stats/scopes.ts b/src/features/stats/scopes.ts new file mode 100644 index 0000000..e8a6bf0 --- /dev/null +++ b/src/features/stats/scopes.ts @@ -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." } } }; +} diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts new file mode 100644 index 0000000..bb574ec --- /dev/null +++ b/src/features/stats/traffic.ts @@ -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>(); + const totals = new Map(); + 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(); + 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; + +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 } } }], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 51fbc19..d33cce6 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -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 = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 1c5f72c..4736e7c 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -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, }); } diff --git a/src/hooks/useRegion.tsx b/src/hooks/useRegion.tsx index bb6310e..fac7824 100644 --- a/src/hooks/useRegion.tsx +++ b/src/hooks/useRegion.tsx @@ -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]); } diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts new file mode 100644 index 0000000..b733ab0 --- /dev/null +++ b/tests/api/analytics-client.test.ts @@ -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(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"); +}); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx new file mode 100644 index 0000000..d6128eb --- /dev/null +++ b/tests/features/stats/ScopesTab.test.tsx @@ -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: () =>
})); +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(); + 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(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + query.isPlaceholderData = false; query.isError = true; rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes"); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx new file mode 100644 index 0000000..4f82a4c --- /dev/null +++ b/tests/features/stats/TrafficTab.test.tsx @@ -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: () =>
})); + +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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx new file mode 100644 index 0000000..48b334b --- /dev/null +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -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 }) =>

Traffic range {range}

})); +vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); +vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); +function Location() { return {useLocation().search}; } + +it("opens Traffic from a shared URL and keeps unrelated selections when changing the range", () => { + render(); + 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(); + 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"); +}); diff --git a/tests/features/stats/scope-query.test.tsx b/tests/features/stats/scope-query.test.tsx new file mode 100644 index 0000000..50f7295 --- /dev/null +++ b/tests/features/stats/scope-query.test.tsx @@ -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 }) => {children} }); + 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 }) => {children} }); + expect(getStatsScopes).not.toHaveBeenCalled(); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/scopes.test.ts b/tests/features/stats/scopes.test.ts new file mode 100644 index 0000000..b74ade4 --- /dev/null +++ b/tests/features/stats/scopes.test.ts @@ -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 }); +}); diff --git a/tests/features/stats/traffic-query.test.tsx b/tests/features/stats/traffic-query.test.tsx new file mode 100644 index 0000000..e07057a --- /dev/null +++ b/tests/features/stats/traffic-query.test.tsx @@ -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 }) => {children} }); + 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 }) => {children} }); + 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(); +}); diff --git a/tests/features/stats/traffic.test.ts b/tests/features/stats/traffic.test.ts new file mode 100644 index 0000000..583f000 --- /dev/null +++ b/tests/features/stats/traffic.test.ts @@ -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 }] }); + }); +});