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 =
'' +
'' +
- 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 =
'' +
'' +
- 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 =
- '' +
- '' +
- 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 = '';
+
+ var temp = _extractPrimaryTemp(m.temperatures);
+ var html = '';
+
+ 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 = 'N/A
'; return; }
+
+ var html = '';
+ 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 = 'N/A
'; return; }
+
+ var html = '';
+
+ // 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 = '';
+ 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 += '
';
+ }
+
+ 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 = '';
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 = '
';
+ var procs = m.processes || {};
+ var keys = Object.keys(procs).sort();
+ var html = '
';
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 = '
';
- 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 = '
';
+
+ 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 @@
+
+
Connecting…
@@ -3138,8 +3140,26 @@
Connecting…
+
+
+
+
+
+
+
+
+
@@ -3147,10 +3167,12 @@
Scanning…
-
+
+
+
diff --git a/templates/partials/modes/system.html b/templates/partials/modes/system.html
index a750e11..2d20b50 100644
--- a/templates/partials/modes/system.html
+++ b/templates/partials/modes/system.html
@@ -31,6 +31,24 @@
+
+
+
+
+
+
+
+
+
SDR Devices
diff --git a/tests/test_system.py b/tests/test_system.py
index 6d2ea59..3ddef06 100644
--- a/tests/test_system.py
+++ b/tests/test_system.py
@@ -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'