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
This commit is contained in:
MrAlders0n
2026-06-11 13:34:11 -04:00
parent 9c93dac0f6
commit df9f4a07ad
15 changed files with 370 additions and 130 deletions
+2 -1
View File
@@ -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
+18 -14
View File
@@ -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<NodeNeighbor[]> {
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<StatsOverview> {
return request("/stats/overview", { iata });
export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
}
export function getStatsObservations(iata?: string, since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iata, since });
export function getStatsObservations(iatas?: string[], since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since });
}
export function getPayloadBreakdown(iata?: string, since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iata, since });
export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
}
export function getTopNodes(iata?: string, limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iata, limit });
export function getTopNodes(iatas?: string[], limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit });
}
export function getTopObservers(iata?: string, since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iata, since, limit });
export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit });
}
export function getRadioPresets(iata?: string): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iata });
export function getRadioPresets(iatas?: string[]): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iatas: iatasParam(iatas) });
}
export function getStatsNodeTypes(iatas?: string[]): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { iatas: iatasParam(iatas) });
}
// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
+30 -21
View File
@@ -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 (
<div className="mx-auto flex max-w-[1100px] flex-col gap-3.5 px-4 py-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total packets" sublabel="24h" accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel="24h" accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel="24h" accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel="24h" accent="var(--color-warn)" value={kpiLoading ? "—" : (ov?.activeIatas ?? "—")} />
<StatCard label="Total packets" sublabel={ovWindow} accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel={ovWindow} accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel={ovWindow} accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel={ovWindow} accent="var(--color-warn)" value={kpiLoading ? "—" : (ov?.activeIatas ?? "—")} />
</div>
<ChartCard
@@ -126,7 +139,8 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
/>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Top nodes" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
{/* range-driven charts lead the grid; the all-time ones follow below */}
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
<ChartCard
title={<>Payload types · {range}</>}
right={<span className="font-mono text-[10px] text-text-muted">{formatCount(payloadTotal)} obs</span>}
@@ -136,17 +150,12 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
isError={payload.isError}
isEmpty={payloadItems.length === 0}
/>
<ChartCard title={<>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 */}
<Card title="Node types">
<div className="flex h-[208px] items-center justify-center font-mono text-[11px] text-text-dim">
Coming soon
</div>
</Card>
<ChartCard title="Radio presets" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
{/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */}
<ChartCard title="Top nodes · all time" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<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</>}>
<Card title={<>Scopes · all regions · all time</>}>
{scopes.isError ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Failed to load</div>
) : scopes.isLoading ? (
+4 -2
View File
@@ -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.
+127 -9
View File
@@ -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 MAXMIN 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 });
+2
View File
@@ -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,
+9 -8
View File
@@ -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<string, number>();
// 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<string, { nodes: number; observers: number }>();
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 {
+7 -1
View File
@@ -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;
+1
View File
@@ -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();
+25 -19
View File
@@ -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,
});
}
+2 -9
View File
@@ -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<StatsRange, string> = {
@@ -17,13 +17,6 @@ const INTERVAL_PARAM: Record<StatsRange, string> = {
"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,
+34 -1
View File
@@ -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");
});
});
+99 -1
View File
@@ -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<string, any>;
// 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<string, any>;
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<string, any>;
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<string, any>;
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>): 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<string, any>;
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<string, any>;
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<string, any>;
expect(opt.series[0].data).toEqual([[2000, 3], [3000, 0]]);
});
it("charts bucketed points as-is", () => {
const opt = receiveErrorsOption(points, colors, true) as Record<string, any>;
expect(opt.series[0].data).toEqual([[1000, 5], [2000, 8], [3000, 8]]);
});
});
+10 -6
View File
@@ -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"]);
});
@@ -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);
});
});