mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
255
routes/system.py
255
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)})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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) + '°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) + '°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 & 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 & 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">↑ ' + formatRate(Math.max(0, upRate)) + '</span>' +
|
||||
'<span class="sys-bw-down">↓ ' + 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 & 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) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + '<br>' +
|
||||
locationData.lon.toFixed(4) + '°' + (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 || '--') + '°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) + '°C';
|
||||
if (temp.high) html += ' / ' + Math.round(temp.high) + '°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) + '°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 ? ' · ' : '') +
|
||||
(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 + ' — ' + (d.name || 'Unknown') + '</div>';
|
||||
escHtml(d.type) + ' #' + d.index + ' — ' + 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 {
|
||||
|
||||
@@ -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…</span></div>
|
||||
@@ -3138,8 +3140,26 @@
|
||||
<div class="sys-card-header">Memory</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardTemp">
|
||||
<div class="sys-card-header">Temperature & Power</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: NETWORK & LOCATION -->
|
||||
<div class="sys-group-header">Network & 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…</span></div>
|
||||
</div>
|
||||
<div class="sys-card sys-card-wide" id="sysCardLocation">
|
||||
<div class="sys-card-header">Location & Weather</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Loading…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: EQUIPMENT & OPERATIONS -->
|
||||
<div class="sys-group-header">Equipment & Operations</div>
|
||||
<div class="sys-card" id="sysCardDisk">
|
||||
<div class="sys-card-header">Disk</div>
|
||||
<div class="sys-card-header">Disk & Storage</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardSdr">
|
||||
@@ -3147,10 +3167,12 @@
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardProcesses">
|
||||
<div class="sys-card-header">Processes</div>
|
||||
<div class="sys-card-header">Active Processes</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</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…</span></div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user