diff --git a/requirements.txt b/requirements.txt
index cb05331..6b9fdfb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -44,3 +44,6 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
+
+# System health monitoring (optional - graceful fallback if unavailable)
+psutil>=5.9.0
diff --git a/routes/__init__.py b/routes/__init__.py
index e1e1319..3a00b91 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -30,6 +30,7 @@ def register_blueprints(app):
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .subghz import subghz_bp
+ from .system import system_bp
from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp
from .vdl2 import vdl2_bp
@@ -75,6 +76,7 @@ def register_blueprints(app):
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(morse_bp) # CW/Morse code decoder
+ app.register_blueprint(system_bp) # System health monitoring
# Initialize TSCM state with queue and lock from app
import app as app_module
diff --git a/routes/system.py b/routes/system.py
new file mode 100644
index 0000000..839899d
--- /dev/null
+++ b/routes/system.py
@@ -0,0 +1,323 @@
+"""System Health monitoring blueprint.
+
+Provides real-time system metrics (CPU, memory, disk, temperatures),
+active process status, and SDR device enumeration via SSE streaming.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import platform
+import queue
+import socket
+import threading
+import time
+from typing import Any
+
+from flask import Blueprint, Response, jsonify
+
+from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
+from utils.logging import sensor_logger as logger
+from utils.sse import sse_stream_fanout
+
+try:
+ import psutil
+
+ _HAS_PSUTIL = True
+except ImportError:
+ psutil = None # type: ignore[assignment]
+ _HAS_PSUTIL = False
+
+system_bp = Blueprint('system', __name__, url_prefix='/system')
+
+# ---------------------------------------------------------------------------
+# Background metrics collector
+# ---------------------------------------------------------------------------
+
+_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
+_collector_started = False
+_collector_lock = threading.Lock()
+_app_start_time: float | None = None
+
+
+def _get_app_start_time() -> float:
+ """Return the application start timestamp from the main app module."""
+ global _app_start_time
+ if _app_start_time is None:
+ try:
+ import app as app_module
+
+ _app_start_time = getattr(app_module, '_app_start_time', time.time())
+ except Exception:
+ _app_start_time = time.time()
+ return _app_start_time
+
+
+def _get_app_version() -> str:
+ """Return the application version string."""
+ try:
+ from config import VERSION
+
+ return VERSION
+ except Exception:
+ return 'unknown'
+
+
+def _format_uptime(seconds: float) -> str:
+ """Format seconds into a human-readable uptime string."""
+ days = int(seconds // 86400)
+ hours = int((seconds % 86400) // 3600)
+ minutes = int((seconds % 3600) // 60)
+ parts = []
+ if days > 0:
+ parts.append(f'{days}d')
+ if hours > 0:
+ parts.append(f'{hours}h')
+ parts.append(f'{minutes}m')
+ return ' '.join(parts)
+
+
+def _collect_process_status() -> dict[str, bool]:
+ """Return running/stopped status for each decoder process.
+
+ Mirrors the logic in app.py health_check().
+ """
+ try:
+ import app as app_module
+
+ def _alive(attr: str) -> bool:
+ proc = getattr(app_module, attr, None)
+ if proc is None:
+ return False
+ try:
+ return proc.poll() is None
+ except Exception:
+ return False
+
+ processes: dict[str, bool] = {
+ 'pager': _alive('current_process'),
+ 'sensor': _alive('sensor_process'),
+ 'adsb': _alive('adsb_process'),
+ 'ais': _alive('ais_process'),
+ 'acars': _alive('acars_process'),
+ 'vdl2': _alive('vdl2_process'),
+ 'aprs': _alive('aprs_process'),
+ 'dsc': _alive('dsc_process'),
+ 'morse': _alive('morse_process'),
+ }
+
+ # WiFi
+ try:
+ from app import _get_wifi_health
+
+ wifi_active, _, _ = _get_wifi_health()
+ processes['wifi'] = wifi_active
+ except Exception:
+ processes['wifi'] = False
+
+ # Bluetooth
+ try:
+ from app import _get_bluetooth_health
+
+ bt_active, _ = _get_bluetooth_health()
+ processes['bluetooth'] = bt_active
+ except Exception:
+ processes['bluetooth'] = False
+
+ # SubGHz
+ try:
+ from app import _get_subghz_active
+
+ processes['subghz'] = _get_subghz_active()
+ except Exception:
+ processes['subghz'] = False
+
+ return processes
+ except Exception:
+ return {}
+
+
+def _collect_metrics() -> dict[str, Any]:
+ """Gather a snapshot of system metrics."""
+ now = time.time()
+ start = _get_app_start_time()
+ uptime_seconds = round(now - start, 2)
+
+ metrics: dict[str, Any] = {
+ 'type': 'system_metrics',
+ 'timestamp': now,
+ 'system': {
+ 'hostname': socket.gethostname(),
+ 'platform': platform.platform(),
+ 'python': platform.python_version(),
+ 'version': _get_app_version(),
+ 'uptime_seconds': uptime_seconds,
+ 'uptime_human': _format_uptime(uptime_seconds),
+ },
+ 'processes': _collect_process_status(),
+ }
+
+ if _HAS_PSUTIL:
+ # CPU
+ cpu_percent = psutil.cpu_percent(interval=None)
+ cpu_count = psutil.cpu_count() or 1
+ try:
+ load_1, load_5, load_15 = os.getloadavg()
+ except (OSError, AttributeError):
+ load_1 = load_5 = load_15 = 0.0
+
+ metrics['cpu'] = {
+ 'percent': cpu_percent,
+ 'count': cpu_count,
+ 'load_1': round(load_1, 2),
+ 'load_5': round(load_5, 2),
+ 'load_15': round(load_15, 2),
+ }
+
+ # Memory
+ mem = psutil.virtual_memory()
+ metrics['memory'] = {
+ 'total': mem.total,
+ 'used': mem.used,
+ 'available': mem.available,
+ 'percent': mem.percent,
+ }
+
+ swap = psutil.swap_memory()
+ metrics['swap'] = {
+ 'total': swap.total,
+ 'used': swap.used,
+ 'percent': swap.percent,
+ }
+
+ # Disk
+ try:
+ disk = psutil.disk_usage('/')
+ metrics['disk'] = {
+ 'total': disk.total,
+ 'used': disk.used,
+ 'free': disk.free,
+ 'percent': disk.percent,
+ 'path': '/',
+ }
+ except Exception:
+ metrics['disk'] = None
+
+ # Temperatures
+ try:
+ temps = psutil.sensors_temperatures()
+ if temps:
+ temp_data: dict[str, list[dict[str, Any]]] = {}
+ for chip, entries in temps.items():
+ temp_data[chip] = [
+ {
+ 'label': e.label or chip,
+ 'current': e.current,
+ 'high': e.high,
+ 'critical': e.critical,
+ }
+ for e in entries
+ ]
+ metrics['temperatures'] = temp_data
+ else:
+ metrics['temperatures'] = None
+ except (AttributeError, Exception):
+ metrics['temperatures'] = None
+ else:
+ metrics['cpu'] = None
+ metrics['memory'] = None
+ metrics['swap'] = None
+ metrics['disk'] = None
+ metrics['temperatures'] = None
+
+ return metrics
+
+
+def _collector_loop() -> None:
+ """Background thread that pushes metrics onto the queue every 3 seconds."""
+ # Seed psutil's CPU measurement so the first real read isn't 0%.
+ if _HAS_PSUTIL:
+ with contextlib.suppress(Exception):
+ psutil.cpu_percent(interval=None)
+
+ while True:
+ try:
+ metrics = _collect_metrics()
+ # Non-blocking put — drop oldest if full
+ try:
+ _metrics_queue.put_nowait(metrics)
+ except queue.Full:
+ with contextlib.suppress(queue.Empty):
+ _metrics_queue.get_nowait()
+ _metrics_queue.put_nowait(metrics)
+ except Exception as exc:
+ logger.debug('system metrics collection error: %s', exc)
+ time.sleep(3)
+
+
+def _ensure_collector() -> None:
+ """Start the background collector thread once."""
+ global _collector_started
+ if _collector_started:
+ return
+ with _collector_lock:
+ if _collector_started:
+ return
+ t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
+ t.start()
+ _collector_started = True
+ logger.info('System metrics collector started')
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+
+@system_bp.route('/metrics')
+def get_metrics() -> Response:
+ """REST snapshot of current system metrics."""
+ _ensure_collector()
+ return jsonify(_collect_metrics())
+
+
+@system_bp.route('/stream')
+def stream_system() -> Response:
+ """SSE stream for real-time system metrics."""
+ _ensure_collector()
+
+ response = Response(
+ sse_stream_fanout(
+ source_queue=_metrics_queue,
+ channel_key='system',
+ timeout=SSE_QUEUE_TIMEOUT,
+ keepalive_interval=SSE_KEEPALIVE_INTERVAL,
+ ),
+ mimetype='text/event-stream',
+ )
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ return response
+
+
+@system_bp.route('/sdr_devices')
+def get_sdr_devices() -> Response:
+ """Enumerate all connected SDR devices (on-demand, not every tick)."""
+ try:
+ from utils.sdr.detection import detect_all_devices
+
+ devices = detect_all_devices()
+ result = []
+ for d in devices:
+ result.append({
+ 'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
+ 'index': d.index,
+ 'name': d.name,
+ 'serial': d.serial or '',
+ 'driver': d.driver or '',
+ })
+ return jsonify({'devices': result})
+ except Exception as exc:
+ logger.warning('SDR device detection failed: %s', exc)
+ return jsonify({'devices': [], 'error': str(exc)})
diff --git a/static/css/modes/system.css b/static/css/modes/system.css
new file mode 100644
index 0000000..3efd245
--- /dev/null
+++ b/static/css/modes/system.css
@@ -0,0 +1,215 @@
+/* System Health Mode Styles */
+
+.sys-dashboard {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 16px;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.sys-card {
+ background: var(--bg-card, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a4a);
+ border-radius: 6px;
+ padding: 16px;
+ min-height: 120px;
+}
+
+.sys-card-header {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #8888aa);
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.sys-card-body {
+ font-size: 12px;
+ color: var(--text-primary, #e0e0ff);
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+}
+
+.sys-card-detail {
+ font-size: 11px;
+ color: var(--text-dim, #8888aa);
+ margin-top: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Metric Bars */
+.sys-metric-bar-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.sys-metric-bar-label {
+ font-size: 10px;
+ color: var(--text-dim, #8888aa);
+ min-width: 40px;
+ text-transform: uppercase;
+}
+
+.sys-metric-bar {
+ flex: 1;
+ height: 8px;
+ background: var(--bg-primary, #0d0d1a);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.sys-metric-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.4s ease;
+}
+
+.sys-metric-bar-fill.ok {
+ background: var(--accent-green, #00ff88);
+}
+
+.sys-metric-bar-fill.warn {
+ background: var(--accent-yellow, #ffcc00);
+}
+
+.sys-metric-bar-fill.crit {
+ background: var(--accent-red, #ff3366);
+}
+
+.sys-metric-bar-value {
+ font-size: 12px;
+ font-weight: 700;
+ min-width: 36px;
+ text-align: right;
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+}
+
+.sys-metric-na {
+ color: var(--text-dim, #8888aa);
+ font-style: italic;
+ font-size: 11px;
+}
+
+/* Process items */
+.sys-process-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 0;
+}
+
+.sys-process-name {
+ font-size: 12px;
+}
+
+.sys-process-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sys-process-dot.running {
+ background: var(--accent-green, #00ff88);
+ box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
+}
+
+.sys-process-dot.stopped {
+ background: var(--text-dim, #555);
+}
+
+/* SDR Devices */
+.sys-sdr-device {
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border-color, #2a2a4a);
+}
+
+.sys-sdr-device:last-child {
+ border-bottom: none;
+}
+
+.sys-rescan-btn {
+ font-size: 9px;
+ padding: 2px 8px;
+ background: transparent;
+ border: 1px solid var(--border-color, #2a2a4a);
+ color: var(--accent-cyan, #00d4ff);
+ border-radius: 3px;
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.sys-rescan-btn:hover {
+ background: var(--bg-primary, #0d0d1a);
+}
+
+/* Sidebar Quick Grid */
+.sys-quick-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.sys-quick-item {
+ padding: 6px 8px;
+ background: var(--bg-primary, #0d0d1a);
+ border: 1px solid var(--border-color, #2a2a4a);
+ border-radius: 4px;
+ text-align: center;
+}
+
+.sys-quick-label {
+ display: block;
+ font-size: 9px;
+ color: var(--text-dim, #8888aa);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 2px;
+}
+
+.sys-quick-value {
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+ color: var(--text-primary, #e0e0ff);
+}
+
+/* Color-coded quick values */
+.sys-val-ok {
+ color: var(--accent-green, #00ff88) !important;
+}
+
+.sys-val-warn {
+ color: var(--accent-yellow, #ffcc00) !important;
+}
+
+.sys-val-crit {
+ color: var(--accent-red, #ff3366) !important;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sys-dashboard {
+ grid-template-columns: 1fr;
+ padding: 8px;
+ gap: 10px;
+ }
+}
+
+@media (max-width: 1024px) and (min-width: 769px) {
+ .sys-dashboard {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
diff --git a/static/js/modes/system.js b/static/js/modes/system.js
new file mode 100644
index 0000000..1aab9aa
--- /dev/null
+++ b/static/js/modes/system.js
@@ -0,0 +1,300 @@
+/**
+ * System Health – IIFE module
+ *
+ * Always-on monitoring that auto-connects when the mode is entered.
+ * Streams real-time system metrics via SSE and provides SDR device enumeration.
+ */
+const SystemHealth = (function () {
+ 'use strict';
+
+ let eventSource = null;
+ let connected = false;
+ let lastMetrics = null;
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ function formatBytes(bytes) {
+ if (bytes == null) return '--';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let val = bytes;
+ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
+ return val.toFixed(1) + ' ' + units[i];
+ }
+
+ function barClass(pct) {
+ if (pct >= 85) return 'crit';
+ if (pct >= 60) return 'warn';
+ return 'ok';
+ }
+
+ function barHtml(pct, label) {
+ if (pct == null) return 'N/A ';
+ const cls = barClass(pct);
+ const rounded = Math.round(pct);
+ return '
' +
+ (label ? '
' + label + ' ' : '') +
+ '
' +
+ '
' + rounded + '% ' +
+ '
';
+ }
+
+ // -----------------------------------------------------------------------
+ // Rendering
+ // -----------------------------------------------------------------------
+
+ function renderCpuCard(m) {
+ const el = document.getElementById('sysCardCpu');
+ if (!el) return;
+ const cpu = m.cpu;
+ if (!cpu) { el.innerHTML = 'psutil not available
'; return; }
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(cpu.percent, '') +
+ '
Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '
' +
+ '
Cores: ' + cpu.count + '
' +
+ '
';
+ }
+
+ function renderMemoryCard(m) {
+ const el = document.getElementById('sysCardMemory');
+ if (!el) return;
+ const mem = m.memory;
+ if (!mem) { el.innerHTML = 'N/A
'; return; }
+ const swap = m.swap || {};
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(mem.percent, '') +
+ '
' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '
' +
+ '
Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' +
+ '
';
+ }
+
+ function renderDiskCard(m) {
+ const el = document.getElementById('sysCardDisk');
+ if (!el) return;
+ const disk = m.disk;
+ if (!disk) { el.innerHTML = 'N/A
'; return; }
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(disk.percent, '') +
+ '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
' +
+ '
Path: ' + (disk.path || '/') + '
' +
+ '
';
+ }
+
+ function _extractPrimaryTemp(temps) {
+ if (!temps) return null;
+ // Prefer common chip names
+ const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
+ for (const name of preferred) {
+ if (temps[name] && temps[name].length) return temps[name][0];
+ }
+ // Fall back to first available
+ for (const key of Object.keys(temps)) {
+ if (temps[key] && temps[key].length) return temps[key][0];
+ }
+ return null;
+ }
+
+ function renderSdrCard(devices) {
+ const el = document.getElementById('sysCardSdr');
+ if (!el) return;
+ let html = '';
+ html += '';
+ if (!devices || !devices.length) {
+ html += '
No devices found ';
+ } else {
+ devices.forEach(function (d) {
+ html += '
' +
+ '
' +
+ '
' + d.type + ' #' + d.index + ' ' +
+ '
' + (d.name || 'Unknown') + '
' +
+ (d.serial ? '
S/N: ' + d.serial + '
' : '') +
+ '
';
+ });
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function renderProcessCard(m) {
+ const el = document.getElementById('sysCardProcesses');
+ if (!el) return;
+ const procs = m.processes || {};
+ const keys = Object.keys(procs).sort();
+ let html = '';
+ if (!keys.length) {
+ html += '
No data ';
+ } else {
+ keys.forEach(function (k) {
+ const running = procs[k];
+ const dotCls = running ? 'running' : 'stopped';
+ const label = k.charAt(0).toUpperCase() + k.slice(1);
+ html += '
' +
+ ' ' +
+ '' + label + ' ' +
+ '
';
+ });
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function renderSystemInfoCard(m) {
+ const el = document.getElementById('sysCardInfo');
+ if (!el) return;
+ const sys = m.system || {};
+ const temp = _extractPrimaryTemp(m.temperatures);
+ let html = '';
+ html += '
Host: ' + (sys.hostname || '--') + '
';
+ html += '
OS: ' + (sys.platform || '--') + '
';
+ html += '
Python: ' + (sys.python || '--') + '
';
+ html += '
App: v' + (sys.version || '--') + '
';
+ html += '
Uptime: ' + (sys.uptime_human || '--') + '
';
+ if (temp) {
+ html += '
Temp: ' + Math.round(temp.current) + '°C';
+ if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
+ html += '
';
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function updateSidebarQuickStats(m) {
+ const cpuEl = document.getElementById('sysQuickCpu');
+ const tempEl = document.getElementById('sysQuickTemp');
+ const ramEl = document.getElementById('sysQuickRam');
+ const diskEl = document.getElementById('sysQuickDisk');
+
+ if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
+ if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
+ if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--';
+
+ const temp = _extractPrimaryTemp(m.temperatures);
+ if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--';
+
+ // Color-code values
+ [cpuEl, ramEl, diskEl].forEach(function (el) {
+ if (!el) return;
+ const val = parseInt(el.textContent);
+ el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
+ if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
+ });
+ }
+
+ function updateSidebarProcesses(m) {
+ const el = document.getElementById('sysProcessList');
+ if (!el) return;
+ const procs = m.processes || {};
+ const keys = Object.keys(procs).sort();
+ if (!keys.length) { el.textContent = 'No data'; return; }
+ const running = keys.filter(function (k) { return procs[k]; });
+ const stopped = keys.filter(function (k) { return !procs[k]; });
+ el.innerHTML =
+ (running.length ? '' + running.length + ' running ' : '') +
+ (running.length && stopped.length ? ' · ' : '') +
+ (stopped.length ? '' + stopped.length + ' stopped ' : '');
+ }
+
+ function renderAll(m) {
+ renderCpuCard(m);
+ renderMemoryCard(m);
+ renderDiskCard(m);
+ renderProcessCard(m);
+ renderSystemInfoCard(m);
+ updateSidebarQuickStats(m);
+ updateSidebarProcesses(m);
+ }
+
+ // -----------------------------------------------------------------------
+ // SSE Connection
+ // -----------------------------------------------------------------------
+
+ function connect() {
+ if (eventSource) return;
+ eventSource = new EventSource('/system/stream');
+ eventSource.onmessage = function (e) {
+ try {
+ var data = JSON.parse(e.data);
+ if (data.type === 'keepalive') return;
+ lastMetrics = data;
+ renderAll(data);
+ } catch (_) { /* ignore parse errors */ }
+ };
+ eventSource.onopen = function () {
+ connected = true;
+ };
+ eventSource.onerror = function () {
+ connected = false;
+ };
+ }
+
+ function disconnect() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ connected = false;
+ }
+
+ // -----------------------------------------------------------------------
+ // SDR Devices
+ // -----------------------------------------------------------------------
+
+ function refreshSdr() {
+ var sidebarEl = document.getElementById('sysSdrList');
+ if (sidebarEl) sidebarEl.innerHTML = 'Scanning…';
+
+ var cardEl = document.getElementById('sysCardSdr');
+ if (cardEl) cardEl.innerHTML = 'Scanning…
';
+
+ fetch('/system/sdr_devices')
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ var devices = data.devices || [];
+ renderSdrCard(devices);
+ // Update sidebar
+ if (sidebarEl) {
+ if (!devices.length) {
+ sidebarEl.innerHTML = 'No SDR devices found ';
+ } else {
+ var html = '';
+ devices.forEach(function (d) {
+ html += ' ' +
+ d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '
';
+ });
+ sidebarEl.innerHTML = html;
+ }
+ }
+ })
+ .catch(function () {
+ if (sidebarEl) sidebarEl.innerHTML = 'Detection failed ';
+ renderSdrCard([]);
+ });
+ }
+
+ // -----------------------------------------------------------------------
+ // Public API
+ // -----------------------------------------------------------------------
+
+ function init() {
+ connect();
+ refreshSdr();
+ }
+
+ function destroy() {
+ disconnect();
+ }
+
+ return {
+ init: init,
+ destroy: destroy,
+ refreshSdr: refreshSdr,
+ };
+})();
diff --git a/templates/index.html b/templates/index.html
index 6e16d15..ddc1b96 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -82,7 +82,8 @@
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
- morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
+ morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
+ system: "{{ url_for('static', filename='css/modes/system.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
@@ -705,6 +706,7 @@
{% include 'partials/modes/bt_locate.html' %}
{% include 'partials/modes/waterfall.html' %}
+ {% include 'partials/modes/system.html' %}
@@ -3125,6 +3127,36 @@
+
+
+
@@ -3196,6 +3228,7 @@
+
@@ -3349,6 +3382,7 @@
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
+ system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -4128,6 +4162,7 @@
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
+ document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
const pagerStats = document.getElementById('pagerStats');
@@ -4169,6 +4204,7 @@
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
+ const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4186,6 +4222,7 @@
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
+ if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4242,11 +4279,16 @@
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
}
+ // Disconnect System Health SSE when leaving the mode
+ if (mode !== 'system') {
+ if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
+ }
+
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
- if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
+ if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'system') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -4297,8 +4339,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
- if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block';
- if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex';
+ if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'block';
+ if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -4372,6 +4414,8 @@
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
+ } else if (mode === 'system') {
+ SystemHealth.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes
diff --git a/templates/partials/modes/system.html b/templates/partials/modes/system.html
new file mode 100644
index 0000000..a750e11
--- /dev/null
+++ b/templates/partials/modes/system.html
@@ -0,0 +1,52 @@
+
+
+
+
System Health
+
+ Real-time monitoring of host resources, active decoders, and SDR hardware.
+ Auto-connects when entering this mode.
+
+
+
+
+
+
Quick Status
+
+
+ CPU
+ --%
+
+
+ Temp
+ --°C
+
+
+ RAM
+ --%
+
+
+ Disk
+ --%
+
+
+
+
+
+
+
SDR Devices
+
+ Scanning…
+
+
+ Rescan SDR
+
+
+
+
+
+
Active Processes
+
+ Waiting for data…
+
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index 7cc4fff..68a6d6c 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -140,6 +140,19 @@
+ {# System Group #}
+
+
{# Dynamic dashboard button (shown when in satellite mode) #}
@@ -230,6 +243,8 @@
{{ mobile_item('websdr', 'WebSDR', ' ') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', ' ') }}
+ {# System #}
+ {{ mobile_item('system', 'System', ' ') }}
{# JavaScript stub for pages that don't have switchMode defined #}
diff --git a/tests/test_system.py b/tests/test_system.py
new file mode 100644
index 0000000..6d2ea59
--- /dev/null
+++ b/tests/test_system.py
@@ -0,0 +1,89 @@
+"""Tests for the System Health monitoring blueprint."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+
+def _login(client):
+ """Mark the Flask test session as authenticated."""
+ with client.session_transaction() as sess:
+ sess['logged_in'] = True
+ sess['username'] = 'test'
+ sess['role'] = 'admin'
+
+
+def test_metrics_returns_expected_keys(client):
+ """GET /system/metrics returns top-level metric keys."""
+ _login(client)
+ resp = client.get('/system/metrics')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert 'system' in data
+ assert 'processes' in data
+ assert 'cpu' in data
+ assert 'memory' in data
+ assert 'disk' in data
+ assert data['system']['hostname']
+ assert 'version' in data['system']
+ assert 'uptime_seconds' in data['system']
+ assert 'uptime_human' in data['system']
+
+
+def test_metrics_without_psutil(client):
+ """Metrics degrade gracefully when psutil is unavailable."""
+ _login(client)
+ import routes.system as mod
+
+ orig = mod._HAS_PSUTIL
+ mod._HAS_PSUTIL = False
+ try:
+ resp = client.get('/system/metrics')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ # These fields should be None without psutil
+ assert data['cpu'] is None
+ assert data['memory'] is None
+ assert data['disk'] is None
+ finally:
+ mod._HAS_PSUTIL = orig
+
+
+def test_sdr_devices_returns_list(client):
+ """GET /system/sdr_devices returns a devices list."""
+ _login(client)
+ mock_device = MagicMock()
+ mock_device.sdr_type = MagicMock()
+ mock_device.sdr_type.value = 'rtlsdr'
+ mock_device.index = 0
+ mock_device.name = 'Generic RTL2832U'
+ mock_device.serial = '00000001'
+ mock_device.driver = 'rtlsdr'
+
+ with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
+ resp = client.get('/system/sdr_devices')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert 'devices' in data
+ assert len(data['devices']) == 1
+ assert data['devices'][0]['type'] == 'rtlsdr'
+ assert data['devices'][0]['name'] == 'Generic RTL2832U'
+
+
+def test_sdr_devices_handles_detection_failure(client):
+ """SDR detection failure returns empty list with error."""
+ _login(client)
+ with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
+ resp = client.get('/system/sdr_devices')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data['devices'] == []
+ assert 'error' in data
+
+
+def test_stream_returns_sse_content_type(client):
+ """GET /system/stream returns text/event-stream."""
+ _login(client)
+ resp = client.get('/system/stream')
+ assert resp.status_code == 200
+ assert 'text/event-stream' in resp.content_type