mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
First draft of repeater telemetry feature
This commit is contained in:
Generated
+10
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.5.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -32,7 +32,8 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0"
|
||||
"three": "^0.182.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -6385,6 +6386,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uplot": {
|
||||
"version": "1.6.32",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/uplot/-/uplot-1.6.32.tgz",
|
||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0"
|
||||
"three": "^0.182.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
RepeaterTelemetryHistoryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -296,6 +297,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
toggleTelemetryTracking: (key: string) =>
|
||||
fetchJson<AppSettings>('/settings/telemetry-tracked-keys/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
@@ -355,6 +361,10 @@ export const api = {
|
||||
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string, hours: number = 168) =>
|
||||
fetchJson<RepeaterTelemetryHistoryResponse>(
|
||||
`/contacts/${publicKey}/repeater/telemetry-history?hours=${hours}`
|
||||
),
|
||||
repeaterNeighbors: (publicKey: string) =>
|
||||
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
@@ -22,6 +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 { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
@@ -87,6 +89,27 @@ export function RepeaterDashboard({
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
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);
|
||||
@@ -264,6 +287,11 @@ export function RepeaterDashboard({
|
||||
onRefresh={() => refreshPane('status')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<BatteryHistoryPane
|
||||
publicKey={conversation.id}
|
||||
isTracked={telemetryTracked}
|
||||
onToggleTracking={handleToggleTelemetryTracking}
|
||||
/>
|
||||
<RadioSettingsPane
|
||||
data={paneData.radioSettings}
|
||||
state={paneStates.radioSettings}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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,
|
||||
}: {
|
||||
publicKey: string;
|
||||
isTracked: boolean;
|
||||
onToggleTracking: () => void;
|
||||
}) {
|
||||
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]);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,7 @@ const baseSettings = {
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
telemetry_tracked_keys: [],
|
||||
};
|
||||
|
||||
const publicChannel = {
|
||||
|
||||
@@ -50,6 +50,21 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
||||
useRepeaterDashboard: () => mockHook,
|
||||
}));
|
||||
|
||||
// Mock api module (BatteryHistoryPane calls api.getSettings on mount)
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
telemetry_tracked_keys: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
favorites: [],
|
||||
}),
|
||||
repeaterTelemetryHistory: vi.fn().mockResolvedValue({ entries: [] }),
|
||||
toggleTelemetryTracking: vi.fn().mockResolvedValue({ telemetry_tracked_keys: [] }),
|
||||
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: {
|
||||
|
||||
@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
telemetry_tracked_keys: [],
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
|
||||
@@ -7,3 +7,19 @@ class ResizeObserver {
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
// uPlot calls matchMedia at import time for DPI detection
|
||||
if (typeof globalThis.matchMedia === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ export interface AppSettings {
|
||||
flood_scope: string;
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
telemetry_tracked_keys: string[];
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -463,6 +464,17 @@ export interface PaneState {
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TelemetryHistoryEntry {
|
||||
timestamp: number;
|
||||
battery_volts: number;
|
||||
uptime_seconds: number | null;
|
||||
noise_floor_dbm: number | null;
|
||||
}
|
||||
|
||||
export interface RepeaterTelemetryHistoryResponse {
|
||||
entries: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
remote_snr: number | null;
|
||||
local_snr: number | null;
|
||||
|
||||
Reference in New Issue
Block a user