Remove automatic telemetry querying, remove battery pane, add telemetry history pane

This commit is contained in:
Gnome Adrift
2026-04-01 11:54:39 -07:00
committed by Jack Kingsman
parent 87df4b4aa1
commit c808f0930b
19 changed files with 272 additions and 596 deletions
+8 -29
View File
@@ -1,6 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
@@ -24,7 +23,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { BatteryHistoryPane } from './repeater/RepeaterBatteryHistoryPane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -94,26 +93,6 @@ export function RepeaterDashboard({
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Telemetry tracking state
const [telemetryTracked, setTelemetryTracked] = useState(false);
useEffect(() => {
api.getSettings().then((s) => {
setTelemetryTracked(s.telemetry_tracked_keys.includes(conversation.id.toLowerCase()));
}).catch(() => {});
}, [conversation.id]);
const handleToggleTelemetryTracking = useCallback(async () => {
const wasTracked = telemetryTracked;
setTelemetryTracked(!wasTracked);
try {
const updated = await api.toggleTelemetryTracking(conversation.id);
setTelemetryTracked(updated.telemetry_tracked_keys.includes(conversation.id.toLowerCase()));
} catch {
setTelemetryTracked(wasTracked);
toast.error('Failed to toggle telemetry tracking');
}
}, [conversation.id, telemetryTracked]);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
@@ -314,12 +293,6 @@ export function RepeaterDashboard({
onRefresh={() => refreshPane('status')}
disabled={anyLoading}
/>
<BatteryHistoryPane
publicKey={conversation.id}
isTracked={telemetryTracked}
onToggleTracking={handleToggleTelemetryTracking}
statusFetchedAt={paneStates.status.fetched_at}
/>
<RadioSettingsPane
data={paneData.radioSettings}
state={paneStates.radioSettings}
@@ -382,6 +355,12 @@ export function RepeaterDashboard({
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane
entries={paneData.status?.telemetry_history ?? []}
statusFetchedAt={paneStates.status.fetched_at}
/>
</div>
)}
</div>
@@ -1,199 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { api } from '../../api';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
type TimeRange = 24 | 168 | 720;
const RANGE_LABELS: Record<TimeRange, string> = {
24: '24h',
168: '7d',
720: '30d',
};
export function BatteryHistoryPane({
publicKey,
isTracked,
onToggleTracking,
statusFetchedAt,
}: {
publicKey: string;
isTracked: boolean;
onToggleTracking: () => void;
statusFetchedAt?: number | null;
}) {
const chartRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
const [entries, setEntries] = useState<TelemetryHistoryEntry[] | null>(null);
const [range, setRange] = useState<TimeRange>(168);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(
async (hours: TimeRange) => {
setLoading(true);
setError(null);
try {
const resp = await api.repeaterTelemetryHistory(publicKey, hours);
setEntries(resp.entries);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load history');
} finally {
setLoading(false);
}
},
[publicKey]
);
useEffect(() => {
fetchHistory(range);
}, [fetchHistory, range, statusFetchedAt]);
// Build / rebuild chart
useEffect(() => {
if (!chartRef.current || !entries || entries.length === 0) {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
return;
}
const timestamps = entries.map((e) => e.timestamp);
const volts = entries.map((e) => e.battery_volts);
const data: uPlot.AlignedData = [timestamps, volts];
// Get CSS variable colors for dark-theme compat
const style = getComputedStyle(document.documentElement);
const textColor = style.getPropertyValue('--foreground').trim() || '#a1a1aa';
const gridColor = style.getPropertyValue('--border').trim() || '#27272a';
const accentColor = '#22c55e'; // green-500
// Resolve oklch/hsl CSS colors to a usable format
const resolvedText = `hsl(${textColor})`;
const resolvedGrid = `hsl(${gridColor})`;
const opts: uPlot.Options = {
width: chartRef.current.clientWidth,
height: 180,
cursor: { show: true },
legend: { show: false },
padding: [8, 8, 0, 0],
axes: [
{
stroke: resolvedText,
grid: { stroke: resolvedGrid, width: 1 },
ticks: { stroke: resolvedGrid, width: 1 },
font: '10px sans-serif',
space: 60,
},
{
stroke: resolvedText,
grid: { stroke: resolvedGrid, width: 1 },
ticks: { stroke: resolvedGrid, width: 1 },
font: '10px sans-serif',
label: 'Volts',
labelFont: '10px sans-serif',
size: 50,
},
],
series: [
{},
{
label: 'Battery',
stroke: accentColor,
width: 2,
points: { show: entries.length < 50, size: 4 },
},
],
};
if (uplotRef.current) {
uplotRef.current.destroy();
}
uplotRef.current = new uPlot(opts, data, chartRef.current);
return () => {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
};
}, [entries]);
// Resize handler
useEffect(() => {
if (!chartRef.current || !uplotRef.current) return;
const observer = new ResizeObserver(() => {
if (uplotRef.current && chartRef.current) {
uplotRef.current.setSize({
width: chartRef.current.clientWidth,
height: 180,
});
}
});
observer.observe(chartRef.current);
return () => observer.disconnect();
}, [entries]);
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Battery History</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onToggleTracking}
className={cn(
'text-[11px] px-2 py-0.5 rounded-full border transition-colors',
isTracked
? 'bg-success/15 border-success/30 text-success'
: 'bg-muted border-border text-muted-foreground hover:text-foreground'
)}
>
{isTracked ? 'Tracking' : 'Track'}
</button>
</div>
</div>
<div className="p-3">
{/* Time range toggles */}
<div className="flex gap-1 mb-2">
{([24, 168, 720] as TimeRange[]).map((h) => (
<button
key={h}
type="button"
onClick={() => setRange(h)}
className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors',
range === h
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{RANGE_LABELS[h]}
</button>
))}
</div>
{loading && (
<p className="text-sm text-muted-foreground italic">Loading...</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{!loading && !error && entries && entries.length === 0 && (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch telemetry above to record a data point
{!isTracked && ', or enable tracking for hourly collection'}.
</p>
)}
<div ref={chartRef} className={cn(entries && entries.length > 0 ? '' : 'hidden')} />
</div>
</div>
);
}
@@ -0,0 +1,176 @@
import { useState, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
};
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatUptime(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({
entries,
statusFetchedAt,
}: {
entries: TelemetryHistoryEntry[];
statusFetchedAt?: number | null;
}) {
const [metric, setMetric] = useState<Metric>('battery_volts');
// statusFetchedAt is used to indicate freshness; suppress unused lint
void statusFetchedAt;
const config = METRIC_CONFIG[metric];
const chartData = useMemo(() => {
return entries.map((e) => {
const d = e.data;
return {
timestamp: e.timestamp,
battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received,
packets_sent: d.packets_sent,
uptime_seconds: d.uptime_seconds,
};
});
}, [entries]);
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Telemetry History</h3>
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
</div>
<div className="p-3">
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors',
metric === m
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{METRIC_CONFIG[m].label}
</button>
))}
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch status above to record data points.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={formatTime}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) =>
metric === 'uptime_seconds' ? formatUptime(v) : `${v}`
}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(ts) => formatTime(Number(ts))}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value);
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix = metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
const label =
metric === 'packets'
? name === 'packets_received'
? 'Received'
: 'Sent'
: config.label;
return [`${display}${suffix}`, label];
}}
/>
{dataKeys.map((key, i) => (
<Area
key={key}
type="linear"
dataKey={key}
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}