Add intervalized repeater metrics collection. Closes #151.

This commit is contained in:
Jack Kingsman
2026-04-03 13:45:39 -07:00
parent 35981d8f8b
commit be2b2604df
22 changed files with 624 additions and 31 deletions
+5
View File
@@ -156,6 +156,7 @@ export function App() {
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
} = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback
@@ -555,6 +556,8 @@ export function App() {
);
}
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
};
const searchProps = {
contacts,
@@ -588,6 +591,8 @@ export function App() {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
};
const crackerProps = {
packets: rawPackets,
+8
View File
@@ -36,6 +36,7 @@ import type {
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
TrackedTelemetryResponse,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -327,6 +328,13 @@ export const api = {
body: JSON.stringify({ name }),
}),
// Tracked telemetry
toggleTrackedTelemetry: (publicKey: string) =>
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
@@ -79,6 +79,8 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
function LoadingPane({ label }: { label: string }) {
@@ -148,6 +150,8 @@ export function ConversationPane({
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
@@ -241,6 +245,8 @@ export function ConversationPane({
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</Suspense>
);
+11 -1
View File
@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function RepeaterDashboard({
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} />
<TelemetryHistoryPane
entries={telemetryHistory}
publicKey={conversation.id}
contacts={contacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</div>
)}
</div>
@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass}
/>
) : (
@@ -9,7 +9,11 @@ import {
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import type { TelemetryHistoryEntry, Contact } from '../../types';
const MAX_TRACKED = 8;
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
@@ -47,8 +51,26 @@ function formatUptime(seconds: number): string {
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
interface TelemetryHistoryPaneProps {
entries: TelemetryHistoryEntry[];
publicKey: string;
contacts: Contact[];
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function TelemetryHistoryPane({
entries,
publicKey,
contacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: TelemetryHistoryPaneProps) {
const [metric, setMetric] = useState<Metric>('battery_volts');
const [toggling, setToggling] = useState(false);
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
const config = METRIC_CONFIG[metric];
@@ -68,13 +90,87 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const handleToggle = async () => {
setToggling(true);
try {
await onToggleTrackedTelemetry(publicKey);
} finally {
setToggling(false);
}
};
const trackedNames = useMemo(() => {
if (!slotsFull) return [];
return trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
return { key, name: contact?.name ?? key.slice(0, 12) };
});
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
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 className="flex items-center gap-2">
<h3 className="text-sm font-medium">Telemetry History</h3>
{entries.length > 0 && (
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
)}
</div>
</div>
<div className="p-3">
{/* Explanation + tracking toggle */}
<div className="mb-3 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed">
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
samples, whichever comes first). This telemetry is stored on normal interactive fetches
via the repeater pane, API calls to the endpoint (
<code className="text-[11px]">POST /api/contacts/&lt;key&gt;/repeater/status</code>), or
when the repeater is opted into interval telemetry polling, in which case the repeater
will be polled for metrics every 8 hours. You can see which repeaters are opted into
this flow in the{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
</p>
{isTracked ? (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
</Button>
) : slotsFull ? (
<div className="space-y-2">
<Button variant="outline" disabled>
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
</Button>
<p className="text-xs text-muted-foreground">
Disable tracking on another repeater to free a slot:{' '}
{trackedNames.map((t) => t.name).join(', ')}
</p>
</div>
) : (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
</Button>
)}
</div>
<Separator className="mb-3" />
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
@@ -149,10 +245,15 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{
dot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className,
}: {
appSettings: AppSettings;
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
</p>
</div>
<Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
</p>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-1">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[10px] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
);
})}
</div>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
+34
View File
@@ -120,6 +120,39 @@ export function useAppSettings() {
}
}, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_repeaters ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_repeaters: optimistic };
});
try {
const result = await api.toggleTrackedTelemetry(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
);
} catch (err) {
console.error('Failed to toggle tracked telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked telemetry');
}
}
}, []);
// One-time migration of localStorage preferences to server
useEffect(() => {
if (!appSettings || hasMigratedRef.current) return;
@@ -182,5 +215,6 @@ export function useAppSettings() {
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
};
}
@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
...overrides,
};
}
+3 -1
View File
@@ -124,6 +124,8 @@ const defaultProps = {
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
trackedTelemetryRepeaters: [] as string[],
onToggleTrackedTelemetry: vi.fn(async () => {}),
};
function createDeferred<T>() {
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
expect(screen.getByText(/No history yet/)).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
+1
View File
@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
};
function renderModal(overrides?: {
+6
View File
@@ -325,6 +325,7 @@ export interface AppSettings {
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
}
export interface AppSettingsUpdate {
@@ -337,6 +338,11 @@ export interface AppSettingsUpdate {
discovery_blocked_types?: number[];
}
export interface TrackedTelemetryResponse {
tracked_telemetry_repeaters: string[];
names: Record<string, string>;
}
export interface MigratePreferencesRequest {
favorites: Favorite[];
sort_order: string;