From df9f4a07ada314e383bf0ed4acdfe022cf6fc8bc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 13:34:11 -0400 Subject: [PATCH 1/3] 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 | 49 ++++--- 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, 369 insertions(+), 129 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 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 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), value: r.value, color: colors.primary })), - [radioPresets.data, colors], + () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), + [radioPresets.data], ); - const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]); + 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); - }); -}); From ad7b4859132011cc006e211f9f391f1db1391657 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:51:09 -0400 Subject: [PATCH 2/3] feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count --- src/api/client.ts | 5 +- src/features/nodes/NodeDetailPanel.tsx | 8 ++- src/features/nodes/NodeTable.tsx | 7 ++ src/features/nodes/node-updates.ts | 3 + src/features/nodes/types.ts | 2 + src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 30 ++------- src/features/traces/TraceList.tsx | 83 ++++++++++++++++++++---- src/types/api.ts | 6 ++ tests/features/traces/TraceList.test.tsx | 31 ++++++++- 10 files changed, 134 insertions(+), 45 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 96c8087..c085d8a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; -import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceDetail } from "../types/api"; +import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; @@ -166,11 +166,12 @@ export function searchCrossIATARoutes( // the last item's lastHeardAt); /traces/{tag} returns the tag's packets with resolved routes. export function getTraces( iatas: string[] | undefined, - params?: { scope?: string; since?: number; until?: number; cursor?: number; limit?: number }, + params?: { scope?: string; type?: TraceType; since?: number; until?: number; cursor?: number; limit?: number }, ): Promise { return request("/traces", { iatas: iatasParam(iatas), scope: params?.scope, + type: params?.type, // TRACE or PING; omitted = both (request() drops undefined params) since: params?.since, until: params?.until, cursor: params?.cursor, diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 1af2665..91673c1 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -21,8 +21,10 @@ function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClic {neighbor.iata}
-
- {neighbor.observationCount.toLocaleString()} obs +
+ {neighbor.publicKey} + · + {neighbor.observationCount.toLocaleString()} obs
); @@ -155,7 +157,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
-
+
0 ? `Neighbors (${node.knownNeighborCount})` : "Neighbors"}> {neighbors && neighbors.length > 0 ? (
{neighbors.map((n) => ( diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 6bbc983..35f0a77 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -72,6 +72,12 @@ const COLUMNS: Column[] = [ ), }, + { + header: "Neighbors", + className: "text-text-muted", + sortValue: (node) => node.knownNeighborCount, + cell: (node) => node.knownNeighborCount.toLocaleString(), + }, { header: "Location", className: "text-text-muted", @@ -105,6 +111,7 @@ function renderNodeCard(node: NodeSummary) {
{formatRadio(node.radio) ?? "—"} {location && · {location}} + {node.knownNeighborCount > 0 && · {node.knownNeighborCount.toLocaleString()} neighbors}
{node.iatas && node.iatas.length > 0 && (
diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index ac3c23c..caee590 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -50,6 +50,9 @@ export function upsertNodePages( radio: data.radio, defaultScope: data.defaultScope, iatas: data.iatas, + // the nodeUpdate event rides on an advert and doesn't carry a neighbor count; a node we're + // meeting for the first time has none resolved yet, so start at 0 until a reload fills it in + knownNeighborCount: 0, isObserver: data.isObserver, }; const pages = [...old.pages]; diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 7d66229..2c24ff2 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -14,6 +14,7 @@ export interface NodeSummary { radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown defaultScope?: string; // most recently matched transport scope name, e.g. "#bc" iatas: NodeIATA[]; + knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node // Set when this node also runs as an observer (watches traffic for uplink). isObserver drives the // map's observer-pip marker variant; observerId, when present, links to that observer's detail. isObserver?: boolean; @@ -35,6 +36,7 @@ export interface Node extends NodeSummary { export interface NodeNeighbor { id: string; name?: string; + publicKey: string; // hex-encoded prefix nodeType: number; nodeTypeName: string; lat?: number; diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 8e1fa7d..bd78f3f 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -16,7 +16,7 @@ interface StatsOverviewProps { wsManager: WsManager; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range + live dot) over the active +// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active // sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager }: StatsOverviewProps) { @@ -48,7 +48,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { return (
- +
{tab === "mesh" ? ( diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index a5793e1..0d2840b 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,5 +1,3 @@ -import type { WsManager } from "../../api/ws-manager"; -import { useWsStatus } from "../../hooks/useWsStatus"; import { Segmented } from "./Segmented"; import type { StatsRange, StatsTab } from "./types"; @@ -39,17 +37,9 @@ interface Props { onTabChange: (tab: StatsTab) => void; range: StatsRange; onRangeChange: (range: StatsRange) => void; - wsManager: WsManager; } -export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, wsManager }: Props) { - const { status } = useWsStatus(wsManager); - const live = status === "connected"; - const connecting = status === "connecting"; - const dotColor = live ? "bg-green" : connecting ? "bg-warn" : "bg-text-dim"; - const label = live ? "LIVE" : connecting ? "LIVE" : "OFFLINE"; - const labelColor = live ? "text-green" : connecting ? "text-warn" : "text-text-dim"; - +export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { return (
-
- onRangeChange(v as StatsRange)} - ariaLabel="Time range" - /> -
- - {label} -
-
+ onRangeChange(v as StatsRange)} + ariaLabel="Time range" + />
); } diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index d815e43..38f49a3 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -5,18 +5,58 @@ import { useRegion } from "../../hooks/useRegion"; import { SkeletonRows } from "../../components/SkeletonRows"; import { EmptyState } from "../../components/EmptyState"; import { Timestamp } from "../../components/Timestamp"; +import { Badge } from "../../components/Badge"; +import { Segmented } from "../stats/Segmented"; +import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters"; import { TraceDetailPanel } from "./TraceDetailPanel"; -import type { TraceTagSummary } from "../../types/api"; +import type { TraceTagSummary, TraceType } from "../../types/api"; // Traces are modest in number and the list isn't streamed, so a single region-filtered fetch covers // the card list (the /traces cursor is sound if pagination is ever needed). const TRACE_LIST_LIMIT = 200; +// "" = both; the backend takes TRACE or PING and omits the param to mean all. +const TYPE_OPTIONS = [ + { value: "", label: "All" }, + { value: "TRACE", label: "Trace" }, + { value: "PING", label: "Ping" }, +]; + interface TraceListProps { onAnalyze: (hash: string | null) => void; onViewNode?: (nodeId: string) => void; } +// The list now carries the most complete observation's path, so we can show the hops (and the SNR we +// heard on each) right on the card instead of making people open the detail panel for a quick look. +function TracePathPreview({ hashes, snrs }: { hashes: string[]; snrs: number[] }) { + return ( +
+ {hashes.map((hash, i) => { + const snr = snrs?.[i]; + const level = snr != null ? snrLevel(snr) : null; + const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + return ( + + {i > 0 && } + + + {hash.toUpperCase()} + + {/* keep a sub-line on every hop (SNR or a placeholder) so the badges across the row line up */} + {snr != null ? ( + {formatSnr(snr)} dB + ) : ( + - + )} + + + ); + })} +
+ ); +} + // A trace tag as a selectable card, echoing PacketRow's look so the tab reads like the Packets tab. function TraceTagCard({ tag, selected, onSelect }: { tag: TraceTagSummary; @@ -40,11 +80,14 @@ function TraceTagCard({ tag, selected, onSelect }: { >
{tag.traceTag.toUpperCase()} + {/* pings get the primary tint, traces the amber one, so the two read apart at a glance */} + {tag.traceType && {tag.traceType}}
{tag.packetCount} pkt · {tag.iataCount} iata
+ {tag.pathHashes?.length ? : null}
); } @@ -52,6 +95,7 @@ function TraceTagCard({ tag, selected, onSelect }: { export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { const { iatas, regionKey } = useRegion(); const [selectedTag, setSelectedTag] = useState(null); + const [typeFilter, setTypeFilter] = useState<"" | TraceType>(""); // drop the selection when the region changes — the selected tag may not be in the new region const prevRegion = useRef(regionKey); @@ -63,23 +107,36 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { }, [regionKey]); const { data: tags, isLoading } = useQuery({ - queryKey: ["traces", regionKey], - queryFn: () => getTraces(iatas, { limit: TRACE_LIST_LIMIT }), + queryKey: ["traces", regionKey, typeFilter], + queryFn: () => getTraces(iatas, { limit: TRACE_LIST_LIMIT, type: typeFilter || undefined }), staleTime: 30_000, }); return (
-
- {isLoading ? ( - - ) : (tags?.length ?? 0) === 0 ? ( - - ) : ( - tags!.map((t) => ( - - )) - )} +
+
+ + {tags ? `${tags.length} tag${tags.length === 1 ? "" : "s"}` : ""} + + setTypeFilter(v as "" | TraceType)} + ariaLabel="Trace type" + /> +
+
+ {isLoading ? ( + + ) : (tags?.length ?? 0) === 0 ? ( + + ) : ( + tags!.map((t) => ( + + )) + )} +
{selectedTag && ( setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} /> diff --git a/src/types/api.ts b/src/types/api.ts index eeb5964..f4b68be 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -173,12 +173,18 @@ export interface CrossIATARoute { // trace tags — a trace series groups the packets that share a 4-byte trace tag. The list endpoint // returns per-tag summaries; the detail endpoint returns the tag's packets with their resolved routes. +// Each tag is either a route TRACE or a PING (round-trip) — the backend tags which. +export type TraceType = "TRACE" | "PING"; + export interface TraceTagSummary { traceTag: string; // hex-encoded 4-byte tag firstHeardAt: number; // epoch ms lastHeardAt: number; // epoch ms packetCount: number; iataCount: number; // distinct IATAs the tag was heard in + traceType: TraceType; + pathHashes: string[]; // hops from the most complete observation we've seen for this tag + snrValues: number[]; // per-hop SNR (dB), index-aligned with pathHashes } export interface RawHop { diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index 8d1bc8b..d070e3a 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -19,8 +19,8 @@ const mockGetTraces = vi.mocked(getTraces); const mockGetTraceDetail = vi.mocked(getTraceDetail); const mockGetRegions = vi.mocked(getRegions); -function tag(traceTag: string, packetCount = 1): TraceTagSummary { - return { traceTag, firstHeardAt: 1, lastHeardAt: 2, packetCount, iataCount: 1 }; +function tag(traceTag: string, packetCount = 1, extra: Partial = {}): TraceTagSummary { + return { traceTag, firstHeardAt: 1, lastHeardAt: 2, packetCount, iataCount: 1, traceType: "TRACE", pathHashes: [], snrValues: [], ...extra }; } const detail: TraceDetail = { @@ -147,6 +147,33 @@ describe("TraceList", () => { expect(await screen.findByRole("tooltip")).toHaveTextContent("GatewayX"); }); + it("tags each card as TRACE or PING and previews the most complete path with per-hop SNR", async () => { + mockGetTraces.mockResolvedValue([ + tag("3f2a11c0", 4, { traceType: "PING", pathHashes: ["a1", "b2"], snrValues: [-7.5, -9] }), + ]); + + renderTraces(); + + expect(await screen.findByText("3F2A11C0")).toBeInTheDocument(); + expect(screen.getByText("PING")).toBeInTheDocument(); + // the path preview shows each hop's hash byte (uppercased) with its SNR on the sub-line + expect(screen.getByText("A1")).toBeInTheDocument(); + expect(screen.getByText("B2")).toBeInTheDocument(); + expect(screen.getByText("-7.50 dB")).toBeInTheDocument(); + }); + + it("refetches with the type param when the trace-type filter changes", async () => { + mockGetTraces.mockResolvedValue([tag("3f2a11c0", 1)]); + + renderTraces(); + await screen.findByText("3F2A11C0"); + + fireEvent.click(screen.getByRole("button", { name: "Ping" })); + + // the region arg is undefined for "all regions", so assert on the params object directly + await waitFor(() => expect(mockGetTraces.mock.calls.at(-1)?.[1]).toMatchObject({ type: "PING" })); + }); + it("shows an empty state when there are no traces", async () => { mockGetTraces.mockResolvedValue([]); From a618a28014192c60ebfd8e8aa240fd15a620b961 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 13:18:27 -0400 Subject: [PATCH 3/3] fix: github workflow, post images publically --- .github/workflows/docker-publish.yml | 4 ++++ README.md | 15 ++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22446c1..1ee448d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -33,6 +33,10 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=beacon-web + org.opencontainers.image.description=Beacon Web — real-time LoRa mesh packet analyzer + org.opencontainers.image.licenses=AGPL-3.0-or-later tags: | type=raw,value=latest,enable={{is_default_branch}} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} diff --git a/README.md b/README.md index 2c3718f..64e3957 100644 --- a/README.md +++ b/README.md @@ -33,20 +33,17 @@ EOF | `VITE_API_BASE` | Backend REST API base URL | | `VITE_WS_URL` | Backend WebSocket URL | -### 3. Authenticate with GitHub Container Registry - -```bash -docker login ghcr.io -u YOUR_GITHUB_USERNAME -``` - -Use a Personal Access Token (classic) with `read:packages` scope as the password. You only need to do this once. - -### 4. Start the services +### 3. Start the services ```bash docker compose up -d ``` +The images are public on GitHub Container Registry — no `docker login` required. +If a pull fails with `403 Forbidden`, the package visibility has regressed to +Private; a maintainer needs to set it back to Public (see the troubleshooting note +in [beacon-docs](https://github.com/MeshCore-Beacon/beacon-docs)). + Caddy will automatically obtain a TLS certificate for your domain. Ensure DNS is pointed at your server before starting. ## Local Development