Drop empty observer telemetry frames and show airtime as duration

This commit is contained in:
MrAlders0n
2026-07-15 21:44:11 -04:00
parent 65965ac8be
commit fb376901f9
8 changed files with 86 additions and 29 deletions
@@ -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<string, unknown> | 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`,
+5 -3
View File
@@ -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
) : (
<>
<ChartCard
title={<>Airtime TX / RX · {range}</>}
title={<>Airtime (RX / TX) · {range}</>}
height={180}
option={airtime}
isLoading={telemetry.isLoading}
+15 -3
View File
@@ -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("<br>");
},
},
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 } },
+18 -13
View File
@@ -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));
}
+9
View File
@@ -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 "—";
@@ -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<string, any>;
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", () => {
+14 -1
View File
@@ -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);
});
});
+13
View File
@@ -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");
});
});