From 1cfeb193c7f4a0d7b930969f86a296e0fd46e8b6 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 23:21:52 +0000 Subject: [PATCH] feat: add System Health monitoring mode Real-time dashboard for host metrics (CPU, memory, disk, temperatures), active decoder process status, and SDR device enumeration via SSE streaming. Auto-connects when entering the mode with graceful psutil fallback. Co-Authored-By: Claude Opus 4.6 --- requirements.txt | 3 + routes/__init__.py | 2 + routes/system.py | 323 +++++++++++++++++++++++++++ static/css/modes/system.css | 215 ++++++++++++++++++ static/js/modes/system.js | 300 +++++++++++++++++++++++++ templates/index.html | 52 ++++- templates/partials/modes/system.html | 52 +++++ templates/partials/nav.html | 15 ++ tests/test_system.py | 89 ++++++++ 9 files changed, 1047 insertions(+), 4 deletions(-) create mode 100644 routes/system.py create mode 100644 static/css/modes/system.css create mode 100644 static/js/modes/system.js create mode 100644 templates/partials/modes/system.html create mode 100644 tests/test_system.py 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 = + '
CPU
' + + '
' + + 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 = + '
Memory
' + + '
' + + 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 = + '
Disk
' + + '
' + + 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 = '
SDR Devices
'; + 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 = '
Processes
'; + 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 = '
System Info
'; + 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 = '
SDR Devices
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… +
+ +
+ + +
+

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 #} +
+ + +
+ {{ mode_item('system', 'Health', '') }} +
+
+ {# Dynamic dashboard button (shown when in satellite mode) #}