From be2b2604df35e5cceb42d58466398bd0b40cd576 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 3 Apr 2026 13:45:39 -0700 Subject: [PATCH] Add intervalized repeater metrics collection. Closes #151. --- app/database.py | 3 +- app/main.py | 2 + app/migrations.py | 21 +++ app/models.py | 4 + app/radio_sync.py | 165 ++++++++++++++++++ app/repository/settings.py | 18 +- app/routers/settings.py | 76 +++++++- app/services/radio_lifecycle.py | 2 + frontend/src/App.tsx | 5 + frontend/src/api.ts | 8 + frontend/src/components/ConversationPane.tsx | 6 + frontend/src/components/RepeaterDashboard.tsx | 12 +- frontend/src/components/SettingsModal.tsx | 6 + .../repeater/RepeaterTelemetryHistoryPane.tsx | 113 +++++++++++- .../settings/SettingsDatabaseSection.tsx | 48 +++++ frontend/src/hooks/useAppSettings.ts | 34 ++++ frontend/src/test/conversationPane.test.tsx | 2 + frontend/src/test/repeaterDashboard.test.tsx | 4 +- frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/types.ts | 6 + tests/test_migrations.py | 32 ++-- tests/test_settings_router.py | 87 ++++++++- 22 files changed, 624 insertions(+), 31 deletions(-) diff --git a/app/database.py b/app/database.py index 6c633c1..6da61bb 100644 --- a/app/database.py +++ b/app/database.py @@ -104,7 +104,8 @@ CREATE TABLE IF NOT EXISTS app_settings ( flood_scope TEXT DEFAULT '', blocked_keys TEXT DEFAULT '[]', blocked_names TEXT DEFAULT '[]', - discovery_blocked_types TEXT DEFAULT '[]' + discovery_blocked_types TEXT DEFAULT '[]', + tracked_telemetry_repeaters TEXT DEFAULT '[]' ); INSERT OR IGNORE INTO app_settings (id) VALUES (1); diff --git a/app/main.py b/app/main.py index 286cf95..f002fe0 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from app.radio_sync import ( stop_message_polling, stop_periodic_advert, stop_periodic_sync, + stop_telemetry_collect, ) from app.routers import ( channels, @@ -103,6 +104,7 @@ async def lifespan(app: FastAPI): await stop_noise_floor_sampling() await stop_periodic_advert() await stop_periodic_sync() + await stop_telemetry_collect() if radio_manager.meshcore: await radio_manager.meshcore.stop_auto_message_fetching() await radio_manager.disconnect() diff --git a/app/migrations.py b/app/migrations.py index b702eb4..ecd9df0 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -401,6 +401,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 52) applied += 1 + if version < 53: + logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings") + await _migrate_053_tracked_telemetry_repeaters(conn) + await set_version(conn, 53) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -3171,3 +3177,18 @@ async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Conne await conn.commit() else: raise + + +async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None: + """Add tracked_telemetry_repeaters JSON list column to app_settings.""" + tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}: + await conn.commit() + return + col_cursor = await conn.execute("PRAGMA table_info(app_settings)") + columns = {row[1] for row in await col_cursor.fetchall()} + if "tracked_telemetry_repeaters" not in columns: + await conn.execute( + "ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'" + ) + await conn.commit() diff --git a/app/models.py b/app/models.py index 8a4c7d0..f727008 100644 --- a/app/models.py +++ b/app/models.py @@ -826,6 +826,10 @@ class AppSettings(BaseModel): "advertisements should not create new contacts; existing contacts are still updated" ), ) + tracked_telemetry_repeaters: list[str] = Field( + default_factory=list, + description="Public keys of repeaters opted into periodic telemetry collection (max 8)", + ) class FanoutConfig(BaseModel): diff --git a/app/radio_sync.py b/app/radio_sync.py index 4380a69..508ec53 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -28,6 +28,7 @@ from app.repository import ( AppSettingsRepository, ChannelRepository, ContactRepository, + RepeaterTelemetryRepository, ) from app.services.contact_reconciliation import ( promote_prefix_contacts_for_contact, @@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60 # more frequently than this. MIN_ADVERT_INTERVAL = 3600 +# Periodic telemetry collection task handle +_telemetry_collect_task: asyncio.Task | None = None + +# Telemetry collection interval (8 hours) +TELEMETRY_COLLECT_INTERVAL = 8 * 3600 + +# Initial delay before the first telemetry collection cycle (let radio settle) +TELEMETRY_COLLECT_INITIAL_DELAY = 60 + # Counter to pause polling during repeater operations (supports nested pauses) _polling_pause_count: int = 0 @@ -1524,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None except Exception as e: logger.error("Error syncing contacts to radio: %s", e, exc_info=True) return {"loaded": 0, "error": str(e)} + + +# --------------------------------------------------------------------------- +# Periodic repeater telemetry collection +# --------------------------------------------------------------------------- + + +async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool: + """Fetch status telemetry from a single repeater and record it. + + Returns True on success, False on failure (logged, not raised). + """ + try: + await mc.commands.add_contact(contact.to_radio_dict()) + status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5) + except Exception as e: + logger.debug( + "Telemetry collect: radio command failed for %s: %s", + contact.public_key[:12], + e, + ) + return False + + if status is None: + logger.debug("Telemetry collect: no response from %s", contact.public_key[:12]) + return False + + # Map to the same field names as the manual repeater status endpoint + data = { + "battery_volts": status.get("bat", 0) / 1000.0, + "tx_queue_len": status.get("tx_queue_len", 0), + "noise_floor_dbm": status.get("noise_floor", 0), + "last_rssi_dbm": status.get("last_rssi", 0), + "last_snr_db": status.get("last_snr", 0.0), + "packets_received": status.get("nb_recv", 0), + "packets_sent": status.get("nb_sent", 0), + "airtime_seconds": status.get("airtime", 0), + "rx_airtime_seconds": status.get("rx_airtime", 0), + "uptime_seconds": status.get("uptime", 0), + "sent_flood": status.get("sent_flood", 0), + "sent_direct": status.get("sent_direct", 0), + "recv_flood": status.get("recv_flood", 0), + "recv_direct": status.get("recv_direct", 0), + "flood_dups": status.get("flood_dups", 0), + "direct_dups": status.get("direct_dups", 0), + "full_events": status.get("full_evts", 0), + } + + try: + await RepeaterTelemetryRepository.record( + public_key=contact.public_key, + timestamp=int(time.time()), + data=data, + ) + logger.info( + "Telemetry collect: recorded snapshot for %s (%s)", + contact.name or contact.public_key[:12], + contact.public_key[:12], + ) + return True + except Exception as e: + logger.warning( + "Telemetry collect: failed to record for %s: %s", + contact.public_key[:12], + e, + ) + return False + + +async def _telemetry_collect_loop() -> None: + """Background task that collects telemetry from tracked repeaters every 8 hours. + + Runs a first cycle after a short initial delay (so newly tracked repeaters + get a sample promptly), then sleeps the full interval between subsequent cycles. + + Acquires the radio lock per-repeater (non-blocking) so manual operations can + interleave. Failures are logged and skipped. + """ + first_run = True + while True: + try: + delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL + await asyncio.sleep(delay) + first_run = False + + if not radio_manager.is_connected: + logger.debug("Telemetry collect: radio not connected, skipping cycle") + continue + + app_settings = await AppSettingsRepository.get() + tracked = app_settings.tracked_telemetry_repeaters + if not tracked: + continue + + logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked)) + collected = 0 + + for pub_key in tracked: + contact = await ContactRepository.get_by_key(pub_key) + if not contact or contact.type != 2: + logger.debug( + "Telemetry collect: skipping %s (not found or not repeater)", + pub_key[:12], + ) + continue + + try: + async with radio_manager.radio_operation( + "telemetry_collect", + blocking=False, + suspend_auto_fetch=True, + ) as mc: + if await _collect_repeater_telemetry(mc, contact): + collected += 1 + except RadioOperationBusyError: + logger.debug( + "Telemetry collect: radio busy, skipping %s", + pub_key[:12], + ) + + logger.info( + "Telemetry collect: cycle complete, %d/%d successful", + collected, + len(tracked), + ) + + except asyncio.CancelledError: + logger.info("Telemetry collect task cancelled") + break + except Exception as e: + logger.error("Error in telemetry collect loop: %s", e, exc_info=True) + + +def start_telemetry_collect() -> None: + """Start the periodic telemetry collection background task.""" + global _telemetry_collect_task + if _telemetry_collect_task is None or _telemetry_collect_task.done(): + _telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop()) + logger.info( + "Started periodic telemetry collection (interval: %ds)", + TELEMETRY_COLLECT_INTERVAL, + ) + + +async def stop_telemetry_collect() -> None: + """Stop the periodic telemetry collection background task.""" + global _telemetry_collect_task + if _telemetry_collect_task and not _telemetry_collect_task.done(): + _telemetry_collect_task.cancel() + try: + await _telemetry_collect_task + except asyncio.CancelledError: + pass + _telemetry_collect_task = None + logger.info("Stopped periodic telemetry collection") diff --git a/app/repository/settings.py b/app/repository/settings.py index 1fba7b4..7516ea6 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -29,7 +29,8 @@ class AppSettingsRepository: SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, last_message_times, preferences_migrated, advert_interval, last_advert_time, flood_scope, - blocked_keys, blocked_names, discovery_blocked_types + blocked_keys, blocked_names, discovery_blocked_types, + tracked_telemetry_repeaters FROM app_settings WHERE id = 1 """ ) @@ -89,6 +90,15 @@ class AppSettingsRepository: except (json.JSONDecodeError, TypeError): discovery_blocked_types = [] + # Parse tracked_telemetry_repeaters JSON + tracked_telemetry_repeaters: list[str] = [] + try: + raw_tracked = row["tracked_telemetry_repeaters"] + if raw_tracked: + tracked_telemetry_repeaters = json.loads(raw_tracked) + except (json.JSONDecodeError, TypeError, KeyError): + tracked_telemetry_repeaters = [] + return AppSettings( max_radio_contacts=row["max_radio_contacts"], favorites=favorites, @@ -101,6 +111,7 @@ class AppSettingsRepository: blocked_keys=blocked_keys, blocked_names=blocked_names, discovery_blocked_types=discovery_blocked_types, + tracked_telemetry_repeaters=tracked_telemetry_repeaters, ) @staticmethod @@ -116,6 +127,7 @@ class AppSettingsRepository: blocked_keys: list[str] | None = None, blocked_names: list[str] | None = None, discovery_blocked_types: list[int] | None = None, + tracked_telemetry_repeaters: list[str] | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -166,6 +178,10 @@ class AppSettingsRepository: updates.append("discovery_blocked_types = ?") params.append(json.dumps(discovery_blocked_types)) + if tracked_telemetry_repeaters is not None: + updates.append("tracked_telemetry_repeaters = ?") + params.append(json.dumps(tracked_telemetry_repeaters)) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) diff --git a/app/routers/settings.py b/app/routers/settings.py index 6a5687a..0ddc157 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -2,16 +2,18 @@ import asyncio import logging from typing import Literal -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from app.models import AppSettings +from app.models import CONTACT_TYPE_REPEATER, AppSettings from app.region_scope import normalize_region_scope -from app.repository import AppSettingsRepository +from app.repository import AppSettingsRepository, ContactRepository logger = logging.getLogger(__name__) router = APIRouter(prefix="/settings", tags=["settings"]) +MAX_TRACKED_TELEMETRY_REPEATERS = 8 + class AppSettingsUpdate(BaseModel): max_radio_contacts: int | None = Field( @@ -66,6 +68,19 @@ class FavoriteRequest(BaseModel): id: str = Field(description="Channel key or contact public key") +class TrackedTelemetryRequest(BaseModel): + public_key: str = Field(description="Public key of the repeater to toggle tracking") + + +class TrackedTelemetryResponse(BaseModel): + tracked_telemetry_repeaters: list[str] = Field( + description="Current list of tracked repeater public keys" + ) + names: dict[str, str] = Field( + description="Map of public key to display name for tracked repeaters" + ) + + class MigratePreferencesRequest(BaseModel): favorites: list[FavoriteRequest] = Field( default_factory=list, @@ -191,6 +206,61 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings: return await AppSettingsRepository.toggle_blocked_name(request.name) +@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse) +async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse: + """Toggle periodic telemetry collection for a repeater. + + Max 8 repeaters may be tracked. Returns 409 if the limit is reached and + the requested repeater is not already tracked. + """ + key = request.public_key.lower() + settings = await AppSettingsRepository.get() + current = settings.tracked_telemetry_repeaters + + async def _resolve_names(keys: list[str]) -> dict[str, str]: + names: dict[str, str] = {} + for k in keys: + contact = await ContactRepository.get_by_key(k) + names[k] = contact.name if contact and contact.name else k[:12] + return names + + if key in current: + # Remove + new_list = [k for k in current if k != key] + logger.info("Removing repeater %s from tracked telemetry", key[:12]) + await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list) + return TrackedTelemetryResponse( + tracked_telemetry_repeaters=new_list, + names=await _resolve_names(new_list), + ) + + # Validate it's a repeater + contact = await ContactRepository.get_by_key(key) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + if contact.type != CONTACT_TYPE_REPEATER: + raise HTTPException(status_code=400, detail="Contact is not a repeater") + + if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS: + names = await _resolve_names(current) + raise HTTPException( + status_code=409, + detail={ + "message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached", + "tracked_telemetry_repeaters": current, + "names": names, + }, + ) + + new_list = current + [key] + logger.info("Adding repeater %s to tracked telemetry", key[:12]) + await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list) + return TrackedTelemetryResponse( + tracked_telemetry_repeaters=new_list, + names=await _resolve_names(new_list), + ) + + @router.post("/migrate", response_model=MigratePreferencesResponse) async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse: """Migrate all preferences from frontend localStorage to database. diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index 8d3d932..a522d73 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None: start_message_polling, start_periodic_advert, start_periodic_sync, + start_telemetry_collect, sync_and_offload_all, sync_radio_time, ) @@ -241,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None: start_periodic_sync() start_periodic_advert() start_message_polling() + start_telemetry_collect() radio_manager._setup_complete = True finally: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2cf6af..c2ec167 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 449d501..7fddd28 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/settings/tracked-telemetry/toggle', { + method: 'POST', + body: JSON.stringify({ public_key: publicKey }), + }), + // Favorites toggleFavorite: (type: Favorite['type'], id: string) => fetchJson('/settings/favorites/toggle', { diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 82c9c0f..9f65a09 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -79,6 +79,8 @@ interface ConversationPaneProps { onDismissUnreadMarker: () => void; onSendMessage: (text: string) => Promise; onToggleNotifications: () => void; + trackedTelemetryRepeaters: string[]; + onToggleTrackedTelemetry: (publicKey: string) => Promise; } 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} /> ); diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 96feadf..d3eb138 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -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; } 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 */} - + )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b36d361..2237435 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -50,6 +50,8 @@ interface SettingsModalBaseProps { onToggleBlockedName?: (name: string) => void; contacts?: Contact[]; onBulkDeleteContacts?: (deletedKeys: string[]) => void; + trackedTelemetryRepeaters?: string[]; + onToggleTrackedTelemetry?: (publicKey: string) => Promise; } 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} /> ) : ( diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 89c5508..de7b478 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -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; +} + +export function TelemetryHistoryPane({ + entries, + publicKey, + contacts, + trackedTelemetryRepeaters, + onToggleTrackedTelemetry, +}: TelemetryHistoryPaneProps) { const [metric, setMetric] = useState('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 (
-

Telemetry History

- {entries.length} samples +
+

Telemetry History

+ {entries.length > 0 && ( + {entries.length} samples + )} +
+ {/* Explanation + tracking toggle */} +
+

+ 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 ( + POST /api/contacts/<key>/repeater/status), 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{' '} + + Database & Messaging + {' '} + settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake + of keeping mesh congestion reasonable. +

+ + {isTracked ? ( + + ) : slotsFull ? ( +
+ +

+ Disable tracking on another repeater to free a slot:{' '} + {trackedNames.map((t) => t.name).join(', ')} +

+
+ ) : ( + + )} +
+ + + {/* Metric selector */}
{(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))', }} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 5a7cc48..2987daa 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -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; className?: string; }) { const [retentionDays, setRetentionDays] = useState('14'); @@ -223,6 +227,50 @@ export function SettingsDatabaseSection({

+ + + {/* ── Tracked Repeater Telemetry ── */} +
+ +

+ 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). +

+ + {trackedTelemetryRepeaters.length === 0 ? ( +

+ No repeaters are being tracked. Enable tracking from a repeater's dashboard. +

+ ) : ( +
+ {trackedTelemetryRepeaters.map((key) => { + const contact = contacts.find((c) => c.public_key === key); + const displayName = contact?.name ?? key.slice(0, 12); + return ( +
+
+ {displayName} + + {key.slice(0, 12)} + +
+ {onToggleTrackedTelemetry && ( + + )} +
+ ); + })} +
+ )} +
+ {error && (
{error} diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index f20ce5b..b7656b8 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -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, }; } diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 7e81944..a93967a 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -164,6 +164,8 @@ function createProps(overrides: Partial {}), onToggleNotifications: vi.fn(), + trackedTelemetryRepeaters: [], + onToggleTrackedTelemetry: vi.fn(async () => {}), ...overrides, }; } diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index d51d785..1f0f6cb 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -124,6 +124,8 @@ const defaultProps = { onToggleNotifications: vi.fn(), onToggleFavorite: vi.fn(), onDeleteContact: vi.fn(), + trackedTelemetryRepeaters: [] as string[], + onToggleTrackedTelemetry: vi.fn(async () => {}), }; function createDeferred() { @@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => { render(); 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 () => { diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 3d27ff7..30b4d34 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -69,6 +69,7 @@ const baseSettings: AppSettings = { blocked_keys: [], blocked_names: [], discovery_blocked_types: [], + tracked_telemetry_repeaters: [], }; function renderModal(overrides?: { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 29b495d..f57b2de 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; +} + export interface MigratePreferencesRequest { favorites: Favorite[]; sort_order: string; diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 0eaeb58..859517f 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1249,8 +1249,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 14 - assert await get_version(conn) == 52 + assert applied == 15 + assert await get_version(conn) == 53 cursor = await conn.execute( """ @@ -1321,8 +1321,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 14 - assert await get_version(conn) == 52 + assert applied == 15 + assert await get_version(conn) == 53 cursor = await conn.execute( """ @@ -1388,8 +1388,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 52 + assert applied == 9 + assert await get_version(conn) == 53 cursor = await conn.execute( """ @@ -1441,8 +1441,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 13 - assert await get_version(conn) == 52 + assert applied == 14 + assert await get_version(conn) == 53 await conn.execute( """ @@ -1503,8 +1503,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 12 - assert await get_version(conn) == 52 + assert applied == 13 + assert await get_version(conn) == 53 await conn.execute( """ @@ -1556,8 +1556,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 11 - assert await get_version(conn) == 52 + assert applied == 12 + assert await get_version(conn) == 53 await conn.execute( """ @@ -1696,8 +1696,8 @@ class TestMigration046: applied = await run_migrations(conn) - assert applied == 7 - assert await get_version(conn) == 52 + assert applied == 8 + assert await get_version(conn) == 53 cursor = await conn.execute( """ @@ -1790,8 +1790,8 @@ class TestMigration047: applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 52 + assert applied == 7 + assert await get_version(conn) == 53 cursor = await conn.execute( """ diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index 1c73d97..cc8a70d 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -3,15 +3,18 @@ from unittest.mock import AsyncMock, patch import pytest +from fastapi import HTTPException -from app.models import AppSettings -from app.repository import AppSettingsRepository +from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert +from app.repository import AppSettingsRepository, ContactRepository from app.routers.settings import ( AppSettingsUpdate, FavoriteRequest, MigratePreferencesRequest, + TrackedTelemetryRequest, migrate_preferences, toggle_favorite, + toggle_tracked_telemetry, update_settings, ) @@ -202,3 +205,83 @@ class TestMigratePreferences: assert response.migrated is False assert response.settings.preferences_migrated is True + + +class TestToggleTrackedTelemetry: + """Tests for POST /settings/tracked-telemetry/toggle.""" + + async def _create_repeater(self, key: str, name: str = "TestRepeater") -> None: + await ContactRepository.upsert( + ContactUpsert(public_key=key, name=name, type=CONTACT_TYPE_REPEATER) + ) + + @pytest.mark.asyncio + async def test_add_repeater_to_tracking(self, test_db): + key = "aa" * 32 + await self._create_repeater(key) + + result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key)) + + assert key in result.tracked_telemetry_repeaters + assert result.names[key] == "TestRepeater" + + # Verify persisted + settings = await AppSettingsRepository.get() + assert key in settings.tracked_telemetry_repeaters + + @pytest.mark.asyncio + async def test_remove_repeater_from_tracking(self, test_db): + key = "bb" * 32 + await self._create_repeater(key) + await AppSettingsRepository.update(tracked_telemetry_repeaters=[key]) + + result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key)) + + assert key not in result.tracked_telemetry_repeaters + + @pytest.mark.asyncio + async def test_rejects_non_repeater_contact(self, test_db): + key = "cc" * 32 + await ContactRepository.upsert(ContactUpsert(public_key=key, name="Client", type=1)) + + with pytest.raises(HTTPException) as exc_info: + await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key)) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_rejects_unknown_contact(self, test_db): + with pytest.raises(HTTPException) as exc_info: + await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key="dd" * 32)) + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_rejects_when_limit_reached(self, test_db): + existing_keys = [] + for i in range(8): + key = f"{i:02x}" * 32 + await self._create_repeater(key, name=f"Repeater{i}") + existing_keys.append(key) + await AppSettingsRepository.update(tracked_telemetry_repeaters=existing_keys) + + new_key = "ff" * 32 + await self._create_repeater(new_key, name="NewRepeater") + + with pytest.raises(HTTPException) as exc_info: + await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=new_key)) + assert exc_info.value.status_code == 409 + detail = exc_info.value.detail + assert len(detail["tracked_telemetry_repeaters"]) == 8 + + @pytest.mark.asyncio + async def test_remove_still_works_when_limit_reached(self, test_db): + """Toggling OFF an already-tracked repeater should work even at max capacity.""" + keys = [] + for i in range(8): + key = f"{i:02x}" * 32 + await self._create_repeater(key) + keys.append(key) + await AppSettingsRepository.update(tracked_telemetry_repeaters=keys) + + result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0])) + assert keys[0] not in result.tracked_telemetry_repeaters + assert len(result.tracked_telemetry_repeaters) == 7