From 556847ea2b54e370d38bd26c86af7e1a1b34b316 Mon Sep 17 00:00:00 2001 From: jkingsman Date: Fri, 5 Jun 2026 21:43:52 -0700 Subject: [PATCH] Improve QoL around repeater history (brush scrubbing, better extents, per-datapoint delta on packet count --- .../repeater/RepeaterTelemetryHistoryPane.tsx | 319 ++++++++++++------ 1 file changed, 220 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index dd02aec..9b3f706 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { AreaChart, Area, @@ -7,6 +7,7 @@ import { CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, + Brush, } from 'recharts'; import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; @@ -84,6 +85,31 @@ function formatUptime(seconds: number): string { return `${(seconds / 86400).toFixed(1)}d`; } +/** Collect all numeric values for the given keys across a set of chart points. */ +function collectValues(data: Array>, keys: string[]): number[] { + const out: number[] = []; + for (const d of data) { + for (const k of keys) { + const v = d[k]; + if (typeof v === 'number' && Number.isFinite(v)) out.push(v); + } + } + return out; +} + +/** Bound a Y axis to the data range padded by 10% on each side. + * Returns undefined (recharts auto-domain) when there is nothing to plot. */ +function paddedDomain(values: number[]): [number, number] | undefined { + if (values.length === 0) return undefined; + const lo = Math.min(...values); + const hi = Math.max(...values); + const span = hi - lo; + // Flat series (single value / no spread): pad relative to magnitude so the + // line doesn't sit on a degenerate zero-height axis. + const pad = span === 0 ? Math.abs(lo) * 0.1 || 1 : span * 0.1; + return [lo - pad, hi + pad]; +} + interface TelemetryHistoryPaneProps { entries: TelemetryHistoryEntry[]; publicKey: string; @@ -102,6 +128,12 @@ export function TelemetryHistoryPane({ const { distanceUnit } = useDistanceUnit(); const [metric, setMetric] = useState('battery_volts'); const [toggling, setToggling] = useState(false); + const [brushRange, setBrushRange] = useState<{ start: number; end: number } | null>(null); + + // Reset the zoom window when switching to a different repeater. + useEffect(() => { + setBrushRange(null); + }, [publicKey]); const isTracked = trackedTelemetryRepeaters.includes(publicKey); const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked; @@ -143,25 +175,51 @@ export function TelemetryHistoryPane({ const activeMetric = allMetricKeys.includes(metric) ? metric : 'battery_volts'; const isBuiltin = BUILTIN_METRICS.includes(activeMetric as BuiltinMetric); - const activeConfig: MetricConfig = isBuiltin - ? BUILTIN_METRIC_CONFIG[activeMetric as BuiltinMetric] - : (lppMetrics.find((m) => m.key === activeMetric)?.config ?? { - label: activeMetric, - unit: '', - color: '#888', - }); + const activeConfig: MetricConfig = useMemo( + () => + isBuiltin + ? BUILTIN_METRIC_CONFIG[activeMetric as BuiltinMetric] + : (lppMetrics.find((m) => m.key === activeMetric)?.config ?? { + label: activeMetric, + unit: '', + color: '#888', + }), + [isBuiltin, activeMetric, lppMetrics] + ); const chartData = useMemo(() => { - return entries.map((e) => { + // Sort chronologically so per-sample deltas compare against the true + // predecessor (entries are not guaranteed ordered by the API). + const ordered = [...entries].sort((a, b) => a.timestamp - b.timestamp); + let prevRecv: number | undefined; + let prevSent: number | undefined; + return ordered.map((e) => { const d = e.data; const recvErrors = d.recv_errors ?? undefined; const packetsReceived = d.packets_received; + const packetsSent = d.packets_sent; + // Per-sample deltas off the cumulative lifetime counters. A drop + // (counter < previous) means the repeater rebooted and reset its + // counters, so we emit no delta for that sample rather than a large + // negative spike. The first sample has no predecessor, so no delta. + const recvDelta = + prevRecv != null && packetsReceived != null && packetsReceived >= prevRecv + ? packetsReceived - prevRecv + : undefined; + const sentDelta = + prevSent != null && packetsSent != null && packetsSent >= prevSent + ? packetsSent - prevSent + : undefined; + if (packetsReceived != null) prevRecv = packetsReceived; + if (packetsSent != null) prevSent = packetsSent; const point: Record = { timestamp: e.timestamp, battery_volts: d.battery_volts, noise_floor_dbm: d.noise_floor_dbm, packets_received: packetsReceived, - packets_sent: d.packets_sent, + packets_sent: packetsSent, + packets_received_delta: recvDelta, + packets_sent_delta: sentDelta, recv_errors: recvErrors, recv_error_pct: recvErrors != null && packetsReceived != null && packetsReceived + recvErrors > 0 @@ -179,35 +237,106 @@ export function TelemetryHistoryPane({ }); }, [entries, distanceUnit]); - const dataKeys = - activeMetric === 'packets' - ? ['packets_received', 'packets_sent'] - : activeMetric === 'recv_errors' - ? ['recv_errors', 'recv_error_pct'] - : [activeMetric]; + // Series descriptors drive axes, colors, labels, and tooltip formatting. + // Cumulative counters render as filled areas on the left axis; derived + // per-sample deltas render as gapped lines on a secondary right axis. + const series = useMemo(() => { + if (activeMetric === 'packets') { + return [ + { + key: 'packets_received', + color: '#0ea5e9', + axis: 'left' as const, + line: false, + label: 'Received', + }, + { + key: 'packets_sent', + color: '#f43f5e', + axis: 'left' as const, + line: false, + label: 'Sent', + }, + { + key: 'packets_received_delta', + color: '#14b8a6', + axis: 'right' as const, + line: true, + label: 'Received Δ', + }, + { + key: 'packets_sent_delta', + color: '#f59e0b', + axis: 'right' as const, + line: true, + label: 'Sent Δ', + }, + ]; + } + if (activeMetric === 'recv_errors') { + return [ + { + key: 'recv_errors', + color: '#ef4444', + axis: 'left' as const, + line: false, + label: 'RX Errors', + }, + { + key: 'recv_error_pct', + color: '#f59e0b', + axis: 'right' as const, + line: false, + label: 'Error Rate', + }, + ]; + } + return [ + { + key: activeMetric, + color: activeConfig.color, + axis: 'left' as const, + line: false, + label: activeConfig.label, + }, + ]; + }, [activeMetric, activeConfig]); - const yDomain = useMemo<[number, number] | undefined>(() => { - if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined; - const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[]; - if (values.length === 0) return [3, 5]; - const lo = Math.min(...values); - const hi = Math.max(...values); - return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)]; - }, [activeMetric, chartData]); + const leftKeys = useMemo( + () => series.filter((s) => s.axis === 'left').map((s) => s.key), + [series] + ); + const rightKeys = useMemo( + () => series.filter((s) => s.axis === 'right').map((s) => s.key), + [series] + ); - const yDomainPct = useMemo<[number, number]>(() => { - const MIN_SPAN = 5; - const values = chartData.map((d) => d.recv_error_pct).filter((v) => v != null) as number[]; - if (values.length === 0) return [0, MIN_SPAN]; - const lo = Math.min(...values); - const hi = Math.max(...values); - const span = hi - lo; - if (span >= MIN_SPAN) - return [Math.max(0, Math.floor(lo - span * 0.1)), Math.ceil(hi + span * 0.1)]; - const pad = (MIN_SPAN - span) / 2; - const bottom = Math.max(0, Math.floor(lo - pad)); - return [bottom, Math.ceil(bottom + MIN_SPAN)]; - }, [chartData]); + // Brush-controlled viewport. Indices are clamped to the current data length + // so a stale range from a previous repeater can never index out of bounds. + const lastIndex = Math.max(0, chartData.length - 1); + const brushStart = brushRange ? Math.min(brushRange.start, lastIndex) : 0; + const brushEnd = brushRange ? Math.min(brushRange.end, lastIndex) : lastIndex; + + const visibleData = useMemo( + () => chartData.slice(brushStart, brushEnd + 1), + [chartData, brushStart, brushEnd] + ); + + // Y extents bound to the visible window so zooming re-tightens the axis. + const leftDomain = useMemo( + () => paddedDomain(collectValues(visibleData, leftKeys)), + [visibleData, leftKeys] + ); + const rightDomain = useMemo( + () => (rightKeys.length ? paddedDomain(collectValues(visibleData, rightKeys)) : undefined), + [visibleData, rightKeys] + ); + + const handleBrushChange = (range: { startIndex?: number; endIndex?: number }) => { + if (typeof range.startIndex === 'number' && typeof range.endIndex === 'number') { + setBrushRange({ start: range.startIndex, end: range.endIndex }); + } + }; const handleToggle = async () => { setToggling(true); @@ -329,12 +458,12 @@ export function TelemetryHistoryPane({ No history yet. Fetch status above to record data points.

) : ( - + - {activeMetric === 'recv_errors' && ( + {rightKeys.length > 0 && ( `${v}%`} + tickFormatter={(v) => (activeMetric === 'recv_errors' ? `${v}%` : `${v}`)} /> )} formatTime(Number(ts))} // eslint-disable-next-line @typescript-eslint/no-explicit-any formatter={(value: any, name: any) => { + const s = series.find((x) => x.key === name); + const label = s?.label ?? String(name); const numVal = typeof value === 'number' ? value : Number(value); - if (activeMetric === 'recv_errors') { - if (name === 'recv_error_pct') return [`${numVal}%`, 'Error Rate']; - return [`${value}`, 'RX Errors']; - } - const display = - activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`; + if (name === 'recv_error_pct') return [`${numVal}%`, label]; + if (activeMetric === 'uptime_seconds') return [formatUptime(numVal), label]; const suffix = - activeMetric === 'uptime_seconds' - ? '' - : activeConfig.unit - ? ` ${activeConfig.unit}` - : ''; - const label = - activeMetric === 'packets' - ? name === 'packets_received' - ? 'Received' - : 'Sent' - : activeConfig.label; - return [`${display}${suffix}`, label]; + activeConfig.unit && + activeMetric !== 'packets' && + activeMetric !== 'recv_errors' + ? ` ${activeConfig.unit}` + : ''; + return [`${value}${suffix}`, label]; }} /> - {dataKeys.map((key, i) => { - const color = - activeMetric === 'packets' - ? i === 0 - ? '#0ea5e9' - : '#f43f5e' - : activeMetric === 'recv_errors' - ? i === 0 - ? '#ef4444' - : '#f59e0b' - : activeConfig.color; - return ( - - ); - })} + {series.map((s) => ( + + ))} + {chartData.length > 2 && ( + formatTime(Number(ts))} + startIndex={brushStart} + endIndex={brushEnd} + onChange={handleBrushChange} + /> + )} )}