mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
The globe wasn't rendering because initGlobe() used setTimeout(100) which can race with the display:none removal by switchMode(). Both GPS and WebSDR modes use requestAnimationFrame to wait for the browser to compute layout before initializing Globe.gl. - Replace setTimeout with RAF-based retry loop (up to 8 frames) - Add try-catch around Globe() init with fallback message - Match the proven pattern from GPS and WebSDR modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
905 lines
38 KiB
JavaScript
905 lines
38 KiB
JavaScript
/**
|
||
* System Health – Enhanced Dashboard IIFE module
|
||
*
|
||
* 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';
|
||
|
||
let eventSource = null;
|
||
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 '--';
|
||
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';
|
||
return 'ok';
|
||
}
|
||
|
||
function barHtml(pct, label) {
|
||
if (pct == null) return '<span class="sys-metric-na">N/A</span>';
|
||
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>' +
|
||
'<span class="sys-metric-bar-value">' + rounded + '%</span>' +
|
||
'</div>';
|
||
}
|
||
|
||
function escHtml(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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) {
|
||
var el = document.getElementById('sysCardCpu');
|
||
if (!el) return;
|
||
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(3, Math.round(c / 100 * 48));
|
||
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">' +
|
||
'<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) {
|
||
var el = document.getElementById('sysCardMemory');
|
||
if (!el) return;
|
||
var mem = m.memory;
|
||
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||
var swap = m.swap || {};
|
||
el.innerHTML =
|
||
'<div class="sys-card-header">Memory</div>' +
|
||
'<div class="sys-card-body">' +
|
||
barHtml(mem.percent, 'RAM') +
|
||
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
|
||
(swap.total > 0 ? barHtml(swap.percent, 'Swap') +
|
||
'<div class="sys-card-detail">' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' : '') +
|
||
'</div>';
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Temperature & Power Card
|
||
// -----------------------------------------------------------------------
|
||
|
||
function _extractPrimaryTemp(temps) {
|
||
if (!temps) return null;
|
||
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];
|
||
}
|
||
for (var key in temps) {
|
||
if (temps[key] && temps[key].length) return temps[key][0];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderTempCard(m) {
|
||
var el = document.getElementById('sysCardTemp');
|
||
if (!el) return;
|
||
|
||
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;
|
||
|
||
// Preserve the globe DOM node if it already has a canvas
|
||
var existingGlobe = document.getElementById('sysGlobeContainer');
|
||
var savedGlobe = null;
|
||
if (existingGlobe && existingGlobe.querySelector('canvas')) {
|
||
savedGlobe = existingGlobe;
|
||
existingGlobe.parentNode.removeChild(existingGlobe);
|
||
}
|
||
|
||
var html = '<div class="sys-card-header">Location & Weather</div><div class="sys-card-body">';
|
||
html += '<div class="sys-location-inner">';
|
||
|
||
// Globe placeholder (will be replaced with saved node or initialized fresh)
|
||
if (!savedGlobe) {
|
||
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
|
||
} else {
|
||
html += '<div id="sysGlobePlaceholder"></div>';
|
||
}
|
||
|
||
// Details below globe
|
||
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') + ', ' +
|
||
locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
|
||
|
||
// GPS status indicator
|
||
if (locationData.source === 'gps' && locationData.gps) {
|
||
var gps = locationData.gps;
|
||
var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix';
|
||
var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d';
|
||
html += '<div class="sys-gps-status">' +
|
||
'<span class="sys-gps-dot ' + dotCls + '"></span> ' + fixLabel;
|
||
if (gps.satellites != null) html += ' · ' + gps.satellites + ' sats';
|
||
if (gps.accuracy != null) html += ' · ±' + gps.accuracy + 'm';
|
||
html += '</div>';
|
||
} else {
|
||
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;
|
||
|
||
// Re-insert saved globe or initialize fresh
|
||
if (savedGlobe) {
|
||
var placeholder = document.getElementById('sysGlobePlaceholder');
|
||
if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder);
|
||
} else {
|
||
requestAnimationFrame(function () { initGlobe(); });
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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 canvas is still alive in this container
|
||
if (globeInstance && container.querySelector('canvas')) return;
|
||
|
||
// Clear stale reference if canvas was destroyed by innerHTML replacement
|
||
if (globeInstance && !container.querySelector('canvas')) {
|
||
globeInstance = null;
|
||
}
|
||
|
||
ensureGlobeLibrary().then(function (ready) {
|
||
if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return;
|
||
|
||
// Wait for layout — container may have 0 dimensions right after
|
||
// display:none is removed by switchMode(). Use RAF retry like GPS mode.
|
||
var attempts = 0;
|
||
function tryInit() {
|
||
if (globeDestroyed) return;
|
||
container = document.getElementById('sysGlobeContainer');
|
||
if (!container) return;
|
||
|
||
if ((!container.clientWidth || !container.clientHeight) && attempts < 8) {
|
||
attempts++;
|
||
requestAnimationFrame(tryInit);
|
||
return;
|
||
}
|
||
if (!container.clientWidth || !container.clientHeight) return;
|
||
|
||
container.innerHTML = '';
|
||
container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)';
|
||
|
||
try {
|
||
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 = 120;
|
||
controls.maxDistance = 300;
|
||
}
|
||
|
||
// Size the globe
|
||
globeInstance.width(container.clientWidth);
|
||
globeInstance.height(container.clientHeight);
|
||
|
||
updateGlobePosition();
|
||
} catch (e) {
|
||
// Globe.gl / WebGL init failed — show static fallback
|
||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--text-dim);font-size:11px;">Globe unavailable</div>';
|
||
}
|
||
}
|
||
requestAnimationFrame(tryInit);
|
||
});
|
||
}
|
||
|
||
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>';
|
||
} else {
|
||
devices.forEach(function (d) {
|
||
html += '<div class="sys-sdr-device">' +
|
||
'<span class="sys-process-dot running"></span> ' +
|
||
'<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>';
|
||
});
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Process Card
|
||
// -----------------------------------------------------------------------
|
||
|
||
function renderProcessCard(m) {
|
||
var el = document.getElementById('sysCardProcesses');
|
||
if (!el) return;
|
||
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) {
|
||
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">' + 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) {
|
||
var el = document.getElementById('sysCardInfo');
|
||
if (!el) return;
|
||
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><span>' + escHtml(sys.hostname || '--') + '</span></div>';
|
||
html += '<div class="sys-info-item"><strong>OS</strong><span>' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '</span></div>';
|
||
html += '<div class="sys-info-item"><strong>Python</strong><span>' + escHtml(sys.python || '--') + '</span></div>';
|
||
html += '<div class="sys-info-item"><strong>App</strong><span>v' + escHtml(sys.version || '--') + '</span></div>';
|
||
html += '<div class="sys-info-item"><strong>Uptime</strong><span>' + escHtml(sys.uptime_human || '--') + '</span></div>';
|
||
|
||
if (m.boot_time) {
|
||
var bootDate = new Date(m.boot_time * 1000);
|
||
html += '<div class="sys-info-item"><strong>Boot</strong><span>' + escHtml(bootDate.toLocaleString()) + '</span></div>';
|
||
}
|
||
|
||
if (m.network && m.network.connections != null) {
|
||
html += '<div class="sys-info-item"><strong>Connections</strong><span>' + m.network.connections + '</span></div>';
|
||
}
|
||
|
||
html += '</div></div>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Sidebar Updates
|
||
// -----------------------------------------------------------------------
|
||
|
||
function updateSidebarQuickStats(m) {
|
||
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) + '%' : '--';
|
||
|
||
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;
|
||
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) {
|
||
var el = document.getElementById('sysProcessList');
|
||
if (!el) return;
|
||
var procs = m.processes || {};
|
||
var keys = Object.keys(procs).sort();
|
||
if (!keys.length) { el.textContent = 'No data'; return; }
|
||
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) {
|
||
// If server only has default/none, check client-side saved location
|
||
if ((data.source === 'default' || data.source === 'none') &&
|
||
window.ObserverLocation && ObserverLocation.getShared) {
|
||
var shared = ObserverLocation.getShared();
|
||
if (shared && shared.lat && shared.lon) {
|
||
data.lat = shared.lat;
|
||
data.lon = shared.lon;
|
||
data.source = 'manual';
|
||
}
|
||
}
|
||
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 () {});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// SSE Connection
|
||
// -----------------------------------------------------------------------
|
||
|
||
function connect() {
|
||
if (eventSource) return;
|
||
eventSource = new EventSource('/system/stream');
|
||
eventSource.onmessage = function (e) {
|
||
try {
|
||
var data = JSON.parse(e.data);
|
||
if (data.type === 'keepalive') return;
|
||
lastMetrics = data;
|
||
renderAll(data);
|
||
} catch (_) { /* ignore parse errors */ }
|
||
};
|
||
eventSource.onopen = function () {
|
||
connected = true;
|
||
};
|
||
eventSource.onerror = function () {
|
||
connected = false;
|
||
};
|
||
}
|
||
|
||
function disconnect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
connected = false;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// SDR Devices
|
||
// -----------------------------------------------------------------------
|
||
|
||
function refreshSdr() {
|
||
var sidebarEl = document.getElementById('sysSdrList');
|
||
if (sidebarEl) sidebarEl.innerHTML = 'Scanning…';
|
||
|
||
var cardEl = document.getElementById('sysCardSdr');
|
||
if (cardEl) cardEl.innerHTML = '<div class="sys-card-header">SDR Devices</div><div class="sys-card-body">Scanning…</div>';
|
||
|
||
fetch('/system/sdr_devices')
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
var devices = data.devices || [];
|
||
renderSdrCard(devices);
|
||
// Update sidebar
|
||
if (sidebarEl) {
|
||
if (!devices.length) {
|
||
sidebarEl.innerHTML = '<span style="color: var(--text-dim);">No SDR devices found</span>';
|
||
} else {
|
||
var html = '';
|
||
devices.forEach(function (d) {
|
||
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
|
||
escHtml(d.type) + ' #' + d.index + ' — ' + escHtml(d.name || 'Unknown') + '</div>';
|
||
});
|
||
sidebarEl.innerHTML = html;
|
||
}
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (sidebarEl) sidebarEl.innerHTML = '<span style="color: var(--accent-red, #ff3366);">Detection failed</span>';
|
||
renderSdrCard([]);
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Public API
|
||
// -----------------------------------------------------------------------
|
||
|
||
function init() {
|
||
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 {
|
||
init: init,
|
||
destroy: destroy,
|
||
refreshSdr: refreshSdr,
|
||
};
|
||
})();
|