From df9f4a07ada314e383bf0ed4acdfe022cf6fc8bc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 13:34:11 -0400 Subject: [PATCH] stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes --- .github/workflows/docker-publish.yml | 3 +- src/api/client.ts | 32 +++-- src/features/stats/MeshTab.tsx | 51 ++++--- src/features/stats/ObserverTab.tsx | 6 +- src/features/stats/chartOptions.ts | 136 ++++++++++++++++-- src/features/stats/echarts-setup.ts | 2 + src/features/stats/transforms.ts | 17 +-- src/features/stats/types.ts | 8 +- src/features/stats/useLiveStats.ts | 1 + src/features/stats/useStats.ts | 44 +++--- src/features/stats/useTelemetry.ts | 11 +- tests/api/client.test.ts | 35 ++++- tests/features/stats/chart-options.test.ts | 100 ++++++++++++- tests/features/stats/radio-presets.test.ts | 16 ++- .../stats/telemetry-normalize.test.ts | 38 ----- 15 files changed, 370 insertions(+), 130 deletions(-) delete mode 100644 tests/features/stats/telemetry-normalize.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 64087af..22446c1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Publish Docker Image on: push: - branches: [main] + branches: [main, dev] tags: ["v*"] env: @@ -35,6 +35,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha diff --git a/src/api/client.ts b/src/api/client.ts index a30c399..96c8087 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,6 +12,7 @@ import type { RadioPreset, ScopeStats, ObserverTelemetry, + NodeTypeCount, } from "../features/stats/types"; // typed fetch wrapper with query params @@ -253,31 +254,34 @@ export function getNodeNeighbors(nodeId: string): Promise { return request(`/nodes/${nodeId}/neighbors`); } -// stats endpoints. `iata` is a single code (undefined = all regions); the /stats/* endpoints filter -// by one IATA only, unlike the comma-separated `iatas` used elsewhere. +// stats endpoints -export function getStatsOverview(iata?: string): Promise { - return request("/stats/overview", { iata }); +export function getStatsOverview(iatas?: string[]): Promise { + return request("/stats/overview", { iatas: iatasParam(iatas) }); } -export function getStatsObservations(iata?: string, since?: number): Promise { - return request("/stats/observations", { iata, since }); +export function getStatsObservations(iatas?: string[], since?: number): Promise { + return request("/stats/observations", { iatas: iatasParam(iatas), since }); } -export function getPayloadBreakdown(iata?: string, since?: number): Promise { - return request("/stats/payload-breakdown", { iata, since }); +export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { + return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); } -export function getTopNodes(iata?: string, limit = 10): Promise { - return request("/stats/top-nodes", { iata, limit }); +export function getTopNodes(iatas?: string[], limit = 10): Promise { + return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit }); } -export function getTopObservers(iata?: string, since?: number, limit = 10): Promise { - return request("/stats/top-observers", { iata, since, limit }); +export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); } -export function getRadioPresets(iata?: string): Promise { - return request("/stats/radio-presets", { iata }); +export function getRadioPresets(iatas?: string[]): Promise { + return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); +} + +export function getStatsNodeTypes(iatas?: string[]): Promise { + return request("/stats/node-types", { iatas: iatasParam(iatas) }); } // renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 5b4440c..f2ec64f 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, type ChartColors } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats"; -import { observationsAreaOption, leaderboardOption, typeBarOption } from "./chartOptions"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; import { aggregatePresets, formatPreset } from "./transforms"; @@ -49,6 +49,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const topObservers = useTopObservers(range, 8); const radioPresets = useRadioPresets(); const scopes = useScopes(); + const nodeTypes = useNodeTypes(); const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]); const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]); @@ -90,11 +91,21 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [observerIds, onSelectObserver], ); - const presetRows = useMemo( - () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })), - [radioPresets.data, colors], + const typeRows = useMemo( + () => + [...(nodeTypes.data ?? [])] + .sort((a, b) => b.count - a.count) + .map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })), + [nodeTypes.data, colors], ); - const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]); + const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); + const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); + + const presetRows = useMemo( + () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), + [radioPresets.data], + ); + const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]); const scopeRows = useMemo( () => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount), @@ -106,14 +117,16 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const ov = overview.data; const kpiLoading = overview.isLoading; + // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below + const ovWindow = `${ov?.windowHours ?? 24}h`; return (
- - - - + + + +
- + {/* range-driven charts lead the grid; the all-time ones follow below */} + Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> Payload types · {range}} right={{formatCount(payloadTotal)} obs} @@ -136,17 +150,12 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.length === 0} /> - Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> - {/* needs a /stats/node-types endpoint (ticket filed) — the old donut counted types among - the top-10 nodes only, which read as the region's whole population */} - -
- Coming soon -
-
- + {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} + + + - Scopes · all regions}> + Scopes · all regions · all time}> {scopes.isError ? (
Failed to load
) : scopes.isLoading ? ( diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index dd8823c..05ccc54 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -175,11 +175,13 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan }, [selectedObserverId, topObservers.data, onSelectObserver]); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - const airtime = useMemo(() => airtimeOption(points, colors), [points, colors]); + // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points + const bucketed = telemetry.data != null && telemetry.data.interval !== "1h"; + const airtime = useMemo(() => airtimeOption(points, colors, bucketed), [points, colors, bucketed]); const battery = useMemo(() => batteryOption(points, colors), [points, colors]); const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors), [points, colors]); + const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 284fc94..8cd36b6 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -131,6 +131,122 @@ export function leaderboardOption( }; } +// Horizontal stacked bars per preset (node + observer segments, total at the bar end). +export function presetBarsOption( + rows: { name: string; nodes: number; observers: number }[], + c: ChartColors, + gridLeft = 172, // fits a full "910.525 · 62.5k · SF7" label +): EChartsOption { + const totals = rows.map((r) => r.nodes + r.observers); + const segment = (data: number[], color: string) => ({ + type: "bar" as const, + stack: "preset", + barMaxWidth: 22, + barCategoryGap: "42%", + data, + itemStyle: { color }, + }); + return { + animation: false, + backgroundColor: "transparent", + grid: { left: gridLeft, right: 56, top: 22, bottom: 6 }, + tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "shadow" } }, + legend: { + data: ["Nodes", "Observers"], + right: 8, + top: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } }, + yAxis: { + type: "category", + inverse: true, + data: rows.map((r) => r.name), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: c.textNormal, + fontFamily: MONO, + fontSize: 11, + align: "left", + margin: gridLeft - 10, + width: gridLeft - 16, + overflow: "truncate", + }, + }, + series: [ + { name: "Nodes", ...segment(rows.map((r) => r.nodes), c.primary) }, + { + name: "Observers", + ...segment(rows.map((r) => r.observers), c.secondary), + // outer segment carries the row total so it sits at the end of the whole stack + label: { + show: true, + position: "right" as const, + color: c.textBright, + fontFamily: MONO, + fontSize: 11, + formatter: (p: { dataIndex: number }) => totals[p.dataIndex]!.toLocaleString(), + }, + }, + ], + }; +} + +// Donut for small category sets; the center total rides on the first slice's label, which ECharts pins to the ring center. +export function donutOption( + items: { name: string; value: number; color?: string }[], + c: ChartColors, + centerValue: string, + centerLabel: string, +): EChartsOption { + const centerText = { + show: true, + position: "center" as const, + formatter: `{v|${centerValue}}\n{l|${centerLabel}}`, + rich: { + v: { color: c.textBright, fontFamily: MONO, fontSize: 21, fontWeight: 700 as const, lineHeight: 24 }, + l: { color: c.textMuted, fontFamily: MONO, fontSize: 9, lineHeight: 12 }, + }, + }; + return { + animation: false, + backgroundColor: "transparent", + tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" }, + legend: { + orient: "horizontal", + left: "center", + bottom: 4, + itemWidth: 9, + itemHeight: 9, + itemGap: 10, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + series: [ + { + type: "pie", + radius: ["48%", "70%"], + center: ["50%", "46%"], + avoidLabelOverlap: false, + itemStyle: { borderColor: c.bgSurface, borderWidth: 2, borderRadius: 4 }, + label: { show: false }, + emphasis: { scaleSize: 5 }, + data: items.map((it, i) => ({ + name: it.name, + value: it.value, + itemStyle: { color: it.color ?? c.series[i % c.series.length] }, + // the center total rides on the first slice only; per-slice labels stay hidden + ...(i === 0 ? { label: centerText, emphasis: { label: centerText } } : {}), + })), + }, + ], + }; +} + // Vertical bars for the payload-type breakdown. Replaced the old donut: with 10+ slivers the legend // needed scrolling, names truncated, and thin slices couldn't be compared by eye — bars label every // category inline and need no legend at all. @@ -172,11 +288,10 @@ export function typeBarOption( } // ---- Observer telemetry ---- -// `t` arrives in epoch ms (normalized in useObserverTelemetry). +// `t` arrives in epoch ms. -// airtimeTx/RxPct are cumulative counters, so chart the per-report delta (airtime used per interval), -// clamped at 0 to ignore counter resets. Caveat: under bucketing (7d/30d) the backend AVGs these -// counters, so the delta is approximate — pending a backend MAX−MIN fix (beacon-docs ticket). +// 1h points are cumulative counters, so chart per-report deltas (clamped at 0 for resets); +// bucketed points already arrive as per-bucket deltas, so chart those as-is. function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") { const out: [number, number | null][] = []; for (let i = 1; i < points.length; i++) { @@ -188,7 +303,9 @@ function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxP return out; } -export function airtimeOption(points: TelemetryPoint[], c: ChartColors): EChartsOption { +export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketed: boolean): EChartsOption { + const series = (key: "airtimeRxPct" | "airtimeTxPct") => + bucketed ? points.map((p) => [p.t, p[key]]) : deltaSeries(points, key); return { animation: false, backgroundColor: "transparent", @@ -198,8 +315,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors): ECharts xAxis: timeAxis(c), yAxis: valueAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } @@ -254,5 +371,6 @@ export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => export const queueOption = (p: TelemetryPoint[], c: ChartColors) => metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Recv errors / report", color: c.danger, accessor: (x) => x.receiveErrors, delta: true, area: true }); +// receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => + metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index e703520..319b6f5 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -6,6 +6,7 @@ import * as echarts from "echarts/core"; import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts"; import { GridComponent, + TitleComponent, TooltipComponent, LegendComponent, GraphicComponent, @@ -20,6 +21,7 @@ echarts.use([ PieChart, GaugeChart, GridComponent, + TitleComponent, TooltipComponent, LegendComponent, GraphicComponent, diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 211f8e9..3b9f641 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -1,17 +1,18 @@ import type { RadioPreset, TelemetryPoint } from "./types"; -// Collapse radio presets (one row per preset+iata+sourceType) into one row per preset, summing -// counts, sorted by descending total. Junk presets (all-zero "0,0,0" from unconfigured radios) are -// dropped so they don't clutter the chart. -export function aggregatePresets(rows: RadioPreset[]): { preset: string; value: number }[] { - const byPreset = new Map(); +// Collapse presets to one row each (keeping the node/observer split), dropping junk "0,0,0" configs. +export function aggregatePresets(rows: RadioPreset[]): { preset: string; nodes: number; observers: number }[] { + const byPreset = new Map(); for (const r of rows) { if (isJunkPreset(r.preset)) continue; - byPreset.set(r.preset, (byPreset.get(r.preset) ?? 0) + r.count); + const cur = byPreset.get(r.preset) ?? { nodes: 0, observers: 0 }; + if (r.sourceType === "node") cur.nodes += r.count; + else cur.observers += r.count; + byPreset.set(r.preset, cur); } return [...byPreset.entries()] - .map(([preset, value]) => ({ preset, value })) - .sort((a, b) => b.value - a.value); + .map(([preset, counts]) => ({ preset, ...counts })) + .sort((a, b) => b.nodes + b.observers - (a.nodes + a.observers)); } function isJunkPreset(preset: string): boolean { diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 8274b0c..d09650c 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -47,6 +47,12 @@ export interface RadioPreset { count: number; } +export interface NodeTypeCount { + nodeType: number; + nodeTypeName: string; + count: number; +} + export interface ScopeStats { name: string; // normalized scope name e.g. "#bc" packetCount: number; @@ -55,7 +61,7 @@ export interface ScopeStats { } export interface TelemetryPoint { - t: number; // epoch ms (normalized in useObserverTelemetry — backend raw path emits seconds) + t: number; // epoch ms batteryMv: number | null; airtimeTxPct: number | null; airtimeRxPct: number | null; diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index 49ebda0..50624d3 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -9,6 +9,7 @@ import type { StatsOverview, StatsRange } from "./types"; // Live overview KPIs: every packetObservation bumps the cached overview counters (no refetch). High // frequency, so increments are coalesced and flushed once per animation frame. The overview query also // refetches periodically (useStatsOverview) so the live deltas self-correct against the server. +// both totalPackets and totalObservations feed the top KPIs, so both bumps count. export function useLiveOverview(wsManager: WsManager) { const { regionKey } = useRegion(); const qc = useQueryClient(); diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index e4f09aa..836f3db 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -8,6 +8,7 @@ import { getTopObservers, getRadioPresets, getStatsScopes, + getStatsNodeTypes, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; @@ -21,18 +22,11 @@ const common = { // `since` is computed inside queryFn so refetches use a fresh window without churning the query key. const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range]; -// The /stats/* endpoints filter by a single IATA. Map the region selection to one: a single selected -// IATA filters; "all regions" or a multi-IATA region passes nothing (the endpoints then span all). -function useStatsIata(): { iata: string | undefined; regionKey: string } { - const { iatas, regionKey } = useRegion(); - return { iata: iatas?.length === 1 ? iatas[0] : undefined, regionKey }; -} - export function useStatsOverview() { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-overview", regionKey], - queryFn: () => getStatsOverview(iata), + queryFn: () => getStatsOverview(iatas), ...common, // self-correct the WS-accumulated live counters against the server refetchInterval: 60_000, @@ -40,46 +34,58 @@ export function useStatsOverview() { } export function useStatsObservations(range: StatsRange) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-observations", regionKey, range], - queryFn: () => getStatsObservations(iata, sinceFor(range)), + queryFn: () => getStatsObservations(iatas, sinceFor(range)), ...common, + // feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh + refetchInterval: 60_000, }); } export function usePayloadBreakdown(range: StatsRange) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-payload", regionKey, range], - queryFn: () => getPayloadBreakdown(iata, sinceFor(range)), + queryFn: () => getPayloadBreakdown(iatas, sinceFor(range)), ...common, }); } export function useTopNodes(limit = 10) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-top-nodes", regionKey, limit], - queryFn: () => getTopNodes(iata, limit), + queryFn: () => getTopNodes(iatas, limit), ...common, }); } export function useTopObservers(range: StatsRange, limit = 10) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-top-observers", regionKey, range, limit], - queryFn: () => getTopObservers(iata, sinceFor(range), limit), + queryFn: () => getTopObservers(iatas, sinceFor(range), limit), ...common, }); } export function useRadioPresets() { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-radio-presets", regionKey], - queryFn: () => getRadioPresets(iata), + queryFn: () => getRadioPresets(iatas), + ...common, + }); +} + +// node-types is a population census (no time window), so the key is region-only +export function useNodeTypes() { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-node-types", regionKey], + queryFn: () => getStatsNodeTypes(iatas), ...common, }); } diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 158f833..f2ec34c 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,6 +1,6 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { getObserver, getObserverTelemetry } from "../../api/client"; -import type { ObserverTelemetry, StatsRange } from "./types"; +import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { @@ -17,13 +17,6 @@ const INTERVAL_PARAM: Record = { "30d": "24h", }; -// The backend's raw (interval=1h) path emits `t` in epoch SECONDS while the bucketed path emits ms. -// Normalize everything to ms here so chart code is unit-agnostic. (Tracked: beacon-docs ticket.) -export function normalizeTelemetry(data: ObserverTelemetry, interval: string): ObserverTelemetry { - if (interval !== "1h") return data; - return { ...data, points: data.points.map((p) => ({ ...p, t: p.t * 1000 })) }; -} - export function useObserver(observerId: string | null) { return useQuery({ queryKey: ["observer", observerId], @@ -38,7 +31,7 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang const interval = INTERVAL_PARAM[range]; return useQuery({ queryKey: ["observer-telemetry", observerId, range, interval], - queryFn: async () => normalizeTelemetry(await getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), interval), + queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, placeholderData: keepPreviousData, diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a328fde..a9909e9 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail } from "../../src/api/client"; +import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -331,3 +331,36 @@ describe("getObserversPage", () => { expect(url).toContain("name=north"); }); }); + +describe("stats endpoints", () => { + it("joins the region's IATAs into the iatas param", async () => { + const getUrl = mockFetchOnce({ totalPackets: 0 }); + + await getStatsOverview(["YOW", "YYZ"]); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/overview"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + }); + + it("omits iatas for all regions and still forwards the rest", async () => { + const getUrl = mockFetchOnce([]); + + await getTopObservers(undefined, 1700000000000, 15); + + const url = new URL(getUrl()); + expect(url.searchParams.has("iatas")).toBe(false); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("15"); + }); + + it("hits /stats/node-types with the region's IATAs", async () => { + const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]); + + await getStatsNodeTypes(["YOW", "YYZ"]); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/node-types"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + }); +}); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index 031100e..a8d189e 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */ import { describe, it, expect } from "vitest"; -import { typeBarOption, leaderboardOption } from "../../../src/features/stats/chartOptions"; +import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption } from "../../../src/features/stats/chartOptions"; import type { ChartColors } from "../../../src/features/stats/chartTheme"; +import type { TelemetryPoint } from "../../../src/features/stats/types"; const colors: ChartColors = { primary: "#3b82f6", @@ -51,6 +52,53 @@ describe("typeBarOption", () => { }); }); +describe("donutOption", () => { + it("pins the total to the ring center via a label on the first slice, not a title block", () => { + const opt = donutOption([{ name: "repeater", value: 3 }, { name: "sensor", value: 7 }], colors, "10", "NODES") as Record; + // title/graphic blocks never sat quite right — the pie's own center label always does + expect(opt.title).toBeUndefined(); + expect(opt.graphic).toBeUndefined(); + const label = opt.series[0].data[0].label; + expect(label.show).toBe(true); + expect(label.position).toBe("center"); + expect(label.formatter).toContain("10"); + expect(label.formatter).toContain("NODES"); + // only the first slice carries it, or every slice would stamp its own copy + expect(opt.series[0].data[1].label).toBeUndefined(); + }); + + it("centers the pie with the legend below so the card fills evenly", () => { + const opt = donutOption([{ name: "repeater", value: 3 }], colors, "3", "NODES") as Record; + expect(opt.series[0].center[0]).toBe("50%"); + expect(opt.legend.left).toBe("center"); + expect(opt.legend.bottom).toBeDefined(); + }); +}); + +describe("presetBarsOption", () => { + const rows = [ + { name: "910.525 · 62.5k · SF7", nodes: 112, observers: 46 }, + { name: "910.425 · 62.5k · SF7", nodes: 5, observers: 1 }, + ]; + + it("stacks a node and an observer series per preset, in row order", () => { + const opt = presetBarsOption(rows, colors) as Record; + expect(opt.yAxis.data).toEqual(["910.525 · 62.5k · SF7", "910.425 · 62.5k · SF7"]); + expect(opt.series.map((s: { name: string }) => s.name)).toEqual(["Nodes", "Observers"]); + expect(opt.series[0].stack).toBe(opt.series[1].stack); + expect(opt.series[0].data).toEqual([112, 5]); + expect(opt.series[1].data).toEqual([46, 1]); + }); + + it("labels each stack with its total at the bar end", () => { + const opt = presetBarsOption(rows, colors) as Record; + const label = opt.series[1].label; + expect(label.show).toBe(true); + expect(label.formatter({ dataIndex: 0 })).toBe("158"); + expect(label.formatter({ dataIndex: 1 })).toBe("6"); + }); +}); + describe("leaderboardOption", () => { it("left-aligns names at the card edge and truncates long ones to the label gutter", () => { const rows = [{ name: "A very long observer name that overflows", value: 5, color: "#abc" }]; @@ -61,3 +109,53 @@ describe("leaderboardOption", () => { expect(opt.yAxis.axisLabel.margin).toBe(110); }); }); + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxPct: null, + airtimeRxPct: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +describe("airtimeOption", () => { + const points = [ + point(1000, { airtimeRxPct: 10, airtimeTxPct: 4 }), + point(2000, { airtimeRxPct: 12, airtimeTxPct: 4 }), + point(3000, { airtimeRxPct: 11, airtimeTxPct: 7 }), + ]; + + it("charts raw counters as clamped per-report deltas", () => { + const opt = airtimeOption(points, colors, false) as Record; + expect(opt.series[0].data).toEqual([[2000, 2], [3000, 0]]); // RX dips → clamp at 0 + expect(opt.series[1].data).toEqual([[2000, 0], [3000, 3]]); // TX + }); + + it("charts bucketed points as-is", () => { + const opt = airtimeOption(points, colors, true) as Record; + expect(opt.series[0].data).toEqual([[1000, 10], [2000, 12], [3000, 11]]); + expect(opt.series[1].data).toEqual([[1000, 4], [2000, 4], [3000, 7]]); + }); +}); + +describe("receiveErrorsOption", () => { + const points = [ + point(1000, { receiveErrors: 5 }), + point(2000, { receiveErrors: 8 }), + point(3000, { receiveErrors: 8 }), + ]; + + it("charts raw counters as clamped per-report deltas", () => { + const opt = receiveErrorsOption(points, colors, false) as Record; + expect(opt.series[0].data).toEqual([[2000, 3], [3000, 0]]); + }); + + it("charts bucketed points as-is", () => { + const opt = receiveErrorsOption(points, colors, true) as Record; + expect(opt.series[0].data).toEqual([[1000, 5], [2000, 8], [3000, 8]]); + }); +}); diff --git a/tests/features/stats/radio-presets.test.ts b/tests/features/stats/radio-presets.test.ts index 4f11d26..92b5e9c 100644 --- a/tests/features/stats/radio-presets.test.ts +++ b/tests/features/stats/radio-presets.test.ts @@ -5,7 +5,7 @@ import type { RadioPreset } from "../../../src/features/stats/types"; const row = (preset: string, sourceType: string, iata: string, count: number): RadioPreset => ({ preset, sourceType, iata, count }); describe("aggregatePresets", () => { - it("sums counts for the same preset across sourceType and iata", () => { + it("splits each preset into node and observer totals across iatas", () => { const rows = [ row("910.525,62.5,7", "observer", "YVR", 3), row("910.525,62.5,7", "node", "YVR", 5), @@ -13,13 +13,17 @@ describe("aggregatePresets", () => { row("869.525,250,11", "node", "YVR", 4), ]; const out = aggregatePresets(rows); - const byPreset = Object.fromEntries(out.map((r) => [r.preset, r.value])); - expect(byPreset["910.525,62.5,7"]).toBe(10); - expect(byPreset["869.525,250,11"]).toBe(4); + expect(out).toContainEqual({ preset: "910.525,62.5,7", nodes: 5, observers: 5 }); + expect(out).toContainEqual({ preset: "869.525,250,11", nodes: 4, observers: 0 }); }); - it("returns rows sorted by descending count", () => { - const rows = [row("910.5,62.5,7", "node", "YVR", 1), row("868,250,11", "node", "YVR", 9), row("915,125,9", "node", "YVR", 5)]; + it("returns rows sorted by descending total", () => { + const rows = [ + row("910.5,62.5,7", "node", "YVR", 1), + row("868,250,11", "node", "YVR", 5), + row("868,250,11", "observer", "YVR", 4), + row("915,125,9", "node", "YVR", 5), + ]; expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["868,250,11", "915,125,9", "910.5,62.5,7"]); }); diff --git a/tests/features/stats/telemetry-normalize.test.ts b/tests/features/stats/telemetry-normalize.test.ts deleted file mode 100644 index 1c7fced..0000000 --- a/tests/features/stats/telemetry-normalize.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { normalizeTelemetry } from "../../../src/features/stats/useTelemetry"; -import type { ObserverTelemetry } from "../../../src/features/stats/types"; - -const SEC = 1_700_000_000; // a second-scale epoch -const MS = SEC * 1000; - -describe("normalizeTelemetry", () => { - it("scales raw (1h) second-epoch points up to ms", () => { - const raw: ObserverTelemetry = { - range: "24h", - interval: "1h", - points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], - }; - expect(normalizeTelemetry(raw, "1h").points[0]!.t).toBe(MS); - }); - - it("leaves bucketed (6h/24h) ms-epoch points untouched", () => { - const bucketed: ObserverTelemetry = { - range: "7d", - interval: "6h", - points: [{ t: MS, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], - }; - expect(normalizeTelemetry(bucketed, "6h").points[0]!.t).toBe(MS); - }); - - it("does not mutate other fields", () => { - const raw: ObserverTelemetry = { - range: "24h", - interval: "1h", - points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: 1.5, airtimeRxPct: 2.5, noiseFloorDb: -110, uptimeSeconds: 42, queueLength: 3, receiveErrors: 1 }], - }; - const p = normalizeTelemetry(raw, "1h").points[0]!; - expect(p.batteryMv).toBe(3700); - expect(p.airtimeTxPct).toBe(1.5); - expect(p.receiveErrors).toBe(1); - }); -});