mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 00:58:21 +00:00
Drop empty observer telemetry frames and show airtime as duration
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user