mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-11 00:44:48 +02:00
Improve QoL around repeater history (brush scrubbing, better extents, per-datapoint delta on packet count
This commit is contained in:
@@ -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<Record<string, number | undefined>>, 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<string>('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<string, number | undefined> = {
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 4,
|
||||
right: activeMetric === 'recv_errors' ? 8 : 4,
|
||||
right: rightKeys.length ? 8 : 4,
|
||||
bottom: 0,
|
||||
left: -8,
|
||||
}}
|
||||
@@ -351,7 +480,7 @@ export function TelemetryHistoryPane({
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={yDomain}
|
||||
domain={leftDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -359,15 +488,15 @@ export function TelemetryHistoryPane({
|
||||
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
||||
}
|
||||
/>
|
||||
{activeMetric === 'recv_errors' && (
|
||||
{rightKeys.length > 0 && (
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={yDomainPct}
|
||||
domain={rightDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
tickFormatter={(v) => (activeMetric === 'recv_errors' ? `${v}%` : `${v}`)}
|
||||
/>
|
||||
)}
|
||||
<RechartsTooltip
|
||||
@@ -380,66 +509,58 @@ export function TelemetryHistoryPane({
|
||||
labelFormatter={(ts) => 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 (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
yAxisId={
|
||||
activeMetric === 'recv_errors' && key === 'recv_error_pct' ? 'right' : 'left'
|
||||
}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{series.map((s) => (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="linear"
|
||||
dataKey={s.key}
|
||||
yAxisId={s.axis}
|
||||
connectNulls={false}
|
||||
stroke={s.color}
|
||||
fill={s.color}
|
||||
fillOpacity={s.line ? 0 : 0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: s.color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: s.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{chartData.length > 2 && (
|
||||
<Brush
|
||||
dataKey="timestamp"
|
||||
height={22}
|
||||
travellerWidth={8}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fill="hsl(var(--muted))"
|
||||
tickFormatter={(ts) => formatTime(Number(ts))}
|
||||
startIndex={brushStart}
|
||||
endIndex={brushEnd}
|
||||
onChange={handleBrushChange}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user