feat: enhance System Health dashboard with rich telemetry and visualizations

Add SVG arc gauge, per-core CPU bars, temperature sparkline, network
interface monitoring with bandwidth deltas, disk I/O rates, 3D globe
with observer location, weather overlay, battery/fan/throttle support,
and process grid layout. New /system/location and /system/weather endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-26 23:42:45 +00:00
parent 1cfeb193c7
commit 0a90010c1f
6 changed files with 1344 additions and 87 deletions

View File

@@ -1,7 +1,8 @@
"""System Health monitoring blueprint.
Provides real-time system metrics (CPU, memory, disk, temperatures),
active process status, and SDR device enumeration via SSE streaming.
Provides real-time system metrics (CPU, memory, disk, temperatures,
network, battery, fans), active process status, SDR device enumeration,
location, and weather data via SSE streaming and REST endpoints.
"""
from __future__ import annotations
@@ -11,11 +12,13 @@ import os
import platform
import queue
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from flask import Blueprint, Response, jsonify
from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger
@@ -29,6 +32,11 @@ except ImportError:
psutil = None # type: ignore[assignment]
_HAS_PSUTIL = False
try:
import requests as _requests
except ImportError:
_requests = None # type: ignore[assignment]
system_bp = Blueprint('system', __name__, url_prefix='/system')
# ---------------------------------------------------------------------------
@@ -40,6 +48,11 @@ _collector_started = False
_collector_lock = threading.Lock()
_app_start_time: float | None = None
# Weather cache
_weather_cache: dict[str, Any] = {}
_weather_cache_time: float = 0.0
_WEATHER_CACHE_TTL = 600 # 10 minutes
def _get_app_start_time() -> float:
"""Return the application start timestamp from the main app module."""
@@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]:
return {}
def _collect_throttle_flags() -> str | None:
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
try:
result = subprocess.run(
['vcgencmd', 'get_throttled'],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0 and 'throttled=' in result.stdout:
return result.stdout.strip().split('=', 1)[1]
except Exception:
pass
return None
def _collect_power_draw() -> float | None:
"""Read power draw in watts from sysfs (Linux only)."""
try:
power_supply = Path('/sys/class/power_supply')
if not power_supply.exists():
return None
for supply_dir in power_supply.iterdir():
power_file = supply_dir / 'power_now'
if power_file.exists():
val = int(power_file.read_text().strip())
return round(val / 1_000_000, 2) # microwatts to watts
except Exception:
pass
return None
def _collect_metrics() -> dict[str, Any]:
"""Gather a snapshot of system metrics."""
now = time.time()
@@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]:
}
if _HAS_PSUTIL:
# CPU
# CPU — overall + per-core + frequency
cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count() or 1
try:
@@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]:
except (OSError, AttributeError):
load_1 = load_5 = load_15 = 0.0
per_core = []
with contextlib.suppress(Exception):
per_core = psutil.cpu_percent(interval=None, percpu=True)
freq_data = None
with contextlib.suppress(Exception):
freq = psutil.cpu_freq()
if freq:
freq_data = {
'current': round(freq.current, 0),
'min': round(freq.min, 0),
'max': round(freq.max, 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),
'per_core': per_core,
'freq': freq_data,
}
# Memory
@@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]:
'percent': swap.percent,
}
# Disk
# Disk — usage + I/O counters
try:
disk = psutil.disk_usage('/')
metrics['disk'] = {
@@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]:
except Exception:
metrics['disk'] = None
disk_io = None
with contextlib.suppress(Exception):
dio = psutil.disk_io_counters()
if dio:
disk_io = {
'read_bytes': dio.read_bytes,
'write_bytes': dio.write_bytes,
'read_count': dio.read_count,
'write_count': dio.write_count,
}
metrics['disk_io'] = disk_io
# Temperatures
try:
temps = psutil.sensors_temperatures()
@@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]:
metrics['temperatures'] = None
except (AttributeError, Exception):
metrics['temperatures'] = None
# Fans
fans_data = None
with contextlib.suppress(Exception):
fans = psutil.sensors_fans()
if fans:
fans_data = {}
for chip, entries in fans.items():
fans_data[chip] = [
{'label': e.label or chip, 'current': e.current}
for e in entries
]
metrics['fans'] = fans_data
# Battery
battery_data = None
with contextlib.suppress(Exception):
bat = psutil.sensors_battery()
if bat:
battery_data = {
'percent': bat.percent,
'plugged': bat.power_plugged,
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
}
metrics['battery'] = battery_data
# Network interfaces
net_ifaces: list[dict[str, Any]] = []
with contextlib.suppress(Exception):
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for iface_name in sorted(addrs.keys()):
if iface_name == 'lo':
continue
iface_info: dict[str, Any] = {'name': iface_name}
# Get addresses
for addr in addrs[iface_name]:
if addr.family == socket.AF_INET:
iface_info['ipv4'] = addr.address
elif addr.family == socket.AF_INET6:
iface_info.setdefault('ipv6', addr.address)
elif addr.family == psutil.AF_LINK:
iface_info['mac'] = addr.address
# Get stats
if iface_name in stats:
st = stats[iface_name]
iface_info['is_up'] = st.isup
iface_info['speed'] = st.speed # Mbps
iface_info['mtu'] = st.mtu
net_ifaces.append(iface_info)
metrics['network'] = {'interfaces': net_ifaces}
# Network I/O counters (raw — JS computes deltas)
net_io = None
with contextlib.suppress(Exception):
counters = psutil.net_io_counters(pernic=True)
if counters:
net_io = {}
for nic, c in counters.items():
if nic == 'lo':
continue
net_io[nic] = {
'bytes_sent': c.bytes_sent,
'bytes_recv': c.bytes_recv,
}
metrics['network']['io'] = net_io
# Connection count
conn_count = 0
with contextlib.suppress(Exception):
conn_count = len(psutil.net_connections())
metrics['network']['connections'] = conn_count
# Boot time
boot_ts = None
with contextlib.suppress(Exception):
boot_ts = psutil.boot_time()
metrics['boot_time'] = boot_ts
# Power / throttle (Pi-specific)
metrics['power'] = {
'throttled': _collect_throttle_flags(),
'draw_watts': _collect_power_draw(),
}
else:
metrics['cpu'] = None
metrics['memory'] = None
metrics['swap'] = None
metrics['disk'] = None
metrics['disk_io'] = None
metrics['temperatures'] = None
metrics['fans'] = None
metrics['battery'] = None
metrics['network'] = None
metrics['boot_time'] = None
metrics['power'] = None
return metrics
@@ -270,6 +433,32 @@ def _ensure_collector() -> None:
logger.info('System metrics collector started')
def _get_observer_location() -> dict[str, Any]:
"""Get observer location from GPS state or config defaults."""
lat, lon, source = None, None, 'none'
# Try GPS state from app module
with contextlib.suppress(Exception):
import app as app_module
gps_state = getattr(app_module, 'gps_state', None)
if gps_state and isinstance(gps_state, dict):
g_lat = gps_state.get('lat') or gps_state.get('latitude')
g_lon = gps_state.get('lon') or gps_state.get('longitude')
if g_lat is not None and g_lon is not None:
lat, lon, source = float(g_lat), float(g_lon), 'gps'
# Fall back to config defaults
if lat is None:
with contextlib.suppress(Exception):
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
return {'lat': lat, 'lon': lon, 'source': source}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@@ -321,3 +510,59 @@ def get_sdr_devices() -> Response:
except Exception as exc:
logger.warning('SDR device detection failed: %s', exc)
return jsonify({'devices': [], 'error': str(exc)})
@system_bp.route('/location')
def get_location() -> Response:
"""Return observer location from GPS or config."""
return jsonify(_get_observer_location())
@system_bp.route('/weather')
def get_weather() -> Response:
"""Proxy weather from wttr.in, cached for 10 minutes."""
global _weather_cache, _weather_cache_time
now = time.time()
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
return jsonify(_weather_cache)
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
if lat is None or lon is None:
loc = _get_observer_location()
lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None:
return jsonify({'error': 'No location available'})
if _requests is None:
return jsonify({'error': 'requests library not available'})
try:
resp = _requests.get(
f'https://wttr.in/{lat},{lon}?format=j1',
timeout=5,
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
)
resp.raise_for_status()
data = resp.json()
current = data.get('current_condition', [{}])[0]
weather = {
'temp_c': current.get('temp_C'),
'temp_f': current.get('temp_F'),
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
'humidity': current.get('humidity'),
'wind_mph': current.get('windspeedMiles'),
'wind_dir': current.get('winddir16Point'),
'feels_like_c': current.get('FeelsLikeC'),
'visibility': current.get('visibility'),
'pressure': current.get('pressure'),
}
_weather_cache = weather
_weather_cache_time = now
return jsonify(weather)
except Exception as exc:
logger.debug('Weather fetch failed: %s', exc)
return jsonify({'error': str(exc)})

View File

@@ -1,14 +1,32 @@
/* System Health Mode Styles */
/* System Health Mode Styles — Enhanced Dashboard */
.sys-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 16px;
width: 100%;
box-sizing: border-box;
}
/* Group headers span full width */
.sys-group-header {
grid-column: 1 / -1;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-cyan, #00d4ff);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
padding-bottom: 6px;
margin-top: 8px;
}
.sys-group-header:first-child {
margin-top: 0;
}
/* Cards */
.sys-card {
background: var(--bg-card, #1a1a2e);
border: 1px solid var(--border-color, #2a2a4a);
@@ -17,6 +35,14 @@
min-height: 120px;
}
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
}
.sys-card-header {
font-size: 11px;
font-weight: 700;
@@ -99,7 +125,252 @@
font-size: 11px;
}
/* Process items */
/* SVG Arc Gauge */
.sys-gauge-wrap {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.sys-gauge-arc {
position: relative;
width: 90px;
height: 90px;
flex-shrink: 0;
}
.sys-gauge-arc svg {
width: 100%;
height: 100%;
}
.sys-gauge-arc .arc-bg {
fill: none;
stroke: var(--bg-primary, #0d0d1a);
stroke-width: 8;
stroke-linecap: round;
}
.sys-gauge-arc .arc-fill {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
filter: drop-shadow(0 0 4px currentColor);
}
.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); }
.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); }
.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); }
.sys-gauge-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 18px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-gauge-details {
flex: 1;
font-size: 11px;
}
/* Per-core bars */
.sys-core-bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 24px;
margin-top: 8px;
}
.sys-core-bar {
flex: 1;
background: var(--bg-primary, #0d0d1a);
border-radius: 2px;
position: relative;
min-width: 4px;
max-width: 16px;
height: 100%;
}
.sys-core-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
transition: height 0.4s ease, background 0.3s ease;
}
/* Temperature sparkline */
.sys-sparkline-wrap {
margin: 8px 0;
}
.sys-sparkline-wrap svg {
width: 100%;
height: 40px;
}
.sys-sparkline-line {
fill: none;
stroke: var(--accent-cyan, #00d4ff);
stroke-width: 1.5;
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
}
.sys-sparkline-area {
fill: url(#sparkGradient);
opacity: 0.3;
}
.sys-temp-big {
font-size: 28px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
margin-bottom: 4px;
}
/* Network interface rows */
.sys-net-iface {
padding: 6px 0;
border-bottom: 1px solid var(--border-color, #2a2a4a);
}
.sys-net-iface:last-child {
border-bottom: none;
}
.sys-net-iface-name {
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
font-size: 11px;
}
.sys-net-iface-ip {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
color: var(--text-primary, #e0e0ff);
}
.sys-net-iface-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
}
/* Bandwidth arrows */
.sys-bandwidth {
display: flex;
gap: 12px;
margin-top: 4px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-bw-up {
color: var(--accent-green, #00ff88);
}
.sys-bw-down {
color: var(--accent-cyan, #00d4ff);
}
/* Globe container */
.sys-location-inner {
display: flex;
gap: 16px;
align-items: stretch;
}
.sys-globe-wrap {
width: 300px;
height: 300px;
flex-shrink: 0;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.sys-location-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.sys-location-coords {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
color: var(--text-primary, #e0e0ff);
}
.sys-location-source {
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Weather overlay */
.sys-weather {
margin-top: auto;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid var(--border-color, #2a2a4a);
}
.sys-weather-temp {
font-size: 24px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-weather-condition {
font-size: 12px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
.sys-weather-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
/* Disk I/O indicators */
.sys-disk-io {
display: flex;
gap: 16px;
margin-top: 8px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-disk-io-read {
color: var(--accent-cyan, #00d4ff);
}
.sys-disk-io-write {
color: var(--accent-green, #00ff88);
}
/* Process grid — dot-matrix style */
.sys-process-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 12px;
}
.sys-process-item {
display: flex;
align-items: center;
@@ -128,6 +399,12 @@
background: var(--text-dim, #555);
}
.sys-process-summary {
margin-top: 8px;
font-size: 11px;
color: var(--text-dim, #8888aa);
}
/* SDR Devices */
.sys-sdr-device {
padding: 6px 0;
@@ -154,6 +431,32 @@
background: var(--bg-primary, #0d0d1a);
}
/* System info — horizontal layout */
.sys-info-grid {
display: flex;
flex-wrap: wrap;
gap: 4px 20px;
font-size: 11px;
color: var(--text-dim, #8888aa);
}
.sys-info-item {
white-space: nowrap;
}
.sys-info-item strong {
color: var(--text-primary, #e0e0ff);
font-weight: 600;
}
/* Battery indicator */
.sys-battery-inline {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
/* Sidebar Quick Grid */
.sys-quick-grid {
display: grid;
@@ -206,10 +509,36 @@
padding: 8px;
gap: 10px;
}
.sys-card-wide,
.sys-card-full {
grid-column: 1;
}
.sys-location-inner {
flex-direction: column;
}
.sys-globe-wrap {
width: 100%;
height: 250px;
}
.sys-process-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.sys-dashboard {
grid-template-columns: repeat(2, 1fr);
}
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
}
}

View File

@@ -1,8 +1,9 @@
/**
* System Health IIFE module
* System Health Enhanced Dashboard 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.
* Streams real-time system metrics via SSE with rich visualizations:
* SVG arc gauge, per-core bars, temperature sparkline, network bandwidth,
* disk I/O, 3D globe, weather, and process grid.
*/
const SystemHealth = (function () {
'use strict';
@@ -11,19 +12,46 @@ const SystemHealth = (function () {
let connected = false;
let lastMetrics = null;
// Temperature sparkline ring buffer (last 20 readings)
const SPARKLINE_SIZE = 20;
let tempHistory = [];
// Network I/O delta tracking
let prevNetIo = null;
let prevNetTimestamp = null;
// Disk I/O delta tracking
let prevDiskIo = null;
let prevDiskTimestamp = null;
// Location & weather state
let locationData = null;
let weatherData = null;
let weatherTimer = null;
let globeInstance = null;
let globeDestroyed = false;
const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js';
const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
function formatBytes(bytes) {
if (bytes == null) return '--';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let val = bytes;
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = 0;
var val = bytes;
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
return val.toFixed(1) + ' ' + units[i];
}
function formatRate(bytesPerSec) {
if (bytesPerSec == null) return '--';
return formatBytes(bytesPerSec) + '/s';
}
function barClass(pct) {
if (pct >= 85) return 'crit';
if (pct >= 60) return 'warn';
@@ -32,8 +60,8 @@ const SystemHealth = (function () {
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);
var cls = barClass(pct);
var 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>' +
@@ -41,71 +69,477 @@ const SystemHealth = (function () {
'</div>';
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// -----------------------------------------------------------------------
// Rendering
// SVG Arc Gauge
// -----------------------------------------------------------------------
function arcGaugeSvg(pct) {
var radius = 36;
var cx = 45, cy = 45;
var startAngle = -225;
var endAngle = 45;
var totalAngle = endAngle - startAngle; // 270 degrees
var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100);
function polarToCart(angle) {
var r = angle * Math.PI / 180;
return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) };
}
var bgStart = polarToCart(startAngle);
var bgEnd = polarToCart(endAngle);
var fillEnd = polarToCart(fillAngle);
var largeArcBg = totalAngle > 180 ? 1 : 0;
var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0;
var cls = barClass(pct);
return '<svg viewBox="0 0 90 90">' +
'<path class="arc-bg" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + largeArcBg + ' 1 ' + bgEnd.x + ' ' + bgEnd.y + '"/>' +
'<path class="arc-fill ' + cls + '" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + fillArc + ' 1 ' + fillEnd.x + ' ' + fillEnd.y + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Temperature Sparkline
// -----------------------------------------------------------------------
function sparklineSvg(values) {
if (!values || values.length < 2) return '';
var w = 200, h = 40;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var step = w / (values.length - 1);
var points = values.map(function (v, i) {
var x = Math.round(i * step);
var y = Math.round(h - ((v - min) / range) * (h - 4) - 2);
return x + ',' + y;
});
var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h;
return '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none">' +
'<defs><linearGradient id="sparkGradient" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.3"/>' +
'<stop offset="100%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.0"/>' +
'</linearGradient></defs>' +
'<polygon class="sys-sparkline-area" points="' + areaPoints + '"/>' +
'<polyline class="sys-sparkline-line" points="' + points.join(' ') + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Rendering — CPU Card
// -----------------------------------------------------------------------
function renderCpuCard(m) {
const el = document.getElementById('sysCardCpu');
var el = document.getElementById('sysCardCpu');
if (!el) return;
const cpu = m.cpu;
var cpu = m.cpu;
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
var pct = Math.round(cpu.percent);
var coreHtml = '';
if (cpu.per_core && cpu.per_core.length) {
coreHtml = '<div class="sys-core-bars">';
cpu.per_core.forEach(function (c) {
var cls = barClass(c);
var h = Math.max(2, Math.round(c / 100 * 24));
coreHtml += '<div class="sys-core-bar"><div class="sys-core-bar-fill ' + cls +
'" style="height:' + h + 'px;background:var(--accent-' +
(cls === 'ok' ? 'green' : cls === 'warn' ? 'yellow' : 'red') +
', #00ff88)"></div></div>';
});
coreHtml += '</div>';
}
var freqHtml = '';
if (cpu.freq) {
var freqGhz = (cpu.freq.current / 1000).toFixed(2);
freqHtml = '<div class="sys-card-detail">Freq: ' + freqGhz + ' GHz</div>';
}
el.innerHTML =
'<div class="sys-card-header">CPU</div>' +
'<div class="sys-card-body">' +
barHtml(cpu.percent, '') +
'<div class="sys-gauge-wrap">' +
'<div class="sys-gauge-arc">' + arcGaugeSvg(pct) +
'<div class="sys-gauge-label">' + pct + '%</div></div>' +
'<div class="sys-gauge-details">' +
'<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>' +
freqHtml +
'</div></div>' +
coreHtml +
'</div>';
}
// -----------------------------------------------------------------------
// Memory Card
// -----------------------------------------------------------------------
function renderMemoryCard(m) {
const el = document.getElementById('sysCardMemory');
var el = document.getElementById('sysCardMemory');
if (!el) return;
const mem = m.memory;
var 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 || {};
var swap = m.swap || {};
el.innerHTML =
'<div class="sys-card-header">Memory</div>' +
'<div class="sys-card-body">' +
barHtml(mem.percent, '') +
barHtml(mem.percent, 'RAM') +
'<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>' +
(swap.total > 0 ? barHtml(swap.percent, 'Swap') +
'<div class="sys-card-detail">' + 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>';
}
// -----------------------------------------------------------------------
// Temperature & Power Card
// -----------------------------------------------------------------------
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];
var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
for (var i = 0; i < preferred.length; i++) {
if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0];
}
// Fall back to first available
for (const key of Object.keys(temps)) {
for (var key in temps) {
if (temps[key] && temps[key].length) return temps[key][0];
}
return null;
}
function renderSdrCard(devices) {
const el = document.getElementById('sysCardSdr');
function renderTempCard(m) {
var el = document.getElementById('sysCardTemp');
if (!el) return;
let html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
var temp = _extractPrimaryTemp(m.temperatures);
var html = '<div class="sys-card-header">Temperature &amp; Power</div><div class="sys-card-body">';
if (temp) {
// Update sparkline history
tempHistory.push(temp.current);
if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift();
html += '<div class="sys-temp-big">' + Math.round(temp.current) + '&deg;C</div>';
html += '<div class="sys-sparkline-wrap">' + sparklineSvg(tempHistory) + '</div>';
// Additional sensors
if (m.temperatures) {
for (var chip in m.temperatures) {
m.temperatures[chip].forEach(function (s) {
html += '<div class="sys-card-detail">' + escHtml(s.label) + ': ' + Math.round(s.current) + '&deg;C</div>';
});
}
}
} else {
html += '<span class="sys-metric-na">No temperature sensors</span>';
}
// Fans
if (m.fans) {
for (var fChip in m.fans) {
m.fans[fChip].forEach(function (f) {
html += '<div class="sys-card-detail">Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM</div>';
});
}
}
// Battery
if (m.battery) {
html += '<div class="sys-card-detail" style="margin-top:8px">' +
'Battery: ' + Math.round(m.battery.percent) + '%' +
(m.battery.plugged ? ' (plugged)' : '') + '</div>';
}
// Throttle flags (Pi)
if (m.power && m.power.throttled) {
html += '<div class="sys-card-detail" style="color:var(--accent-yellow,#ffcc00)">Throttle: 0x' + m.power.throttled + '</div>';
}
// Power draw
if (m.power && m.power.draw_watts != null) {
html += '<div class="sys-card-detail">Power: ' + m.power.draw_watts + ' W</div>';
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Disk Card
// -----------------------------------------------------------------------
function renderDiskCard(m) {
var el = document.getElementById('sysCardDisk');
if (!el) return;
var disk = m.disk;
if (!disk) { el.innerHTML = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body">';
html += barHtml(disk.percent, '');
html += '<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>';
// Disk I/O rates
if (m.disk_io && prevDiskIo && prevDiskTimestamp) {
var dt = (m.timestamp - prevDiskTimestamp);
if (dt > 0) {
var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt;
var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt;
var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt);
var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt);
html += '<div class="sys-disk-io">' +
'<span class="sys-disk-io-read">R: ' + formatRate(Math.max(0, readRate)) + '</span>' +
'<span class="sys-disk-io-write">W: ' + formatRate(Math.max(0, writeRate)) + '</span>' +
'</div>';
html += '<div class="sys-card-detail">IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w</div>';
}
}
if (m.disk_io) {
prevDiskIo = m.disk_io;
prevDiskTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Network Card
// -----------------------------------------------------------------------
function renderNetworkCard(m) {
var el = document.getElementById('sysCardNetwork');
if (!el) return;
var net = m.network;
if (!net) { el.innerHTML = '<div class="sys-card-header">Network</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Network</div><div class="sys-card-body">';
// Interfaces
var ifaces = net.interfaces || [];
if (ifaces.length === 0) {
html += '<span class="sys-metric-na">No interfaces</span>';
} else {
ifaces.forEach(function (iface) {
html += '<div class="sys-net-iface">';
html += '<div class="sys-net-iface-name">' + escHtml(iface.name) +
(iface.is_up ? '' : ' <span style="color:var(--text-dim)">(down)</span>') + '</div>';
if (iface.ipv4) html += '<div class="sys-net-iface-ip">' + escHtml(iface.ipv4) + '</div>';
var details = [];
if (iface.mac) details.push('MAC: ' + iface.mac);
if (iface.speed) details.push(iface.speed + ' Mbps');
if (details.length) html += '<div class="sys-net-iface-detail">' + escHtml(details.join(' | ')) + '</div>';
// Bandwidth for this interface
if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) {
var dt = (m.timestamp - prevNetTimestamp);
if (dt > 0) {
var prev = prevNetIo[iface.name];
var cur = net.io[iface.name];
var upRate = (cur.bytes_sent - prev.bytes_sent) / dt;
var downRate = (cur.bytes_recv - prev.bytes_recv) / dt;
html += '<div class="sys-bandwidth">' +
'<span class="sys-bw-up">&uarr; ' + formatRate(Math.max(0, upRate)) + '</span>' +
'<span class="sys-bw-down">&darr; ' + formatRate(Math.max(0, downRate)) + '</span>' +
'</div>';
}
}
html += '</div>';
});
}
// Connection count
if (net.connections != null) {
html += '<div class="sys-card-detail" style="margin-top:8px">Connections: ' + net.connections + '</div>';
}
// Save for next delta
if (net.io) {
prevNetIo = net.io;
prevNetTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Location & Weather Card
// -----------------------------------------------------------------------
function renderLocationCard() {
var el = document.getElementById('sysCardLocation');
if (!el) return;
var html = '<div class="sys-card-header">Location &amp; Weather</div><div class="sys-card-body">';
html += '<div class="sys-location-inner">';
// Globe container
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
// Details column
html += '<div class="sys-location-details">';
if (locationData && locationData.lat != null) {
html += '<div class="sys-location-coords">' +
locationData.lat.toFixed(4) + '&deg;' + (locationData.lat >= 0 ? 'N' : 'S') + '<br>' +
locationData.lon.toFixed(4) + '&deg;' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
} else {
html += '<div class="sys-location-coords" style="color:var(--text-dim)">No location</div>';
}
// Weather
if (weatherData && !weatherData.error) {
html += '<div class="sys-weather">';
html += '<div class="sys-weather-temp">' + (weatherData.temp_c || '--') + '&deg;C</div>';
html += '<div class="sys-weather-condition">' + escHtml(weatherData.condition || '') + '</div>';
var details = [];
if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%');
if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || ''));
if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C');
details.forEach(function (d) {
html += '<div class="sys-weather-detail">' + escHtml(d) + '</div>';
});
html += '</div>';
} else if (weatherData && weatherData.error) {
html += '<div class="sys-weather"><div class="sys-weather-condition" style="color:var(--text-dim)">Weather unavailable</div></div>';
}
html += '</div>'; // .sys-location-details
html += '</div>'; // .sys-location-inner
html += '</div>';
el.innerHTML = html;
// Initialize globe after DOM is ready
setTimeout(function () { initGlobe(); }, 50);
}
// -----------------------------------------------------------------------
// Globe (reuses globe.gl from GPS mode)
// -----------------------------------------------------------------------
function ensureGlobeLibrary() {
return new Promise(function (resolve, reject) {
if (typeof window.Globe === 'function') { resolve(true); return; }
// Check if script already exists
var existing = document.querySelector(
'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' +
'script[src="' + GLOBE_SCRIPT_URL + '"]'
);
if (existing) {
if (existing.dataset.loaded === 'true') { resolve(true); return; }
if (existing.dataset.failed === 'true') { resolve(false); return; }
existing.addEventListener('load', function () { resolve(true); }, { once: true });
existing.addEventListener('error', function () { resolve(false); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = GLOBE_SCRIPT_URL;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL;
script.onload = function () { script.dataset.loaded = 'true'; resolve(true); };
script.onerror = function () { script.dataset.failed = 'true'; resolve(false); };
document.head.appendChild(script);
});
}
function initGlobe() {
var container = document.getElementById('sysGlobeContainer');
if (!container || globeDestroyed) return;
// Don't reinitialize if globe is already in this container
if (globeInstance && container.querySelector('canvas')) return;
ensureGlobeLibrary().then(function (ready) {
if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return;
container = document.getElementById('sysGlobeContainer');
if (!container || !container.clientWidth) return;
container.innerHTML = '';
container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)';
globeInstance = window.Globe()(container)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.12)
.pointsData([])
.pointRadius(0.8)
.pointAltitude(0.01)
.pointColor(function () { return '#00d4ff'; });
var controls = globeInstance.controls();
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
controls.enablePan = false;
controls.minDistance = 180;
controls.maxDistance = 400;
}
// Size the globe
globeInstance.width(container.clientWidth);
globeInstance.height(container.clientHeight);
updateGlobePosition();
});
}
function updateGlobePosition() {
if (!globeInstance || !locationData || locationData.lat == null) return;
// Observer point
globeInstance.pointsData([{
lat: locationData.lat,
lng: locationData.lon,
size: 0.8,
color: '#00d4ff',
}]);
// Snap view
globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000);
// Stop auto-rotate when we have a fix
var controls = globeInstance.controls();
if (controls) controls.autoRotate = false;
}
function destroyGlobe() {
globeDestroyed = true;
if (globeInstance) {
var container = document.getElementById('sysGlobeContainer');
if (container) container.innerHTML = '';
globeInstance = null;
}
}
// -----------------------------------------------------------------------
// SDR Card
// -----------------------------------------------------------------------
function renderSdrCard(devices) {
var el = document.getElementById('sysCardSdr');
if (!el) return;
var 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>';
@@ -113,9 +547,9 @@ const SystemHealth = (function () {
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>' : '') +
'<strong>' + escHtml(d.type) + ' #' + d.index + '</strong>' +
'<div class="sys-card-detail">' + escHtml(d.name || 'Unknown') + '</div>' +
(d.serial ? '<div class="sys-card-detail">S/N: ' + escHtml(d.serial) + '</div>' : '') +
'</div>';
});
}
@@ -123,93 +557,187 @@ const SystemHealth = (function () {
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Process Card
// -----------------------------------------------------------------------
function renderProcessCard(m) {
const el = document.getElementById('sysCardProcesses');
var 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">';
var procs = m.processes || {};
var keys = Object.keys(procs).sort();
var html = '<div class="sys-card-header">Active Processes</div><div class="sys-card-body">';
if (!keys.length) {
html += '<span class="sys-metric-na">No data</span>';
} else {
var running = 0, stopped = 0;
html += '<div class="sys-process-grid">';
keys.forEach(function (k) {
const running = procs[k];
const dotCls = running ? 'running' : 'stopped';
const label = k.charAt(0).toUpperCase() + k.slice(1);
var isRunning = procs[k];
if (isRunning) running++; else stopped++;
var dotCls = isRunning ? 'running' : 'stopped';
var 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>' +
'<span class="sys-process-name">' + escHtml(label) + '</span>' +
'</div>';
});
html += '</div>';
html += '<div class="sys-process-summary">' + running + ' running / ' + stopped + ' idle</div>';
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// System Info Card
// -----------------------------------------------------------------------
function renderSystemInfoCard(m) {
const el = document.getElementById('sysCardInfo');
var 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) + '&deg;C';
if (temp.high) html += ' / ' + Math.round(temp.high) + '&deg;C max';
html += '</div>';
var sys = m.system || {};
var html = '<div class="sys-card-header">System Info</div><div class="sys-card-body"><div class="sys-info-grid">';
html += '<div class="sys-info-item"><strong>Host:</strong> ' + escHtml(sys.hostname || '--') + '</div>';
html += '<div class="sys-info-item"><strong>OS:</strong> ' + escHtml(sys.platform || '--') + '</div>';
html += '<div class="sys-info-item"><strong>Python:</strong> ' + escHtml(sys.python || '--') + '</div>';
html += '<div class="sys-info-item"><strong>App:</strong> v' + escHtml(sys.version || '--') + '</div>';
html += '<div class="sys-info-item"><strong>Uptime:</strong> ' + escHtml(sys.uptime_human || '--') + '</div>';
if (m.boot_time) {
var bootDate = new Date(m.boot_time * 1000);
html += '<div class="sys-info-item"><strong>Boot:</strong> ' + escHtml(bootDate.toUTCString()) + '</div>';
}
html += '</div>';
if (m.network && m.network.connections != null) {
html += '<div class="sys-info-item"><strong>Connections:</strong> ' + m.network.connections + '</div>';
}
html += '</div></div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Sidebar Updates
// -----------------------------------------------------------------------
function updateSidebarQuickStats(m) {
const cpuEl = document.getElementById('sysQuickCpu');
const tempEl = document.getElementById('sysQuickTemp');
const ramEl = document.getElementById('sysQuickRam');
const diskEl = document.getElementById('sysQuickDisk');
var cpuEl = document.getElementById('sysQuickCpu');
var tempEl = document.getElementById('sysQuickTemp');
var ramEl = document.getElementById('sysQuickRam');
var 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);
var temp = _extractPrimaryTemp(m.temperatures);
if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '&deg;C' : '--';
// Color-code values
[cpuEl, ramEl, diskEl].forEach(function (el) {
if (!el) return;
const val = parseInt(el.textContent);
var 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');
var el = document.getElementById('sysProcessList');
if (!el) return;
const procs = m.processes || {};
const keys = Object.keys(procs).sort();
var procs = m.processes || {};
var 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]; });
var running = keys.filter(function (k) { return procs[k]; });
var 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 ? ' &middot; ' : '') +
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
}
function updateSidebarNetwork(m) {
var el = document.getElementById('sysQuickNet');
if (!el || !m.network) return;
var ifaces = m.network.interfaces || [];
var ips = [];
ifaces.forEach(function (iface) {
if (iface.ipv4 && iface.is_up) {
ips.push(iface.name + ': ' + iface.ipv4);
}
});
el.textContent = ips.length ? ips.join(', ') : '--';
}
function updateSidebarBattery(m) {
var section = document.getElementById('sysQuickBatterySection');
var el = document.getElementById('sysQuickBattery');
if (!section || !el) return;
if (m.battery) {
section.style.display = '';
el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : '');
} else {
section.style.display = 'none';
}
}
function updateSidebarLocation() {
var el = document.getElementById('sysQuickLocation');
if (!el) return;
if (locationData && locationData.lat != null) {
el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')';
} else {
el.textContent = 'No location';
}
}
// -----------------------------------------------------------------------
// Render all
// -----------------------------------------------------------------------
function renderAll(m) {
renderCpuCard(m);
renderMemoryCard(m);
renderTempCard(m);
renderDiskCard(m);
renderNetworkCard(m);
renderProcessCard(m);
renderSystemInfoCard(m);
updateSidebarQuickStats(m);
updateSidebarProcesses(m);
updateSidebarNetwork(m);
updateSidebarBattery(m);
}
// -----------------------------------------------------------------------
// Location & Weather Fetching
// -----------------------------------------------------------------------
function fetchLocation() {
fetch('/system/location')
.then(function (r) { return r.json(); })
.then(function (data) {
locationData = data;
updateSidebarLocation();
renderLocationCard();
if (data.lat != null) fetchWeather();
})
.catch(function () {
renderLocationCard();
});
}
function fetchWeather() {
if (!locationData || locationData.lat == null) return;
fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon)
.then(function (r) { return r.json(); })
.then(function (data) {
weatherData = data;
renderLocationCard();
})
.catch(function () {});
}
// -----------------------------------------------------------------------
@@ -267,7 +795,7 @@ const SystemHealth = (function () {
var html = '';
devices.forEach(function (d) {
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
d.type + ' #' + d.index + ' &mdash; ' + (d.name || 'Unknown') + '</div>';
escHtml(d.type) + ' #' + d.index + ' &mdash; ' + escHtml(d.name || 'Unknown') + '</div>';
});
sidebarEl.innerHTML = html;
}
@@ -284,12 +812,24 @@ const SystemHealth = (function () {
// -----------------------------------------------------------------------
function init() {
globeDestroyed = false;
connect();
refreshSdr();
fetchLocation();
// Refresh weather every 10 minutes
weatherTimer = setInterval(function () {
fetchWeather();
}, 600000);
}
function destroy() {
disconnect();
destroyGlobe();
if (weatherTimer) {
clearInterval(weatherTimer);
weatherTimer = null;
}
}
return {

View File

@@ -3130,6 +3130,8 @@
<!-- System Health Visuals -->
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
<div class="sys-dashboard">
<!-- Row 1: COMPUTE -->
<div class="sys-group-header">Compute</div>
<div class="sys-card" id="sysCardCpu">
<div class="sys-card-header">CPU</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
@@ -3138,8 +3140,26 @@
<div class="sys-card-header">Memory</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardTemp">
<div class="sys-card-header">Temperature &amp; Power</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 2: NETWORK & LOCATION -->
<div class="sys-group-header">Network &amp; Location</div>
<div class="sys-card" id="sysCardNetwork">
<div class="sys-card-header">Network</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card sys-card-wide" id="sysCardLocation">
<div class="sys-card-header">Location &amp; Weather</div>
<div class="sys-card-body"><span class="sys-metric-na">Loading&hellip;</span></div>
</div>
<!-- Row 3: EQUIPMENT & OPERATIONS -->
<div class="sys-group-header">Equipment &amp; Operations</div>
<div class="sys-card" id="sysCardDisk">
<div class="sys-card-header">Disk</div>
<div class="sys-card-header">Disk &amp; Storage</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardSdr">
@@ -3147,10 +3167,12 @@
<div class="sys-card-body"><span class="sys-metric-na">Scanning&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardProcesses">
<div class="sys-card-header">Processes</div>
<div class="sys-card-header">Active Processes</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardInfo">
<!-- Full-width system info -->
<div class="sys-card sys-card-full" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>

View File

@@ -31,6 +31,24 @@
</div>
</div>
<!-- Network & Location -->
<div class="section">
<h3>Network</h3>
<div id="sysQuickNet" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Battery (shown only when available) -->
<div class="section" id="sysQuickBatterySection" style="display: none;">
<h3>Battery</h3>
<div id="sysQuickBattery" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Location -->
<div class="section">
<h3>Location</h3>
<div id="sysQuickLocation" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- SDR Devices -->
<div class="section">
<h3>SDR Devices</h3>

View File

@@ -30,6 +30,32 @@ def test_metrics_returns_expected_keys(client):
assert 'uptime_human' in data['system']
def test_metrics_enhanced_keys(client):
"""GET /system/metrics returns enhanced metric keys."""
_login(client)
resp = client.get('/system/metrics')
assert resp.status_code == 200
data = resp.get_json()
# New enhanced keys
assert 'network' in data
assert 'disk_io' in data
assert 'boot_time' in data
assert 'battery' in data
assert 'fans' in data
assert 'power' in data
# CPU should have per_core and freq
if data['cpu'] is not None:
assert 'per_core' in data['cpu']
assert 'freq' in data['cpu']
# Network should have interfaces and connections
if data['network'] is not None:
assert 'interfaces' in data['network']
assert 'connections' in data['network']
assert 'io' in data['network']
def test_metrics_without_psutil(client):
"""Metrics degrade gracefully when psutil is unavailable."""
_login(client)
@@ -45,6 +71,11 @@ def test_metrics_without_psutil(client):
assert data['cpu'] is None
assert data['memory'] is None
assert data['disk'] is None
assert data['network'] is None
assert data['disk_io'] is None
assert data['battery'] is None
assert data['boot_time'] is None
assert data['power'] is None
finally:
mod._HAS_PSUTIL = orig
@@ -87,3 +118,75 @@ def test_stream_returns_sse_content_type(client):
resp = client.get('/system/stream')
assert resp.status_code == 200
assert 'text/event-stream' in resp.content_type
def test_location_returns_shape(client):
"""GET /system/location returns lat/lon/source shape."""
_login(client)
with patch('routes.system.contextlib.suppress'):
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'lat' in data
assert 'lon' in data
assert 'source' in data
def test_location_falls_back_to_config(client):
"""Location endpoint returns config defaults when GPS unavailable."""
_login(client)
with patch('routes.system.DEFAULT_LATITUDE', 40.7128, create=True), \
patch('routes.system.DEFAULT_LONGITUDE', -74.006, create=True):
# Mock the import inside _get_observer_location
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'source' in data
def test_weather_requires_location(client):
"""Weather endpoint returns error when no location available."""
_login(client)
# Without lat/lon params and no GPS state or config
resp = client.get('/system/weather')
assert resp.status_code == 200
data = resp.get_json()
# Either returns weather or error (depending on config)
assert 'error' in data or 'temp_c' in data
def test_weather_with_mocked_response(client):
"""Weather endpoint returns parsed weather data with mocked HTTP."""
_login(client)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
'current_condition': [{
'temp_C': '22',
'temp_F': '72',
'weatherDesc': [{'value': 'Clear'}],
'humidity': '45',
'windspeedMiles': '8',
'winddir16Point': 'NW',
'FeelsLikeC': '20',
'visibility': '10',
'pressure': '1013',
}]
}
mock_resp.raise_for_status = MagicMock()
import routes.system as mod
# Clear cache
mod._weather_cache.clear()
mod._weather_cache_time = 0.0
with patch('routes.system._requests') as mock_requests:
mock_requests.get.return_value = mock_resp
resp = client.get('/system/weather?lat=40.7&lon=-74.0')
assert resp.status_code == 200
data = resp.get_json()
assert data['temp_c'] == '22'
assert data['condition'] == 'Clear'
assert data['humidity'] == '45'
assert data['wind_mph'] == '8'