From 0a90010c1f903ff0e120df8017bd55317a8738a7 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 23:42:45 +0000 Subject: [PATCH] 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 --- routes/system.py | 255 +++++++++- static/css/modes/system.css | 335 ++++++++++++- static/js/modes/system.js | 692 ++++++++++++++++++++++++--- templates/index.html | 28 +- templates/partials/modes/system.html | 18 + tests/test_system.py | 103 ++++ 6 files changed, 1344 insertions(+), 87 deletions(-) diff --git a/routes/system.py b/routes/system.py index 839899d..b66efc2 100644 --- a/routes/system.py +++ b/routes/system.py @@ -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)}) diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 3efd245..7beee04 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -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; + } } diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 1aab9aa..74fb93d 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -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 'N/A'; - const cls = barClass(pct); - const rounded = Math.round(pct); + var cls = barClass(pct); + var rounded = Math.round(pct); return '
' + (label ? '' + label + '' : '') + '
' + @@ -41,71 +69,477 @@ const SystemHealth = (function () { '
'; } + 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 '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // 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 '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // 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 = '
psutil not available
'; return; } + + var pct = Math.round(cpu.percent); + var coreHtml = ''; + if (cpu.per_core && cpu.per_core.length) { + coreHtml = '
'; + cpu.per_core.forEach(function (c) { + var cls = barClass(c); + var h = Math.max(2, Math.round(c / 100 * 24)); + coreHtml += '
'; + }); + coreHtml += '
'; + } + + var freqHtml = ''; + if (cpu.freq) { + var freqGhz = (cpu.freq.current / 1000).toFixed(2); + freqHtml = '
Freq: ' + freqGhz + ' GHz
'; + } + el.innerHTML = '
CPU
' + '
' + - barHtml(cpu.percent, '') + + '
' + + '
' + arcGaugeSvg(pct) + + '
' + pct + '%
' + + '
' + '
Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '
' + '
Cores: ' + cpu.count + '
' + + freqHtml + + '
' + + coreHtml + '
'; } + // ----------------------------------------------------------------------- + // 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 = '
N/A
'; return; } - const swap = m.swap || {}; + var swap = m.swap || {}; el.innerHTML = '
Memory
' + '
' + - barHtml(mem.percent, '') + + barHtml(mem.percent, 'RAM') + '
' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '
' + - '
Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' + + (swap.total > 0 ? barHtml(swap.percent, 'Swap') + + '
' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' : '') + '
'; } - function renderDiskCard(m) { - const el = document.getElementById('sysCardDisk'); - if (!el) return; - const disk = m.disk; - if (!disk) { el.innerHTML = '
N/A
'; return; } - el.innerHTML = - '
Disk
' + - '
' + - barHtml(disk.percent, '') + - '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
' + - '
Path: ' + (disk.path || '/') + '
' + - '
'; - } + // ----------------------------------------------------------------------- + // 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 = '
SDR Devices
'; + + var temp = _extractPrimaryTemp(m.temperatures); + var html = '
Temperature & Power
'; + + if (temp) { + // Update sparkline history + tempHistory.push(temp.current); + if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift(); + + html += '
' + Math.round(temp.current) + '°C
'; + html += '
' + sparklineSvg(tempHistory) + '
'; + + // Additional sensors + if (m.temperatures) { + for (var chip in m.temperatures) { + m.temperatures[chip].forEach(function (s) { + html += '
' + escHtml(s.label) + ': ' + Math.round(s.current) + '°C
'; + }); + } + } + } else { + html += 'No temperature sensors'; + } + + // Fans + if (m.fans) { + for (var fChip in m.fans) { + m.fans[fChip].forEach(function (f) { + html += '
Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM
'; + }); + } + } + + // Battery + if (m.battery) { + html += '
' + + 'Battery: ' + Math.round(m.battery.percent) + '%' + + (m.battery.plugged ? ' (plugged)' : '') + '
'; + } + + // Throttle flags (Pi) + if (m.power && m.power.throttled) { + html += '
Throttle: 0x' + m.power.throttled + '
'; + } + + // Power draw + if (m.power && m.power.draw_watts != null) { + html += '
Power: ' + m.power.draw_watts + ' W
'; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Disk Card + // ----------------------------------------------------------------------- + + function renderDiskCard(m) { + var el = document.getElementById('sysCardDisk'); + if (!el) return; + var disk = m.disk; + if (!disk) { el.innerHTML = '
Disk & Storage
N/A
'; return; } + + var html = '
Disk & Storage
'; + html += barHtml(disk.percent, ''); + html += '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
'; + + // 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 += '
' + + 'R: ' + formatRate(Math.max(0, readRate)) + '' + + 'W: ' + formatRate(Math.max(0, writeRate)) + '' + + '
'; + html += '
IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w
'; + } + } + + if (m.disk_io) { + prevDiskIo = m.disk_io; + prevDiskTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Network Card + // ----------------------------------------------------------------------- + + function renderNetworkCard(m) { + var el = document.getElementById('sysCardNetwork'); + if (!el) return; + var net = m.network; + if (!net) { el.innerHTML = '
Network
N/A
'; return; } + + var html = '
Network
'; + + // Interfaces + var ifaces = net.interfaces || []; + if (ifaces.length === 0) { + html += 'No interfaces'; + } else { + ifaces.forEach(function (iface) { + html += '
'; + html += '
' + escHtml(iface.name) + + (iface.is_up ? '' : ' (down)') + '
'; + if (iface.ipv4) html += '
' + escHtml(iface.ipv4) + '
'; + var details = []; + if (iface.mac) details.push('MAC: ' + iface.mac); + if (iface.speed) details.push(iface.speed + ' Mbps'); + if (details.length) html += '
' + escHtml(details.join(' | ')) + '
'; + + // 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 += '
' + + '↑ ' + formatRate(Math.max(0, upRate)) + '' + + '↓ ' + formatRate(Math.max(0, downRate)) + '' + + '
'; + } + } + + html += '
'; + }); + } + + // Connection count + if (net.connections != null) { + html += '
Connections: ' + net.connections + '
'; + } + + // Save for next delta + if (net.io) { + prevNetIo = net.io; + prevNetTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Location & Weather Card + // ----------------------------------------------------------------------- + + function renderLocationCard() { + var el = document.getElementById('sysCardLocation'); + if (!el) return; + + var html = '
Location & Weather
'; + html += '
'; + + // Globe container + html += '
'; + + // Details column + html += '
'; + + if (locationData && locationData.lat != null) { + html += '
' + + locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + '
' + + locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '
'; + html += '
Source: ' + escHtml(locationData.source || 'unknown') + '
'; + } else { + html += '
No location
'; + } + + // Weather + if (weatherData && !weatherData.error) { + html += '
'; + html += '
' + (weatherData.temp_c || '--') + '°C
'; + html += '
' + escHtml(weatherData.condition || '') + '
'; + 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 += '
' + escHtml(d) + '
'; + }); + html += '
'; + } else if (weatherData && weatherData.error) { + html += '
Weather unavailable
'; + } + + html += '
'; // .sys-location-details + html += '
'; // .sys-location-inner + html += '
'; + 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 = '
SDR Devices
'; html += '
'; if (!devices || !devices.length) { html += 'No devices found'; @@ -113,9 +547,9 @@ const SystemHealth = (function () { devices.forEach(function (d) { html += '
' + ' ' + - '' + d.type + ' #' + d.index + '' + - '
' + (d.name || 'Unknown') + '
' + - (d.serial ? '
S/N: ' + d.serial + '
' : '') + + '' + escHtml(d.type) + ' #' + d.index + '' + + '
' + escHtml(d.name || 'Unknown') + '
' + + (d.serial ? '
S/N: ' + escHtml(d.serial) + '
' : '') + '
'; }); } @@ -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 = '
Processes
'; + var procs = m.processes || {}; + var keys = Object.keys(procs).sort(); + var html = '
Active Processes
'; if (!keys.length) { html += 'No data'; } else { + var running = 0, stopped = 0; + html += '
'; 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 += '
' + ' ' + - '' + label + '' + + '' + escHtml(label) + '' + '
'; }); + html += '
'; + html += '
' + running + ' running / ' + stopped + ' idle
'; } html += '
'; 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 = '
System Info
'; - html += '
Host: ' + (sys.hostname || '--') + '
'; - html += '
OS: ' + (sys.platform || '--') + '
'; - html += '
Python: ' + (sys.python || '--') + '
'; - html += '
App: v' + (sys.version || '--') + '
'; - html += '
Uptime: ' + (sys.uptime_human || '--') + '
'; - if (temp) { - html += '
Temp: ' + Math.round(temp.current) + '°C'; - if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max'; - html += '
'; + var sys = m.system || {}; + var html = '
System Info
'; + + html += '
Host: ' + escHtml(sys.hostname || '--') + '
'; + html += '
OS: ' + escHtml(sys.platform || '--') + '
'; + html += '
Python: ' + escHtml(sys.python || '--') + '
'; + html += '
App: v' + escHtml(sys.version || '--') + '
'; + html += '
Uptime: ' + escHtml(sys.uptime_human || '--') + '
'; + + if (m.boot_time) { + var bootDate = new Date(m.boot_time * 1000); + html += '
Boot: ' + escHtml(bootDate.toUTCString()) + '
'; } - html += '
'; + + if (m.network && m.network.connections != null) { + html += '
Connections: ' + m.network.connections + '
'; + } + + html += '
'; 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) + '°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 ? '' + running.length + ' running' : '') + (running.length && stopped.length ? ' · ' : '') + (stopped.length ? '' + stopped.length + ' stopped' : ''); } + 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 += '
' + - d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '
'; + escHtml(d.type) + ' #' + d.index + ' — ' + escHtml(d.name || 'Unknown') + '
'; }); 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 { diff --git a/templates/index.html b/templates/index.html index ddc1b96..ddf7990 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3130,6 +3130,8 @@