mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
323
routes/system.py
Normal file
323
routes/system.py
Normal file
@@ -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)})
|
||||
215
static/css/modes/system.css
Normal file
215
static/css/modes/system.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
300
static/js/modes/system.js
Normal file
300
static/js/modes/system.js
Normal file
@@ -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 '<span class="sys-metric-na">N/A</span>';
|
||||
const cls = barClass(pct);
|
||||
const rounded = Math.round(pct);
|
||||
return '<div class="sys-metric-bar-wrap">' +
|
||||
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
|
||||
'<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' +
|
||||
'<span class="sys-metric-bar-value">' + rounded + '%</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderCpuCard(m) {
|
||||
const el = document.getElementById('sysCardCpu');
|
||||
if (!el) return;
|
||||
const cpu = m.cpu;
|
||||
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">CPU</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(cpu.percent, '') +
|
||||
'<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
|
||||
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderMemoryCard(m) {
|
||||
const el = document.getElementById('sysCardMemory');
|
||||
if (!el) return;
|
||||
const mem = m.memory;
|
||||
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
const swap = m.swap || {};
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Memory</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(mem.percent, '') +
|
||||
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderDiskCard(m) {
|
||||
const el = document.getElementById('sysCardDisk');
|
||||
if (!el) return;
|
||||
const disk = m.disk;
|
||||
if (!disk) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Disk</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(disk.percent, '') +
|
||||
'<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Path: ' + (disk.path || '/') + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
|
||||
html += '<div class="sys-card-body">';
|
||||
if (!devices || !devices.length) {
|
||||
html += '<span class="sys-metric-na">No devices found</span>';
|
||||
} else {
|
||||
devices.forEach(function (d) {
|
||||
html += '<div class="sys-sdr-device">' +
|
||||
'<span class="sys-process-dot running"></span> ' +
|
||||
'<strong>' + d.type + ' #' + d.index + '</strong>' +
|
||||
'<div class="sys-card-detail">' + (d.name || 'Unknown') + '</div>' +
|
||||
(d.serial ? '<div class="sys-card-detail">S/N: ' + d.serial + '</div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
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 = '<div class="sys-card-header">Processes</div><div class="sys-card-body">';
|
||||
if (!keys.length) {
|
||||
html += '<span class="sys-metric-na">No data</span>';
|
||||
} else {
|
||||
keys.forEach(function (k) {
|
||||
const running = procs[k];
|
||||
const dotCls = running ? 'running' : 'stopped';
|
||||
const label = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
html += '<div class="sys-process-item">' +
|
||||
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
|
||||
'<span class="sys-process-name">' + label + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
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 = '<div class="sys-card-header">System Info</div><div class="sys-card-body">';
|
||||
html += '<div class="sys-card-detail">Host: ' + (sys.hostname || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">OS: ' + (sys.platform || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Python: ' + (sys.python || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">App: v' + (sys.version || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Uptime: ' + (sys.uptime_human || '--') + '</div>';
|
||||
if (temp) {
|
||||
html += '<div class="sys-card-detail">Temp: ' + Math.round(temp.current) + '°C';
|
||||
if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
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 ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
|
||||
(running.length && stopped.length ? ' · ' : '') +
|
||||
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
|
||||
}
|
||||
|
||||
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 = '<div class="sys-card-header">SDR Devices</div><div class="sys-card-body">Scanning…</div>';
|
||||
|
||||
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 = '<span style="color: var(--text-dim);">No SDR devices found</span>';
|
||||
} else {
|
||||
var html = '';
|
||||
devices.forEach(function (d) {
|
||||
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
|
||||
d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '</div>';
|
||||
});
|
||||
sidebarEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (sidebarEl) sidebarEl.innerHTML = '<span style="color: var(--accent-red, #ff3366);">Detection failed</span>';
|
||||
renderSdrCard([]);
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
connect();
|
||||
refreshSdr();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
refreshSdr: refreshSdr,
|
||||
};
|
||||
})();
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Visuals -->
|
||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||
<div class="sys-dashboard">
|
||||
<div class="sys-card" id="sysCardCpu">
|
||||
<div class="sys-card-header">CPU</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardMemory">
|
||||
<div class="sys-card-header">Memory</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardDisk">
|
||||
<div class="sys-card-header">Disk</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardSdr">
|
||||
<div class="sys-card-header">SDR Devices</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardProcesses">
|
||||
<div class="sys-card-header">Processes</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardInfo">
|
||||
<div class="sys-card-header">System Info</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -3196,6 +3228,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
@@ -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
|
||||
|
||||
52
templates/partials/modes/system.html
Normal file
52
templates/partials/modes/system.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!-- SYSTEM HEALTH MODE -->
|
||||
<div id="systemMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>System Health</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Real-time monitoring of host resources, active decoders, and SDR hardware.
|
||||
Auto-connects when entering this mode.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status Grid -->
|
||||
<div class="section">
|
||||
<h3>Quick Status</h3>
|
||||
<div class="sys-quick-grid">
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">CPU</span>
|
||||
<span class="sys-quick-value" id="sysQuickCpu">--%</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">Temp</span>
|
||||
<span class="sys-quick-value" id="sysQuickTemp">--°C</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">RAM</span>
|
||||
<span class="sys-quick-value" id="sysQuickRam">--%</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">Disk</span>
|
||||
<span class="sys-quick-value" id="sysQuickDisk">--%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SDR Devices -->
|
||||
<div class="section">
|
||||
<h3>SDR Devices</h3>
|
||||
<div id="sysSdrList" style="font-size: 11px; color: var(--text-dim);">
|
||||
Scanning…
|
||||
</div>
|
||||
<button class="run-btn" style="width: 100%; margin-top: 8px;" onclick="SystemHealth.refreshSdr()">
|
||||
Rescan SDR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Processes -->
|
||||
<div class="section">
|
||||
<h3>Active Processes</h3>
|
||||
<div id="sysProcessList" style="font-size: 11px; color: var(--text-dim);">
|
||||
Waiting for data…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +140,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# System Group #}
|
||||
<div class="mode-nav-dropdown" data-group="system">
|
||||
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}>
|
||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||
<span class="nav-label">System</span>
|
||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('system', 'Health', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||
<div class="mode-nav-actions">
|
||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
||||
@@ -230,6 +243,8 @@
|
||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{# New modes #}
|
||||
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||
{# System #}
|
||||
{{ mobile_item('system', 'System', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>') }}
|
||||
</nav>
|
||||
|
||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||
|
||||
89
tests/test_system.py
Normal file
89
tests/test_system.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user