diff --git a/app/routers/health.py b/app/routers/health.py index 1427939..4b38e48 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel): # Core stats battery_mv: int | None = None uptime_secs: int | None = None + queue_len: int | None = None + errors: int | None = None # Radio stats noise_floor: int | None = None last_rssi: int | None = None @@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) "timestamp": raw_stats.get("timestamp"), "battery_mv": raw_stats.get("battery_mv"), "uptime_secs": raw_stats.get("uptime_secs"), + "queue_len": raw_stats.get("queue_len"), + "errors": raw_stats.get("errors"), "noise_floor": raw_stats.get("noise_floor"), "last_rssi": raw_stats.get("last_rssi"), "last_snr": raw_stats.get("last_snr"), diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index d0c937b..09a015a 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { MapPinned } from 'lucide-react'; +import { ChevronDown, MapPinned } from 'lucide-react'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Button } from '../ui/button'; @@ -17,8 +17,116 @@ import type { RadioConfigUpdate, RadioDiscoveryResponse, RadioDiscoveryTarget, + RadioStatsSnapshot, } from '../../types'; +function formatUptime(secs: number): string { + const days = Math.floor(secs / 86400); + const hours = Math.floor((secs % 86400) / 3600); + const minutes = Math.floor((secs % 3600) / 60); + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +function formatAirtime(secs: number): string { + if (secs < 60) return `${secs}s`; + const hours = Math.floor(secs / 3600); + const minutes = Math.floor((secs % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) { + const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null; + const packets = { + recv: stats.packets_recv, + sent: stats.packets_sent, + flood_tx: stats.flood_tx, + direct_tx: stats.direct_tx, + flood_rx: stats.flood_rx, + direct_rx: stats.direct_rx, + }; + + return ( +
+ + + Radio Details + +
+ {age !== null && ( +

+ Updated {age < 5 ? 'just now' : `${age}s ago`} +

+ )} + + {/* Core */} + {stats.uptime_secs != null && ( + + )} + {stats.battery_mv != null && stats.battery_mv > 0 && ( + + )} + {stats.queue_len != null && ( + = 14} + /> + )} + {stats.errors != null && ( + 0} /> + )} + + {/* RF */} + {stats.noise_floor != null && ( + + )} + {stats.last_rssi != null && } + {stats.last_snr != null && } + + {/* Airtime */} + {(stats.tx_air_secs != null || stats.rx_air_secs != null) && ( + <> + {stats.tx_air_secs != null && ( + + )} + {stats.rx_air_secs != null && ( + + )} + + )} + + {/* Packets */} + {packets.recv != null && } + {packets.sent != null && } + {packets.flood_tx != null && } + {packets.flood_rx != null && } + {packets.direct_tx != null && ( + + )} + {packets.direct_rx != null && ( + + )} +
+
+ ); +} + export function SettingsRadioSection({ config, health, @@ -414,6 +522,9 @@ export function SettingsRadioSection({ {deviceInfoLabel &&

{deviceInfoLabel}

} + + {health?.radio_stats && } +