diff --git a/app/migrations.py b/app/migrations.py index 63fceef..9a0ce70 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -3109,16 +3109,14 @@ async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None: async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None: - """Create repeater_telemetry_history table and add tracking opt-in column to app_settings.""" + """Create repeater_telemetry_history table for JSON-blob telemetry snapshots.""" await conn.execute( """ CREATE TABLE IF NOT EXISTS repeater_telemetry_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT NOT NULL, timestamp INTEGER NOT NULL, - battery_volts REAL NOT NULL, - uptime_seconds INTEGER, - noise_floor_dbm INTEGER, + data TEXT NOT NULL, FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE ) """ @@ -3129,10 +3127,4 @@ async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> ON repeater_telemetry_history (public_key, timestamp) """ ) - try: - await conn.execute( - "ALTER TABLE app_settings ADD COLUMN telemetry_tracked_keys TEXT DEFAULT '[]'" - ) - except Exception: - pass # Column may already exist await conn.commit() diff --git a/app/models.py b/app/models.py index d937428..c55411d 100644 --- a/app/models.py +++ b/app/models.py @@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel): flood_dups: int = Field(description="Duplicate flood packets") direct_dups: int = Field(description="Duplicate direct packets") full_events: int = Field(description="Full event queue count") + telemetry_history: list["TelemetryHistoryEntry"] = Field( + default_factory=list, description="Recent telemetry history snapshots" + ) class RepeaterNodeInfoResponse(BaseModel): @@ -805,7 +808,7 @@ class AppSettings(BaseModel): default_factory=list, description="List of favorited conversations" ) auto_decrypt_dm_on_advert: bool = Field( - default=True, + default=False, description="Whether to attempt historical DM decryption on new contact advertisement", ) sidebar_sort_order: Literal["recent", "alpha"] = Field( @@ -847,10 +850,6 @@ class AppSettings(BaseModel): "advertisements should not create new contacts; existing contacts are still updated" ), ) - telemetry_tracked_keys: list[str] = Field( - default_factory=list, - description="Repeater public keys opted in to hourly telemetry tracking", - ) class FanoutConfig(BaseModel): @@ -929,10 +928,4 @@ class StatisticsResponse(BaseModel): class TelemetryHistoryEntry(BaseModel): timestamp: int - battery_volts: float - uptime_seconds: int | None = None - noise_floor_dbm: int | None = None - - -class RepeaterTelemetryHistoryResponse(BaseModel): - entries: list[TelemetryHistoryEntry] + data: dict diff --git a/app/radio_sync.py b/app/radio_sync.py index 38fb893..7fd44c5 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -146,15 +146,6 @@ MESSAGE_POLL_AUDIT_INTERVAL = 3600 # Periodic advertisement task handle _advert_task: asyncio.Task | None = None -# Repeater telemetry polling task handle -_telemetry_task: asyncio.Task | None = None - -# Telemetry polling interval (1 hour) -TELEMETRY_POLL_INTERVAL = 3600 - -# Max age for telemetry history (30 days) -TELEMETRY_MAX_AGE_SECONDS = 30 * 86400 - # Default check interval when periodic advertising is disabled (seconds) # We still need to periodically check if it's been enabled ADVERT_CHECK_INTERVAL = 60 @@ -843,97 +834,6 @@ async def stop_periodic_advert(): logger.info("Stopped periodic advertisement") -async def _repeater_telemetry_loop(): - """Background task that periodically polls telemetry from opted-in repeaters.""" - from app.repository import RepeaterTelemetryRepository - - while True: - try: - await asyncio.sleep(TELEMETRY_POLL_INTERVAL) - - if not radio_manager.is_connected or is_polling_paused(): - continue - - app_settings = await AppSettingsRepository.get() - tracked_keys = app_settings.telemetry_tracked_keys - if not tracked_keys: - continue - - # Prune old entries - try: - pruned = await RepeaterTelemetryRepository.prune_old(TELEMETRY_MAX_AGE_SECONDS) - if pruned > 0: - logger.info("Pruned %d old telemetry history rows", pruned) - except Exception as e: - logger.warning("Failed to prune telemetry history: %s", e) - - for key in tracked_keys: - if not radio_manager.is_connected: - break - - try: - async with radio_manager.radio_operation( - "telemetry_poll", - blocking=False, - suspend_auto_fetch=True, - ) as mc: - contact = await ContactRepository.get_by_key(key) - if contact is None: - logger.debug("Telemetry poll: contact %s not found, skipping", key[:12]) - continue - - await mc.commands.add_contact(contact.to_radio_dict()) - status = await mc.commands.req_status_sync(key, timeout=10, min_timeout=5) - - if status is not None: - await RepeaterTelemetryRepository.record( - public_key=key, - timestamp=int(time.time()), - battery_volts=status.get("bat", 0) / 1000.0, - uptime_seconds=status.get("uptime"), - noise_floor_dbm=status.get("noise_floor"), - ) - logger.debug("Recorded telemetry for %s", key[:12]) - else: - logger.debug("No telemetry response from %s", key[:12]) - - except RadioOperationBusyError: - logger.debug("Skipping telemetry poll for %s: radio busy", key[:12]) - except Exception as e: - logger.warning("Error polling telemetry for %s: %s", key[:12], e) - - await asyncio.sleep(2) - - except asyncio.CancelledError: - logger.info("Repeater telemetry polling task cancelled") - break - except Exception as e: - logger.warning("Error in repeater telemetry loop: %s", e, exc_info=True) - - -def start_repeater_telemetry_polling(): - """Start the periodic repeater telemetry polling background task.""" - global _telemetry_task - if _telemetry_task is None or _telemetry_task.done(): - _telemetry_task = asyncio.create_task(_repeater_telemetry_loop()) - logger.info( - "Started repeater telemetry polling task (interval: %ds)", - TELEMETRY_POLL_INTERVAL, - ) - - -async def stop_repeater_telemetry_polling(): - """Stop the periodic repeater telemetry polling background task.""" - global _telemetry_task - if _telemetry_task and not _telemetry_task.done(): - _telemetry_task.cancel() - try: - await _telemetry_task - except asyncio.CancelledError: - pass - _telemetry_task = None - logger.info("Stopped repeater telemetry polling") - # Prevents reboot-loop: once we've rebooted to fix clock skew this session, # don't do it again (the hardware RTC case can't be fixed by reboot). diff --git a/app/repository/repeater_telemetry.py b/app/repository/repeater_telemetry.py index d438e27..405b3c5 100644 --- a/app/repository/repeater_telemetry.py +++ b/app/repository/repeater_telemetry.py @@ -1,3 +1,4 @@ +import json import logging from app.database import db @@ -10,18 +11,16 @@ class RepeaterTelemetryRepository: async def record( public_key: str, timestamp: int, - battery_volts: float, - uptime_seconds: int | None = None, - noise_floor_dbm: int | None = None, + data: dict, ) -> None: - """Insert a telemetry history row.""" + """Insert a telemetry history row with the full status snapshot as JSON.""" await db.conn.execute( """ INSERT INTO repeater_telemetry_history - (public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm) - VALUES (?, ?, ?, ?, ?) + (public_key, timestamp, data) + VALUES (?, ?, ?) """, - (public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm), + (public_key, timestamp, json.dumps(data)), ) await db.conn.commit() @@ -30,7 +29,7 @@ class RepeaterTelemetryRepository: """Return telemetry rows for a repeater since a given timestamp, ordered ASC.""" cursor = await db.conn.execute( """ - SELECT timestamp, battery_volts, uptime_seconds, noise_floor_dbm + SELECT timestamp, data FROM repeater_telemetry_history WHERE public_key = ? AND timestamp >= ? ORDER BY timestamp ASC @@ -41,22 +40,7 @@ class RepeaterTelemetryRepository: return [ { "timestamp": row["timestamp"], - "battery_volts": row["battery_volts"], - "uptime_seconds": row["uptime_seconds"], - "noise_floor_dbm": row["noise_floor_dbm"], + "data": json.loads(row["data"]), } for row in rows ] - - @staticmethod - async def prune_old(max_age_seconds: int) -> int: - """Delete rows older than max_age_seconds. Returns count of deleted rows.""" - import time - - cutoff = int(time.time()) - max_age_seconds - cursor = await db.conn.execute( - "DELETE FROM repeater_telemetry_history WHERE timestamp < ?", - (cutoff,), - ) - await db.conn.commit() - return cursor.rowcount diff --git a/app/repository/settings.py b/app/repository/settings.py index c0c4a01..6b91148 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -29,8 +29,7 @@ class AppSettingsRepository: SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated, advert_interval, last_advert_time, flood_scope, - blocked_keys, blocked_names, discovery_blocked_types, - telemetry_tracked_keys + blocked_keys, blocked_names, discovery_blocked_types FROM app_settings WHERE id = 1 """ ) @@ -90,14 +89,6 @@ class AppSettingsRepository: except (json.JSONDecodeError, TypeError): discovery_blocked_types = [] - # Parse telemetry_tracked_keys JSON - telemetry_tracked_keys: list[str] = [] - if row["telemetry_tracked_keys"]: - try: - telemetry_tracked_keys = json.loads(row["telemetry_tracked_keys"]) - except (json.JSONDecodeError, TypeError): - telemetry_tracked_keys = [] - # Validate sidebar_sort_order (fallback to "recent" if invalid) sort_order = row["sidebar_sort_order"] if sort_order not in ("recent", "alpha"): @@ -116,7 +107,6 @@ class AppSettingsRepository: blocked_keys=blocked_keys, blocked_names=blocked_names, discovery_blocked_types=discovery_blocked_types, - telemetry_tracked_keys=telemetry_tracked_keys, ) @staticmethod @@ -133,7 +123,6 @@ class AppSettingsRepository: blocked_keys: list[str] | None = None, blocked_names: list[str] | None = None, discovery_blocked_types: list[int] | None = None, - telemetry_tracked_keys: list[str] | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -188,10 +177,6 @@ class AppSettingsRepository: updates.append("discovery_blocked_types = ?") params.append(json.dumps(discovery_blocked_types)) - if telemetry_tracked_keys is not None: - updates.append("telemetry_tracked_keys = ?") - params.append(json.dumps(telemetry_tracked_keys)) - if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) @@ -241,17 +226,6 @@ class AppSettingsRepository: new_names = settings.blocked_names + [name] return await AppSettingsRepository.update(blocked_names=new_names) - @staticmethod - async def toggle_telemetry_tracked_key(key: str) -> AppSettings: - """Toggle a public key in the telemetry tracking list. Keys are normalized to lowercase.""" - normalized = key.lower() - settings = await AppSettingsRepository.get() - if normalized in settings.telemetry_tracked_keys: - new_keys = [k for k in settings.telemetry_tracked_keys if k != normalized] - else: - new_keys = settings.telemetry_tracked_keys + [normalized] - return await AppSettingsRepository.update(telemetry_tracked_keys=new_keys) - @staticmethod async def migrate_preferences_from_frontend( favorites: list[dict], diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 2019f87..29328a2 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -2,7 +2,7 @@ import logging import time from typing import TYPE_CHECKING -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException from meshcore import EventType from app.dependencies import require_connected @@ -24,7 +24,6 @@ from app.models import ( RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, - RepeaterTelemetryHistoryResponse, TelemetryHistoryEntry, ) from app.repository import ContactRepository, RepeaterTelemetryRepository @@ -133,39 +132,29 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: full_events=status.get("full_evts", 0), ) - # Record to telemetry history for charting (best-effort) + # Record to telemetry history as a JSON blob (best-effort) + now = int(time.time()) + status_dict = response.model_dump(exclude={"telemetry_history"}) try: await RepeaterTelemetryRepository.record( public_key=contact.public_key, - timestamp=int(time.time()), - battery_volts=response.battery_volts, - uptime_seconds=response.uptime_seconds, - noise_floor_dbm=response.noise_floor_dbm, + timestamp=now, + data=status_dict, ) except Exception as e: logger.warning("Failed to record telemetry history: %s", e) + # Fetch recent history and embed in response + try: + since = now - 30 * 86400 # last 30 days + rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since) + response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows] + except Exception as e: + logger.warning("Failed to fetch telemetry history: %s", e) + return response -@router.get( - "/{public_key}/repeater/telemetry-history", - response_model=RepeaterTelemetryHistoryResponse, -) -async def repeater_telemetry_history( - public_key: str, - hours: int = Query(default=168, ge=1, le=720), -) -> RepeaterTelemetryHistoryResponse: - """Get historical telemetry data for a repeater.""" - contact = await _resolve_contact_or_404(public_key) - _require_repeater(contact) - - since = int(time.time()) - hours * 3600 - rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since) - entries = [TelemetryHistoryEntry(**row) for row in rows] - return RepeaterTelemetryHistoryResponse(entries=entries) - - @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" diff --git a/app/routers/settings.py b/app/routers/settings.py index ee24928..3625584 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -65,10 +65,6 @@ class BlockNameRequest(BaseModel): name: str = Field(description="Display name to toggle block status") -class TelemetryTrackKeyRequest(BaseModel): - key: str = Field(description="Public key to toggle telemetry tracking") - - class FavoriteRequest(BaseModel): type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'") id: str = Field(description="Channel key or contact public key") @@ -203,13 +199,6 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings: return await AppSettingsRepository.toggle_blocked_name(request.name) -@router.post("/telemetry-tracked-keys/toggle", response_model=AppSettings) -async def toggle_telemetry_tracked_key(request: TelemetryTrackKeyRequest) -> AppSettings: - """Toggle a repeater's telemetry tracking status.""" - logger.info("Toggling telemetry tracking: %s", request.key[:12]) - return await AppSettingsRepository.toggle_telemetry_tracked_key(request.key) - - @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 05d90a7..bdea931 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -33,7 +33,6 @@ async def run_post_connect_setup(radio_manager) -> None: start_message_polling, start_periodic_advert, start_periodic_sync, - start_repeater_telemetry_polling, sync_and_offload_all, sync_radio_time, ) @@ -234,7 +233,6 @@ async def run_post_connect_setup(radio_manager) -> None: start_periodic_sync() start_periodic_advert() start_message_polling() - start_repeater_telemetry_polling() radio_manager._setup_complete = True finally: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 69c4495..54fb75e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.6.2", + "version": "3.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.6.2", + "version": "3.6.3", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/frontend/package.json b/frontend/package.json index 5373be8..cf95aa4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,8 +42,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", - "uplot": "^1.6.32" + "three": "^0.182.0" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8d3609f..fc0a8af 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -35,7 +35,6 @@ import type { RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, - RepeaterTelemetryHistoryResponse, StatisticsResponse, TraceResponse, UnreadCounts, @@ -320,11 +319,6 @@ export const api = { method: 'POST', body: JSON.stringify({ name }), }), - toggleTelemetryTracking: (key: string) => - fetchJson('/settings/telemetry-tracked-keys/toggle', { - method: 'POST', - body: JSON.stringify({ key }), - }), // Favorites toggleFavorite: (type: Favorite['type'], id: string) => @@ -392,10 +386,6 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/status`, { method: 'POST', }), - repeaterTelemetryHistory: (publicKey: string, hours: number = 168) => - fetchJson( - `/contacts/${publicKey}/repeater/telemetry-history?hours=${hours}` - ), repeaterNeighbors: (publicKey: string) => fetchJson(`/contacts/${publicKey}/repeater/neighbors`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 24d173b..d34286d 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -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} /> - + + {/* Telemetry history chart — full width, below console */} + )} diff --git a/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx deleted file mode 100644 index f21ae9e..0000000 --- a/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx +++ /dev/null @@ -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 = { - 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(null); - const uplotRef = useRef(null); - const [entries, setEntries] = useState(null); - const [range, setRange] = useState(168); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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 ( -
-
-

Battery History

-
- -
-
-
- {/* Time range toggles */} -
- {([24, 168, 720] as TimeRange[]).map((h) => ( - - ))} -
- - {loading && ( -

Loading...

- )} - {error && ( -

{error}

- )} - {!loading && !error && entries && entries.length === 0 && ( -

- No history yet. Fetch telemetry above to record a data point - {!isTracked && ', or enable tracking for hourly collection'}. -

- )} -
0 ? '' : 'hidden')} /> -
-
- ); -} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx new file mode 100644 index 0000000..ff39f4a --- /dev/null +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -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 = { + 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('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 ( +
+
+

Telemetry History

+ {entries.length} samples +
+
+ {/* Metric selector */} +
+ {(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => ( + + ))} +
+ + {entries.length === 0 ? ( +

+ No history yet. Fetch status above to record data points. +

+ ) : ( + + + + + + metric === 'uptime_seconds' ? formatUptime(v) : `${v}` + } + /> + 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) => ( + + ))} + + + )} +
+
+ ); +} diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 50addc2..48781d0 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -198,7 +198,6 @@ const baseSettings = { flood_scope: '', blocked_keys: [], blocked_names: [], - telemetry_tracked_keys: [], }; const publicChannel = { diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index f0243e6..d62fbf9 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -51,17 +51,9 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({ useRepeaterDashboard: () => mockHook, })); -// Mock api module (BatteryHistoryPane calls api.getSettings on mount) +// Mock api module 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' }), }, })); @@ -433,6 +425,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + telemetry_history: [], }; render(); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index f454706..305dafc 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -70,7 +70,6 @@ const baseSettings: AppSettings = { blocked_keys: [], blocked_names: [], discovery_blocked_types: [], - telemetry_tracked_keys: [], }; function renderModal(overrides?: { @@ -617,10 +616,10 @@ describe('SettingsModal', () => { openDatabaseSection(); expect( - screen.getByText(/removes packet-analysis availability for those messages/i) + screen.getByText(/remove packet-analysis availability for those historical messages/i) ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' })); + fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' })); await waitFor(() => { expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 05a9b38..523c156 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -243,15 +243,6 @@ export interface ChannelDetail { top_senders_24h: ChannelTopSender[]; } -export interface BulkCreateHashtagChannelsResult { - created_channels: Channel[]; - existing_count: number; - invalid_names: string[]; - decrypt_started: boolean; - decrypt_total_packets: number; - message: string; -} - /** A single path that a message took to reach us */ export interface MessagePath { /** Hex-encoded routing path */ @@ -342,7 +333,6 @@ export interface AppSettings { blocked_keys: string[]; blocked_names: string[]; discovery_blocked_types: number[]; - telemetry_tracked_keys: string[]; } export interface AppSettingsUpdate { @@ -417,6 +407,7 @@ export interface RepeaterStatusResponse { flood_dups: number; direct_dups: number; full_events: number; + telemetry_history: TelemetryHistoryEntry[]; } export interface RepeaterNeighborsResponse { @@ -482,13 +473,7 @@ export interface PaneState { export interface TelemetryHistoryEntry { timestamp: number; - battery_volts: number; - uptime_seconds: number | null; - noise_floor_dbm: number | null; -} - -export interface RepeaterTelemetryHistoryResponse { - entries: TelemetryHistoryEntry[]; + data: RepeaterStatusResponse; } export interface TraceResponse { diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index a55900c..760566e 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -1,12 +1,12 @@ -"""Tests for repeater telemetry history: repository CRUD, pruning, and API endpoints.""" +"""Tests for repeater telemetry history: repository CRUD and embedded status response.""" +import json import time import pytest from app.models import CONTACT_TYPE_REPEATER from app.repository import ( - AppSettingsRepository, ContactRepository, RepeaterTelemetryRepository, ) @@ -14,6 +14,26 @@ from app.repository import ( KEY_A = "aa" * 32 KEY_B = "bb" * 32 +SAMPLE_STATUS = { + "battery_volts": 4.15, + "tx_queue_len": 0, + "noise_floor_dbm": -100, + "last_rssi_dbm": -80, + "last_snr_db": 5.0, + "packets_received": 100, + "packets_sent": 50, + "airtime_seconds": 300, + "rx_airtime_seconds": 200, + "uptime_seconds": 1000, + "sent_flood": 10, + "sent_direct": 40, + "recv_flood": 60, + "recv_direct": 40, + "flood_dups": 5, + "direct_dups": 2, + "full_events": 0, +} + async def _insert_repeater(public_key: str, name: str = "Repeater"): """Insert a repeater contact into the test database.""" @@ -51,7 +71,7 @@ async def _db(test_db): class TestRepeaterTelemetryRepository: - """Tests for RepeaterTelemetryRepository CRUD operations.""" + """Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage.""" @pytest.mark.asyncio async def test_record_and_get_history(self, _db): @@ -61,22 +81,18 @@ class TestRepeaterTelemetryRepository: await RepeaterTelemetryRepository.record( public_key=KEY_A, timestamp=now - 3600, - battery_volts=4.15, - uptime_seconds=1000, - noise_floor_dbm=-100, + data={**SAMPLE_STATUS, "battery_volts": 4.15}, ) await RepeaterTelemetryRepository.record( public_key=KEY_A, timestamp=now, - battery_volts=4.10, - uptime_seconds=2000, - noise_floor_dbm=-95, + data={**SAMPLE_STATUS, "battery_volts": 4.10}, ) history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200) assert len(history) == 2 - assert history[0]["battery_volts"] == 4.15 - assert history[1]["battery_volts"] == 4.10 + assert history[0]["data"]["battery_volts"] == 4.15 + assert history[1]["data"]["battery_volts"] == 4.10 assert history[0]["timestamp"] < history[1]["timestamp"] @pytest.mark.asyncio @@ -84,9 +100,9 @@ class TestRepeaterTelemetryRepository: await _insert_repeater(KEY_A) now = int(time.time()) - await RepeaterTelemetryRepository.record(KEY_A, now - 7200, 4.0) - await RepeaterTelemetryRepository.record(KEY_A, now - 3600, 4.1) - await RepeaterTelemetryRepository.record(KEY_A, now, 4.2) + await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS) + await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601) assert len(history) == 2 @@ -97,87 +113,40 @@ class TestRepeaterTelemetryRepository: await _insert_repeater(KEY_B) now = int(time.time()) - await RepeaterTelemetryRepository.record(KEY_A, now, 4.1) - await RepeaterTelemetryRepository.record(KEY_B, now, 3.9) + await RepeaterTelemetryRepository.record( + KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1} + ) + await RepeaterTelemetryRepository.record( + KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9} + ) history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0) history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0) assert len(history_a) == 1 assert len(history_b) == 1 - assert history_a[0]["battery_volts"] == 4.1 + assert history_a[0]["data"]["battery_volts"] == 4.1 @pytest.mark.asyncio - async def test_prune_old(self, _db): + async def test_data_stored_as_json(self, _db): + """Verify the data column stores valid JSON that round-trips correctly.""" await _insert_repeater(KEY_A) now = int(time.time()) - # Insert one old and one recent - await RepeaterTelemetryRepository.record(KEY_A, now - 100000, 3.5) - await RepeaterTelemetryRepository.record(KEY_A, now, 4.0) - - pruned = await RepeaterTelemetryRepository.prune_old(50000) - assert pruned == 1 - - remaining = await RepeaterTelemetryRepository.get_history(KEY_A, 0) - assert len(remaining) == 1 - assert remaining[0]["battery_volts"] == 4.0 - - @pytest.mark.asyncio - async def test_record_nullable_fields(self, _db): - await _insert_repeater(KEY_A) - now = int(time.time()) - - await RepeaterTelemetryRepository.record(KEY_A, now, 4.0) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) history = await RepeaterTelemetryRepository.get_history(KEY_A, 0) assert len(history) == 1 - assert history[0]["uptime_seconds"] is None - assert history[0]["noise_floor_dbm"] is None + assert history[0]["data"] == SAMPLE_STATUS -class TestTelemetryTrackingToggle: - """Tests for telemetry tracking toggle in app settings.""" +class TestTelemetryHistoryInStatusResponse: + """Tests that history is embedded in the status response (no separate endpoint).""" @pytest.mark.asyncio - async def test_toggle_adds_and_removes_key(self, _db): - settings = await AppSettingsRepository.get() - assert settings.telemetry_tracked_keys == [] - - settings = await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A) - assert KEY_A.lower() in settings.telemetry_tracked_keys - - settings = await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A) - assert KEY_A.lower() not in settings.telemetry_tracked_keys - - @pytest.mark.asyncio - async def test_toggle_normalizes_to_lowercase(self, _db): - upper_key = "AA" * 32 - settings = await AppSettingsRepository.toggle_telemetry_tracked_key(upper_key) - assert KEY_A in settings.telemetry_tracked_keys - - @pytest.mark.asyncio - async def test_toggle_persists_across_reads(self, _db): - await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A) - settings = await AppSettingsRepository.get() - assert KEY_A in settings.telemetry_tracked_keys - - -class TestTelemetryHistoryEndpoint: - """Tests for the telemetry history API endpoint.""" - - @pytest.mark.asyncio - async def test_history_endpoint(self, _db, client): + async def test_history_not_available_as_separate_endpoint(self, _db, client): + """The old GET telemetry-history endpoint should be gone.""" await _insert_repeater(KEY_A) - now = int(time.time()) - - await RepeaterTelemetryRepository.record(KEY_A, now, 4.1, 1000, -90) - - resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history?hours=24") - assert resp.status_code == 200 - data = resp.json() - assert len(data["entries"]) == 1 - assert data["entries"][0]["battery_volts"] == 4.1 - assert data["entries"][0]["uptime_seconds"] == 1000 - assert data["entries"][0]["noise_floor_dbm"] == -90 + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") + assert resp.status_code in (404, 405) @pytest.mark.asyncio async def test_history_endpoint_non_repeater_rejected(self, _db, client): @@ -200,38 +169,5 @@ class TestTelemetryHistoryEndpoint: } ) resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - assert resp.status_code == 400 - - @pytest.mark.asyncio - async def test_history_endpoint_404_unknown_key(self, _db, client): - resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_history_endpoint_default_hours(self, _db, client): - await _insert_repeater(KEY_A) - resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - assert resp.status_code == 200 - - -class TestToggleEndpoint: - """Tests for the telemetry tracking toggle API endpoint.""" - - @pytest.mark.asyncio - async def test_toggle_endpoint(self, _db, client): - resp = await client.post( - "/api/settings/telemetry-tracked-keys/toggle", - json={"key": KEY_A}, - ) - assert resp.status_code == 200 - data = resp.json() - assert KEY_A in data["telemetry_tracked_keys"] - - # Toggle off - resp = await client.post( - "/api/settings/telemetry-tracked-keys/toggle", - json={"key": KEY_A}, - ) - assert resp.status_code == 200 - data = resp.json() - assert KEY_A not in data["telemetry_tracked_keys"] + # Either 404 (method not found) or 400 (not a repeater) — endpoint is gone + assert resp.status_code in (400, 404, 405)