/* ── 数字跳动动画 ── */ function animateNumber(el, newVal, decimals, suffix) { var oldVal = parseFloat(el.getAttribute('data-val') || newVal); if (oldVal === newVal) { el.textContent = newVal.toFixed(decimals) + suffix; return; } el.setAttribute('data-val', newVal); var start = performance.now(); var duration = 900; function step(ts) { var p = Math.min((ts - start) / duration, 1); var eased = 1 - Math.pow(1 - p, 3); // ease-out el.textContent = (oldVal + (newVal - oldVal) * eased).toFixed(decimals) + suffix; if (p < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } window.DashboardModule = { ws: null, reconnectTimer: null, reconnectDelay: 1000, charts: {}, init: function() { var self = this; // 初始化图表 self.charts = { mem: new MiniChart('chart-mem', '#9ece6a'), cpu: new MiniChart('chart-cpu', '#7aa2f7'), net: new MiniChart('chart-net', '#e0af68'), speed: new DualLineChart('chart-speed', '#bb9af7', '#c0a8f0'), proc_mem: new MiniChart('chart-proc-mem', '#f7768e') }; // 静态信息 (HTTP 一次) fetchSystemStaticInfo(); // WebSocket 实时推送 self._connect(); }, _connect: function() { var self = this; if (self.ws) { self.ws.onclose = null; self.ws.close(); self.ws = null; } var base = window.location.pathname.split("/").slice(0, 2).join("/"); var ws = new WebSocket("ws://" + location.host + base + "/api/system/ws"); self.ws = ws; ws.onopen = function() { self.reconnectDelay = 1000; }; ws.onmessage = function(e) { try { var d = JSON.parse(e.data); if (d.type === "sys") { self._updateUI(d.system, d.framework); } } catch(ex) {} }; ws.onclose = function() { self.ws = null; self.reconnectTimer = setTimeout(function() { self.reconnectDelay = Math.min(self.reconnectDelay * 1.5, 15000); self._connect(); }, self.reconnectDelay); }; ws.onerror = function() { ws.close(); }; }, _updateUI: function(sys, fw) { // ── 框架卡片 ── if (fw) { var el; el = document.getElementById('d-uptime'); if (el) el.textContent = formatUptime(fw.uptime || 0); el = document.getElementById('d-plugins'); if (el) el.textContent = fw.plugins || 0; el = document.getElementById('d-ver-badge'); if (el) el.textContent = fw.version || '?'; } if (!sys) return; var self = this; // ── 内存 ── if (sys.memory) { var m = sys.memory.percent || 0; var el = document.getElementById('d-mem'); if (el) animateNumber(el, m, 1, '%'); el = document.getElementById('d-mem-detail'); if (el) el.textContent = (sys.memory.used_gb || 0).toFixed(1) + ' / ' + (sys.memory.total_gb || 0).toFixed(1) + ' GB'; self.charts.mem.update(m); } // ── CPU ── if (sys.cpu) { var cpuVal = sys.cpu.percent; if (cpuVal === null || cpuVal === undefined) { var load = (sys.cpu.load_avg && sys.cpu.load_avg[0]) ? sys.cpu.load_avg[0] : 0; cpuVal = Math.min(100, (load / (sys.cpu.cores || 1)) * 100); } var el = document.getElementById('d-cpu'); if (el) animateNumber(el, cpuVal, 0, '%'); self.charts.cpu.update(cpuVal); if (sys.cpu.load_avg) { el = document.getElementById('info-load'); if (el) el.textContent = sys.cpu.load_avg[2].toFixed(2); } } // ── 网络累计流量 (RX + TX) ── if (sys.network) { var el; el = document.getElementById('d-net-rx'); if (el) el.textContent = formatTraffic(sys.network.rx_mb); el = document.getElementById('d-net-tx'); if (el) el.textContent = formatTraffic(sys.network.tx_mb); // Chart shows RX trend self.charts.net.update(sys.network.rx_mb || 0); } // ── 实时网速 ── if (sys.net_speed) { var el; el = document.getElementById('d-speed-rx'); if (el) el.textContent = formatSpeed(sys.net_speed.rx_bytes_sec); el = document.getElementById('d-speed-tx'); if (el) el.textContent = formatSpeed(sys.net_speed.tx_bytes_sec); // Chart: RX solid line, TX dashed line self.charts.speed.update(sys.net_speed.rx_bytes_sec || 0, sys.net_speed.tx_bytes_sec || 0); } // ── 进程内存 ── if (sys.process && sys.process.memory_mb !== undefined) { var pm = sys.process.memory_mb || 0; var el = document.getElementById('d-proc-mem'); if (el) animateNumber(el, pm, 1, ' MB'); self.charts.proc_mem.update(pm); } if (sys.process && sys.process.pid !== undefined) { var el = document.getElementById('info-pid'); if (el) el.textContent = sys.process.pid; } }, destroy: function() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } this.charts = {}; } }; /* ── 静态信息 ── */ async function fetchSystemStaticInfo() { try { var sys = await fetch('./api/system', {credentials:'include'}).then(function(r){return r.json();}); var el; if (sys.platform) { el = document.getElementById('info-os'); if (el) el.textContent = sys.platform.system || '--'; el = document.getElementById('info-arch'); if (el) el.textContent = sys.platform.machine || '--'; el = document.getElementById('info-env'); if (el) el.textContent = sys.platform.env || 'Standard'; } if (sys.cpu) { var c = sys.cpu.cores || 0; el = document.getElementById('info-cores'); if (el) el.textContent = c + ' / ' + c; } if (sys.memory) { el = document.getElementById('info-mem-total'); if (el) el.textContent = sys.memory.total_gb + ' GB'; } // 框架信息 (部分来自 API) try { var fw = await fetch('./api/framework', {credentials:'include'}).then(function(r){return r.json();}); el = document.getElementById('info-fw-ver'); if (el) el.textContent = fw.version || '?'; } catch(e) {} var host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); el = document.getElementById('info-addr'); if (el) el.textContent = host; } catch(e) {} } /* ── 格式化 ── */ function formatUptime(seconds) { var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) return h + 'h ' + m + 'm'; if (m > 0) return m + 'm ' + s + 's'; return s + 's'; } function formatTraffic(mb) { if (mb === undefined || mb === null) return '--'; if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB'; if (mb >= 1) return mb.toFixed(1) + ' MB'; return (mb * 1024).toFixed(0) + ' KB'; } function formatSpeed(bytesPerSec) { if (bytesPerSec === undefined || bytesPerSec === null || bytesPerSec < 0) return '0 B/s'; if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + ' MB/s'; if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(0) + ' KB/s'; return bytesPerSec.toFixed(0) + ' B/s'; }