mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 09:03:44 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user