From fb376901f980ec8bcdc15affaffb462948a96da5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Jul 2026 20:53:22 -0400 Subject: [PATCH] Drop empty observer telemetry frames and show airtime as duration --- .../observers/ObserverDetailPanel.tsx | 10 +----- src/features/stats/ObserverTab.tsx | 8 +++-- src/features/stats/chartOptions.ts | 18 +++++++++-- src/features/stats/transforms.ts | 31 +++++++++++-------- src/lib/formatters.ts | 9 ++++++ tests/features/stats/chart-options.test.ts | 11 +++++++ tests/features/stats/has-telemetry.test.ts | 15 ++++++++- tests/lib/formatters.test.ts | 13 ++++++++ 8 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 1dd9c43..19fd916 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -5,7 +5,7 @@ import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { CopyButton } from "../../components/CopyButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; -import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES, formatAirtime } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { useTick } from "../../hooks/useTick"; import { deriveObserverStatus } from "./observer-status"; @@ -68,14 +68,6 @@ function getStats(metadata: Record | undefined): Stats | null { return metadata.stats as Stats; } -function formatAirtime(secs: number): string { - if (secs < 60) return `${secs}s`; - if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return `${h}h ${m}m`; -} - function RadioSection({ observer, noiseFloor }: { observer: Observer; noiseFloor?: number | null }) { const parts = [ observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 71eab28..5ae91ea 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -12,7 +12,7 @@ import { useTick } from "../../hooks/useTick"; import { deriveObserverStatus } from "../observers/observer-status"; import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions"; import { Card, ChartCard } from "./cards"; -import { hasTelemetry } from "./transforms"; +import { hasTelemetry, isEmptyPoint } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; import type { WsManager } from "../../api/ws-manager"; import type { Observer } from "../observers/types"; @@ -174,7 +174,9 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan if (first) onSelectObserver(first.observerId); }, [selectedObserverId, topObservers.data, onSelectObserver]); - const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); + // drop empty announce frames (all-zero rows the server writes for stats-less /status messages) + // so the noise-floor line and airtime deltas don't spike through zero + const points = useMemo(() => (telemetry.data?.points ?? []).filter((p) => !isEmptyPoint(p)), [telemetry.data]); // 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]); @@ -212,7 +214,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan ) : ( <> Airtime TX / RX · {range}} + title={<>Airtime (RX / TX) · {range}} height={180} option={airtime} isLoading={telemetry.isLoading} diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 8cd36b6..1cd36d2 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -1,6 +1,6 @@ import type { EChartsOption } from "./echarts-setup"; import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatAirtime } from "../../lib/formatters"; import type { TelemetryPoint } from "./types"; const MONO = "JetBrains Mono, monospace"; @@ -311,9 +311,21 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketed backgroundColor: "transparent", grid: { left: 44, right: 14, top: 24, bottom: 22 }, legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, - tooltip: { trigger: "axis", ...tooltipStyle(c) }, + // values are on-air seconds per interval — render the axis and tooltip as durations, not bare numbers + tooltip: { + trigger: "axis", + ...tooltipStyle(c), + formatter: (params: unknown) => { + const arr = (Array.isArray(params) ? params : [params]) as Array<{ axisValueLabel?: string; seriesName?: string; marker?: string; value?: unknown }>; + const rows = arr.map((p) => { + const secs = Array.isArray(p.value) ? p.value[1] : p.value; + return `${p.marker ?? ""} ${p.seriesName ?? ""} ${secs == null ? "—" : formatAirtime(Math.round(secs as number))}`; + }); + return [arr[0]?.axisValueLabel ?? "", ...rows].join("
"); + }, + }, xAxis: timeAxis(c), - yAxis: valueAxis(c), + yAxis: { ...valueAxis(c), axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: (v: number) => formatAirtime(Math.round(v)) } }, series: [ { 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 } }, diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 3b9f641..137cd3a 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -28,19 +28,24 @@ export function formatPreset(preset: string): string { return `${freq} · ${bw}k · SF${sf}`; } -// True if any point carries at least one meaningful (non-null, non-zero) metric. Bots / MQTT bridges -// report telemetry rows that are all zeros (no real radio hardware); those count as "no telemetry" -// so we show an empty state rather than a wall of flat-zero charts. -export function hasTelemetry(points: TelemetryPoint[]): boolean { +// A point with no meaningful (non-null, non-zero) metric. The server writes an all-zero telemetry +// row whenever a /status message arrives without a usable `stats` block (empty announce frames, +// bots / MQTT bridges); those aren't real readings, so we drop them rather than plot flat zeros. +export function isEmptyPoint(p: TelemetryPoint): boolean { const live = (v: number | null) => v != null && v !== 0; - return points.some( - (p) => - live(p.batteryMv) || - live(p.airtimeTxPct) || - live(p.airtimeRxPct) || - live(p.noiseFloorDb) || - live(p.uptimeSeconds) || - live(p.queueLength) || - live(p.receiveErrors), + return !( + live(p.batteryMv) || + live(p.airtimeTxPct) || + live(p.airtimeRxPct) || + live(p.noiseFloorDb) || + live(p.uptimeSeconds) || + live(p.queueLength) || + live(p.receiveErrors) ); } + +// True if any point carries real telemetry; an all-empty series means "no telemetry" and we show +// an empty state instead of flat-zero charts. +export function hasTelemetry(points: TelemetryPoint[]): boolean { + return points.some((p) => !isEmptyPoint(p)); +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 6bb6fdf..fc96a04 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -56,6 +56,15 @@ export function formatBattery(volts: number): string { return `${volts.toFixed(2)}V`; } +// on-air time as a compact duration: 45 -> "45s", 74 -> "1m 14s", 3720 -> "1h 2m" +export function formatAirtime(secs: number): string { + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h ${m}m`; +} + // Compact large counts for KPI/stat displays: 932 -> "932", 14732 -> "14.7k", 8_900_000 -> "8.9M". export function formatCount(n: number | null | undefined): string { if (n == null) return "—"; diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index a8d189e..8eba541 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -140,6 +140,17 @@ describe("airtimeOption", () => { expect(opt.series[0].data).toEqual([[1000, 10], [2000, 12], [3000, 11]]); expect(opt.series[1].data).toEqual([[1000, 4], [2000, 4], [3000, 7]]); }); + + it("labels the axis and tooltip as on-air durations", () => { + const opt = airtimeOption(points, colors, true) as Record; + expect(opt.yAxis.axisLabel.formatter(74)).toBe("1m 14s"); + const tip = opt.tooltip.formatter([ + { axisValueLabel: "t", seriesName: "RX", value: [2000, 74], marker: "●" }, + { seriesName: "TX", value: [2000, null], marker: "●" }, + ]); + expect(tip).toContain("RX 1m 14s"); + expect(tip).toContain("TX —"); + }); }); describe("receiveErrorsOption", () => { diff --git a/tests/features/stats/has-telemetry.test.ts b/tests/features/stats/has-telemetry.test.ts index 419301c..fde8099 100644 --- a/tests/features/stats/has-telemetry.test.ts +++ b/tests/features/stats/has-telemetry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { hasTelemetry } from "../../../src/features/stats/transforms"; +import { hasTelemetry, isEmptyPoint } from "../../../src/features/stats/transforms"; import type { TelemetryPoint } from "../../../src/features/stats/types"; const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); @@ -22,3 +22,16 @@ describe("hasTelemetry", () => { expect(hasTelemetry([allZero, allZero])).toBe(false); }); }); + +describe("isEmptyPoint", () => { + it("is true when every metric is null or zero (empty announce frame)", () => { + expect(isEmptyPoint(empty(1))).toBe(true); + const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxPct: 0, airtimeRxPct: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; + expect(isEmptyPoint(allZero)).toBe(true); + }); + + it("is false when any metric is live, e.g. a mains observer with battery 0 but real uptime", () => { + expect(isEmptyPoint({ ...empty(1), uptimeSeconds: 234023 })).toBe(false); + expect(isEmptyPoint({ ...empty(1), noiseFloorDb: -120 })).toBe(false); + }); +}); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index 59a905d..bd42efc 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -7,6 +7,7 @@ import { snrLevel, formatPropagation, formatCount, + formatAirtime, } from "../../src/lib/formatters"; describe("formatHex", () => { @@ -103,3 +104,15 @@ describe("formatPropagation", () => { expect(formatPropagation(null)).toBe("—"); }); }); + +describe("formatAirtime", () => { + it("renders seconds, minutes+seconds, and hours+minutes", () => { + expect(formatAirtime(45)).toBe("45s"); + expect(formatAirtime(74)).toBe("1m 14s"); + expect(formatAirtime(3720)).toBe("1h 2m"); + }); + + it("renders zero as 0s", () => { + expect(formatAirtime(0)).toBe("0s"); + }); +});