(function () { 'use strict'; var $ = function (id) { return document.getElementById(id); }; var pages = Array.prototype.slice.call(document.querySelectorAll('.page')); var PAGES = [ { title: '系统概览', sub: 'SYSTEM OVERVIEW' }, { title: '处理器与负载', sub: 'CPU & LOAD' }, { title: '内存', sub: 'MEMORY' }, { title: '存储', sub: 'STORAGE' }, { title: '网络', sub: 'NETWORK' } ]; var WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; var COLORS = { cyan: '#52e6cf', blue: '#5aa9ff', amber: '#ffb45c', red: '#ff6072', violet: '#b58cff', green: '#62e6a8' }; var params = new URLSearchParams(window.location.search); var initialPage = parseInt(params.get('page') || '1', 10) - 1; if (!isFinite(initialPage)) initialPage = 0; initialPage = clamp(initialPage, 0, PAGES.length - 1); var rotateParam = parseFloat(params.get('rotate') || '10'); if (!isFinite(rotateParam)) rotateParam = 10; var state = { data: null, page: initialPage, paused: /^(1|true|yes)$/i.test(params.get('paused') || ''), rotateMs: clamp(rotateParam, 4, 120) * 1000, remaining: clamp(rotateParam, 4, 120) * 1000, startedAt: 0, rotateTimer: null, progressTimer: null, anchorTs: 0, anchorPerf: 0, tzOffset: 0, lastDataAt: 0, es: null, pollTimer: null, pollInFlight: false, reconnectTimer: null, tickerSized: false, tickerText: '', visibilityPaused: false, cursorTimer: null, source: '' }; var sparks = {}; var stage = $('stage'); /* ------------------------------------------------------------------ */ /* Formatting */ /* ------------------------------------------------------------------ */ function clamp(value, min, max) { value = Number(value); if (!isFinite(value)) return min; return Math.max(min, Math.min(max, value)); } function num(value, fallback) { var n = Number(value); return isFinite(n) ? n : (fallback || 0); } function pad2(value) { value = Math.floor(value); return value < 10 ? '0' + value : String(value); } function fmtBytes(value, forceDigits) { var n = Number(value); if (!isFinite(n) || n <= 0) return '0 B'; var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; var index = 0; while (n >= 1024 && index < units.length - 1) { n /= 1024; index += 1; } var digits = typeof forceDigits === 'number' ? forceDigits : (n >= 100 ? 0 : n >= 10 ? 1 : 2); return n.toFixed(digits) + ' ' + units[index]; } function fmtRate(value) { var n = Number(value); if (!isFinite(n) || n <= 0.5) return '0 B/s'; return fmtBytes(n) + '/s'; } function fmtPct(value, digits) { var n = Number(value); if (!isFinite(n)) return '--'; return n.toFixed(typeof digits === 'number' ? digits : 0) + '%'; } function fmtNumber(value) { var n = Number(value); if (!isFinite(n)) return '--'; return n.toLocaleString('en-US'); } function fmtSpeed(mbps) { if (!mbps || !isFinite(Number(mbps))) return '--'; var n = Number(mbps); if (n >= 1000) return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1) + ' Gbps'; return n.toFixed(0) + ' Mbps'; } function fmtUptime(seconds) { var s = Math.max(0, Math.floor(Number(seconds) || 0)); var days = Math.floor(s / 86400); var hours = Math.floor((s % 86400) / 3600); var minutes = Math.floor((s % 3600) / 60); var secs = s % 60; return (days > 0 ? days + 'd ' : '') + pad2(hours) + ':' + pad2(minutes) + ':' + pad2(secs); } function formatOffset(seconds) { var offset = Number(seconds) || 0; var sign = offset >= 0 ? '+' : '-'; offset = Math.abs(offset); var hours = Math.floor(offset / 3600); var minutes = Math.floor((offset % 3600) / 60); return 'UTC' + sign + pad2(hours) + ':' + pad2(minutes); } function serverNowMs() { if (!state.anchorTs) return 0; return state.anchorTs * 1000 + (performance.now() - state.anchorPerf) + state.tzOffset * 1000; } function timeParts(ms) { var date = new Date(ms); return { year: date.getUTCFullYear(), month: pad2(date.getUTCMonth() + 1), day: pad2(date.getUTCDate()), hour: pad2(date.getUTCHours()), minute: pad2(date.getUTCMinutes()), second: pad2(date.getUTCSeconds()), weekday: WEEKDAYS[date.getUTCDay()] }; } function formatStamp(epochSeconds) { if (!epochSeconds) return '--'; var p = timeParts((Number(epochSeconds) + state.tzOffset) * 1000); return p.year + '-' + p.month + '-' + p.day + ' ' + p.hour + ':' + p.minute; } function currentClockLabel() { var ms = serverNowMs(); if (!ms) return '--:--:--'; var p = timeParts(ms); return p.hour + ':' + p.minute + ':' + p.second; } function escapeHtml(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]; }); } function setText(id, value) { var el = $(id); if (el) el.textContent = value == null ? '--' : String(value); } function setHtml(id, value) { var el = $(id); if (el) el.innerHTML = value; } function rgba(hex, alpha) { var clean = String(hex).replace('#', ''); if (clean.length === 3) clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2]; var bigint = parseInt(clean, 16); var r = (bigint >> 16) & 255; var g = (bigint >> 8) & 255; var b = bigint & 255; return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; } function toneForPct(value) { var pct = Number(value) || 0; if (pct >= 95) return 'crit'; if (pct >= 85) return 'warn'; return 'ok'; } function toneColor(tone, okColor) { if (tone === 'crit') return COLORS.red; if (tone === 'warn') return COLORS.amber; return okColor || COLORS.cyan; } function setTone(el, tone) { if (!el) return; el.classList.remove('tone-warn', 'tone-crit'); if (tone === 'warn') el.classList.add('tone-warn'); if (tone === 'crit') el.classList.add('tone-crit'); } function setRing(id, pct, tone) { var ring = $(id); if (!ring) return; ring.style.setProperty('--pct', String(clamp(pct, 0, 100))); ring.setAttribute('data-tone', tone || 'ok'); } function paintBar(el, pct, color, glow) { if (!el) return; el.style.setProperty('--w', clamp(pct, 0, 100).toFixed(1) + '%'); if (color) el.style.setProperty('--bar-color', color); if (glow) el.style.setProperty('--bar-glow', glow); } function maxOf(values) { var best = 0; (values || []).forEach(function (v) { var n = Number(v); if (isFinite(n) && n > best) best = n; }); return best; } function minOf(values) { var best = Infinity; (values || []).forEach(function (v) { var n = Number(v); if (isFinite(n) && n < best) best = n; }); return isFinite(best) ? best : 0; } function meanOf(values) { var total = 0; var count = 0; (values || []).forEach(function (v) { var n = Number(v); if (isFinite(n)) { total += n; count += 1; } }); return count ? total / count : 0; } /* ------------------------------------------------------------------ */ /* Sparklines */ /* ------------------------------------------------------------------ */ function Spark(canvas, options) { this.canvas = canvas; this.ctx = canvas ? canvas.getContext('2d') : null; this.options = options || {}; this.options.series = this.options.series || [{ key: 'value', color: COLORS.cyan }]; this.data = {}; this.width = 0; this.height = 0; this.raf = 0; this.resize = this.resize.bind(this); this.draw = this.draw.bind(this); this.resize(); if (window.ResizeObserver && canvas) { this.observer = new ResizeObserver(this.resize); this.observer.observe(canvas); } } Spark.prototype.setData = function (data) { if (Array.isArray(data)) { var wrapped = {}; wrapped[this.options.series[0].key] = data; this.data = wrapped; } else { this.data = data || {}; } this.requestDraw(); }; Spark.prototype.setOptions = function (options) { var key; for (key in options) { if (Object.prototype.hasOwnProperty.call(options, key)) this.options[key] = options[key]; } this.requestDraw(); }; Spark.prototype.resize = function () { if (!this.canvas || !this.ctx) return; var width = this.canvas.clientWidth; var height = this.canvas.clientHeight; if (!width || !height) return; var dpr = Math.min(window.devicePixelRatio || 1, 2); var pixelWidth = Math.round(width * dpr); var pixelHeight = Math.round(height * dpr); if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) { this.canvas.width = pixelWidth; this.canvas.height = pixelHeight; } this.width = width; this.height = height; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.draw(); }; Spark.prototype.requestDraw = function () { var self = this; if (this.raf) return; this.raf = window.requestAnimationFrame(function () { self.raf = 0; self.draw(); }); }; Spark.prototype.draw = function () { if (!this.ctx || !this.width || !this.height) return; var ctx = this.ctx; var w = this.width; var h = this.height; var padTop = 5; var padBottom = 2; var plotHeight = Math.max(1, h - padTop - padBottom); var series = this.options.series; var allValues = []; var i; var j; ctx.clearRect(0, 0, w, h); for (i = 0; i < series.length; i += 1) { var values = this.data[series[i].key] || []; for (j = 0; j < values.length; j += 1) { var value = Number(values[j]); if (isFinite(value)) allValues.push(value); } } var dataMax = allValues.length ? Math.max.apply(null, allValues) : 0; var max = Number(this.options.max); if (!isFinite(max) || max <= 0) max = Math.max(dataMax * 1.12, 1); var min = Number(this.options.min); if (!isFinite(min)) min = 0; if (max <= min) max = min + 1; ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(118, 160, 202, .07)'; for (i = 0; i <= 3; i += 1) { var gy = padTop + (plotHeight * i / 3); ctx.beginPath(); ctx.moveTo(0, Math.round(gy) + 0.5); ctx.lineTo(w, Math.round(gy) + 0.5); ctx.stroke(); } if (this.options.threshold && Number(this.options.threshold) < max) { var ty = padTop + (1 - (Number(this.options.threshold) - min) / (max - min)) * plotHeight; ctx.save(); ctx.setLineDash([3, 4]); ctx.strokeStyle = 'rgba(255, 180, 92, .2)'; ctx.beginPath(); ctx.moveTo(0, ty); ctx.lineTo(w, ty); ctx.stroke(); ctx.restore(); } if (!allValues.length) { ctx.fillStyle = 'rgba(113, 143, 170, .55)'; ctx.font = '7px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.fillText('WAITING FOR SAMPLE', w / 2, h / 2 + 3); return; } for (i = 0; i < series.length; i += 1) { var spec = series[i]; var raw = this.data[spec.key] || []; var points = []; var count = raw.length; if (!count) continue; if (count === 1) { var only = clamp(num(raw[0], min), min, max); points.push({ x: 0, y: padTop + (1 - (only - min) / (max - min)) * plotHeight }); points.push({ x: w, y: padTop + (1 - (only - min) / (max - min)) * plotHeight }); } else { for (j = 0; j < count; j += 1) { var v = clamp(num(raw[j], min), min, max); points.push({ x: (j / (count - 1)) * w, y: padTop + (1 - (v - min) / (max - min)) * plotHeight }); } } var color = spec.color || COLORS.cyan; var gradient = ctx.createLinearGradient(0, padTop, 0, h); gradient.addColorStop(0, rgba(color, spec.alpha || 0.18)); gradient.addColorStop(1, rgba(color, 0)); ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (j = 1; j < points.length; j += 1) { var prev = points[j - 1]; var cur = points[j]; var mid = (prev.x + cur.x) / 2; ctx.bezierCurveTo(mid, prev.y, mid, cur.y, cur.x, cur.y); } ctx.lineTo(points[points.length - 1].x, h); ctx.lineTo(points[0].x, h); ctx.closePath(); ctx.fillStyle = gradient; ctx.fill(); ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (j = 1; j < points.length; j += 1) { var p0 = points[j - 1]; var p1 = points[j]; var middle = (p0.x + p1.x) / 2; ctx.bezierCurveTo(middle, p0.y, middle, p1.y, p1.x, p1.y); } ctx.strokeStyle = color; ctx.lineWidth = series.length > 1 ? 1.25 : 1.45; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.shadowColor = rgba(color, 0.55); ctx.shadowBlur = 4; ctx.stroke(); ctx.shadowBlur = 0; var last = points[points.length - 1]; ctx.beginPath(); ctx.arc(last.x - 0.5, last.y, 2.1, 0, Math.PI * 2); ctx.fillStyle = color; ctx.shadowColor = rgba(color, 0.85); ctx.shadowBlur = 7; ctx.fill(); ctx.shadowBlur = 0; } }; /* ------------------------------------------------------------------ */ /* Clock */ /* ------------------------------------------------------------------ */ function tickClock() { if (!state.anchorTs) return; var ms = serverNowMs(); var p = timeParts(ms); setText('clock', p.hour + ':' + p.minute + ':' + p.second); setText('date', p.year + '-' + p.month + '-' + p.day + ' ' + p.weekday); setText('timezone', formatOffset(state.tzOffset)); setText('ticker-update', p.hour + ':' + p.minute + ':' + p.second); if (state.data && state.data.host) { var elapsed = (performance.now() - state.anchorPerf) / 1000; setText('uptime', fmtUptime(num(state.data.host.uptime_seconds, 0) + elapsed)); } } /* ------------------------------------------------------------------ */ /* Renderers */ /* ------------------------------------------------------------------ */ function renderHost(data) { var host = data.host || {}; var meta = data.meta || {}; setText('hostname', host.hostname || '--'); setText('os-name', host.os || '--'); setText('kernel', host.kernel || '--'); setText('arch', host.arch || '--'); setText('primary-ip', host.primary_ip || '--'); setText('sys-os', host.os || '--'); setText('sys-kernel', host.kernel || '--'); setText('sys-arch', host.arch || '--'); setText('sys-ip', host.primary_ip || '--'); setText('sys-procs', fmtNumber(host.process_count) + ' / ' + fmtNumber(host.thread_count)); setText('version', 'v' + (data.version || '--')); setText('sample-interval', (num(meta.sample_interval, 1)).toFixed(1) + 's'); setText('ticker-sample', 'SAMPLE ' + (num(meta.sample_interval, 1)).toFixed(1) + 's'); setText('foot-mode', meta.demo ? 'DEMO 演示' : 'LIVE 实时'); setText('foot-sample', 'UPDATE ' + currentClockLabel() + ' · ' + (num(meta.sample_interval, 1)).toFixed(1) + 's'); var demoPill = $('demo-pill'); if (demoPill) demoPill.hidden = !meta.demo; if (host.boot_epoch) setText('boot-time', 'BOOT ' + formatStamp(host.boot_epoch)); else setText('boot-time', 'BOOT --'); } function renderCpu(data) { var cpu = data.cpu || {}; var history = data.history || {}; var pct = clamp(num(cpu.percent, 0), 0, 100); var tone = toneForPct(pct); var cores = cpu.cores || (data.host && data.host.cores) || 0; var physical = cpu.physical_cores || (data.host && data.host.physical_cores) || cores; var model = cpu.model || (data.host && data.host.cpu_model) || '--'; var freq = cpu.freq_mhz ? Math.round(cpu.freq_mhz) + ' MHz' : '--'; var temp = (cpu.temp_c === null || cpu.temp_c === undefined) ? '--' : Number(cpu.temp_c).toFixed(1) + '°C'; var peak = maxOf(history.cpu); setHtml('cpu-percent', String(Math.round(pct)) + '%'); setHtml('cpu-percent-2', String(Math.round(pct)) + '%'); setRing('cpu-ring', pct, tone); setRing('cpu-ring-2', pct, tone); setText('cpu-badge', String(cores) + ' THREADS'); setText('cpu-badge-2', String(cores) + ' THREADS'); setText('cpu-model', model); setText('cpu-model-2', model); setText('cpu-freq', freq); setText('cpu-freq-2', freq); setText('cpu-temp', temp); setText('cpu-temp-2', temp); setText('cpu-cores', String(cores) + ' / ' + String(physical)); setText('cpu-cores-2', String(cores) + ' / ' + String(physical)); setText('cpu-peak', 'PEAK ' + fmtPct(peak)); setText('cpu-peak-2', 'PEAK ' + fmtPct(peak)); var breakdown = cpu.breakdown || {}; setText('bd-user-val', fmtPct(num(breakdown.user), 1)); setText('bd-system-val', fmtPct(num(breakdown.system), 1)); setText('bd-iowait-val', fmtPct(num(breakdown.iowait), 1)); setText('bd-steal-val', fmtPct(num(breakdown.steal), 1)); paintBar($('bd-user'), num(breakdown.user), COLORS.cyan, rgba(COLORS.cyan, 0.3)); paintBar($('bd-system'), num(breakdown.system), COLORS.blue, rgba(COLORS.blue, 0.3)); paintBar($('bd-iowait'), num(breakdown.iowait), COLORS.amber, rgba(COLORS.amber, 0.3)); paintBar($('bd-steal'), num(breakdown.steal), COLORS.violet, rgba(COLORS.violet, 0.3)); renderCores(cpu.per_core || []); } function renderCores(values) { var grid = $('core-grid'); if (!grid) return; var cores = values.slice(0, 16); setText('core-count', String(values.length || 0) + ' CORES'); if (!cores.length) { grid.innerHTML = '
等待每核心数据…
'; return; } var columns = Math.min(8, cores.length); grid.style.gridTemplateColumns = 'repeat(' + columns + ', minmax(0, 1fr))'; var html = ''; for (var i = 0; i < cores.length; i += 1) { var value = clamp(num(cores[i]), 0, 100); var tone = toneForPct(value); html += '
' + '
C' + (i < 10 ? '0' + i : i) + '' + Math.round(value) + '%
' + '
' + '
'; } grid.innerHTML = html; } function renderMemory(data) { var memory = data.memory || {}; var history = data.history || {}; var total = num(memory.total); var used = num(memory.used); var available = num(memory.available); var pct = clamp(num(memory.percent), 0, 100); var tone = toneForPct(pct); var peak = maxOf(history.mem); var avg = meanOf(history.mem); var swapPct = clamp(num(memory.swap_percent), 0, 100); setHtml('mem-percent', String(Math.round(pct)) + '%'); setHtml('mem-percent-2', String(Math.round(pct)) + '%'); setRing('mem-ring', pct, tone); setRing('mem-ring-2', pct, tone); setText('mem-badge', 'AVAIL ' + fmtBytes(available)); setText('mem-badge-2', 'AVAIL ' + fmtBytes(available)); setText('mem-used', fmtBytes(used)); setText('mem-total', fmtBytes(total)); setText('mem-used-2', fmtBytes(used)); setText('mem-total-2', fmtBytes(total)); setText('mem-available', fmtBytes(available)); setText('mem-available-2', fmtBytes(available)); setText('mem-cache', fmtBytes(memory.cached)); setText('mem-cache-2', fmtBytes(memory.cached)); setText('mem-buffers-2', fmtBytes(memory.buffers)); setText('mem-trend-badge', fmtPct(pct, 1)); setText('mem-trend-current', fmtPct(pct, 1)); setText('mem-trend-peak', fmtPct(peak, 1)); setText('mem-trend-avg', fmtPct(avg, 1)); setText('mem-trend-swap', fmtPct(swapPct, 1)); setText('mem-peak-2', 'PEAK ' + fmtPct(peak, 1)); var comp = memory.composition || {}; var parts = ['apps', 'cache', 'buffers', 'free']; var suffixes = ['', '-2']; suffixes.forEach(function (suffix) { parts.forEach(function (key) { var el = $('mem-seg-' + key + suffix); if (!el) return; var width = total > 0 ? (num(comp[key]) / total) * 100 : 0; el.style.width = clamp(width, 0, 100).toFixed(2) + '%'; }); }); var memColors = { apps: COLORS.blue, cache: '#3e8ecf', buffers: '#305a7a', free: '#456176' }; parts.forEach(function (key) { var value = total > 0 ? (num(comp[key]) / total) * 100 : 0; paintBar($('mem-bd-' + key), value, memColors[key], rgba(memColors[key], 0.28)); setText('mem-bd-' + key + '-val', fmtPct(value, 1)); }); paintBar($('swap-bar'), swapPct, swapPct >= 80 ? COLORS.red : swapPct >= 50 ? COLORS.amber : COLORS.blue, rgba(swapPct >= 80 ? COLORS.red : swapPct >= 50 ? COLORS.amber : COLORS.blue, 0.35)); setText('swap-text', fmtPct(swapPct, 1)); setText('swap-text-2', fmtPct(swapPct, 1) + ' · ' + fmtBytes(memory.swap_used) + ' / ' + fmtBytes(memory.swap_total)); setText('mem-trend-swap', fmtPct(swapPct, 1)); } function renderLoad(data) { var load = data.load || {}; var cores = num((data.cpu && data.cpu.cores) || (data.host && data.host.cores), 1); var load1 = num(load.load1); var load5 = num(load.load5); var load15 = num(load.load15); var history = data.history || {}; var peak = maxOf(history.load); setText('load1', load1.toFixed(2)); setText('load5', load5.toFixed(2)); setText('load15', load15.toFixed(2)); setText('load-running', fmtNumber(load.running)); setText('load-threads', fmtNumber(load.threads)); setText('load-pct', fmtPct(load.percent, 0)); setText('load-badge', String(cores) + ' CORES'); setText('load-peak', 'PEAK ' + peak.toFixed(1)); var values = [load1, load5, load15]; var ids = ['load1-bar', 'load5-bar', 'load15-bar']; values.forEach(function (value, index) { var barPct = cores > 0 ? (value / cores) * 100 : 0; var tone = cores > 0 && value >= cores * 3 ? 'crit' : cores > 0 && value >= cores * 1.5 ? 'warn' : 'ok'; var color = toneColor(tone, COLORS.amber); paintBar($(ids[index]), barPct, color, rgba(color, 0.35)); }); setTone($('load1'), cores > 0 && load1 >= cores * 3 ? 'crit' : cores > 0 && load1 >= cores * 1.5 ? 'warn' : 'ok'); } function diskColor(tone) { return toneColor(tone, COLORS.violet); } function diskRow(disk) { var pct = clamp(num(disk.percent), 0, 100); var tone = toneForPct(pct); var color = diskColor(tone); return '
' + '
' + escapeHtml(disk.mount) + '' + escapeHtml(disk.fstype || '') + '' + '' + fmtPct(pct, 1) + '
' + '
' + '
' + fmtBytes(disk.used) + ' / ' + fmtBytes(disk.total) + '' + fmtBytes(disk.free) + ' 可用
'; } function renderDisks(data) { var disks = data.disks || []; setText('disk-count', disks.length + ' FS'); var overview = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); }).slice(0, 4); setHtml('disk-overview', overview.length ? overview.map(diskRow).join('') : '
未发现可监控的文件系统
'); var sorted = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); }); setText('fs-badge', disks.length + ' FS'); if (!sorted.length) { setHtml('fs-body', '未发现可监控的文件系统'); } else { var html = ''; sorted.slice(0, 5).forEach(function (disk) { var pct = clamp(num(disk.percent), 0, 100); var tone = toneForPct(pct); var color = diskColor(tone); html += '' + '' + escapeHtml(disk.mount) + '' + (disk.readonly ? 'RO' : '') + '' + '' + escapeHtml(disk.device) + '' + '' + escapeHtml(disk.fstype) + '' + '' + fmtBytes(disk.total) + '' + '' + fmtBytes(disk.used) + '' + '' + fmtBytes(disk.free) + '' + '
' + '' + fmtPct(pct, 1) + '' + ''; }); setHtml('fs-body', html); } var fsTotal = 0; var fsUsed = 0; var fsFree = 0; disks.forEach(function (disk) { fsTotal += num(disk.total); fsUsed += num(disk.used); fsFree += num(disk.free); }); var fsPct = (fsUsed + fsFree) > 0 ? (fsUsed / (fsUsed + fsFree)) * 100 : 0; setText('fs-total', fmtBytes(fsTotal)); setText('fs-used', fmtBytes(fsUsed)); setText('fs-free', fmtBytes(fsFree)); setText('fs-total-pct', fmtPct(fsPct, 1)); paintBar($('fs-total-bar'), fsPct, COLORS.violet, rgba(COLORS.violet, 0.35)); setText('fs-more', sorted.length > 5 ? '另有 ' + (sorted.length - 5) + ' 个文件系统' : '共 ' + sorted.length + ' 个文件系统'); var io = data.disk_io || {}; setText('io-badge', 'R ' + fmtRate(io.read_bps) + ' · W ' + fmtRate(io.write_bps)); var devices = (io.devices || []).slice(0, 5); var allDevices = io.devices || []; setText('io-device-count', String(allDevices.length)); var busiest = allDevices.slice().sort(function (a, b) { return num(b.util_percent) - num(a.util_percent); })[0]; setText('io-busiest', busiest ? busiest.device + ' · ' + fmtPct(busiest.util_percent, 0) : '—'); setText('io-total-read', fmtRate(io.read_bps)); setText('io-total-write', fmtRate(io.write_bps)); setText('io-side-rate', fmtRate(num(io.read_bps) + num(io.write_bps))); if (!devices.length) { setHtml('io-body', '暂无块设备 I/O 活动'); return; } var ioHtml = ''; devices.forEach(function (device) { var util = clamp(num(device.util_percent), 0, 100); var tone = util >= 85 ? 'crit' : util >= 60 ? 'warn' : 'ok'; var color = toneColor(tone, COLORS.blue); ioHtml += '' + '' + escapeHtml(device.device) + '' + '' + fmtRate(device.read_bps) + '' + '' + fmtRate(device.write_bps) + '' + '' + num(device.read_iops).toFixed(0) + ' / ' + num(device.write_iops).toFixed(0) + '' + '' + num(device.read_await_ms).toFixed(2) + ' / ' + num(device.write_await_ms).toFixed(2) + ' ms' + '
' + '' + fmtPct(util, 0) + '' + ''; }); setHtml('io-body', ioHtml); } function ifaceRow(iface) { var virtual = !!iface.virtual; var errors = num(iface.errors) + num(iface.drops); var badge = iface.primary ? 'PRIMARY' : virtual ? 'VIRTUAL' : ''; var link = iface.link ? fmtSpeed(iface.speed_mbps) : 'DOWN'; return '' + '
' + escapeHtml(iface.name) + '' + badge + '
' + '' + escapeHtml(iface.ip || '—') + '' + '' + fmtRate(iface.rx_bps) + '' + '' + fmtRate(iface.tx_bps) + '' + '' + fmtBytes(iface.rx_total) + ' / ' + fmtBytes(iface.tx_total) + '' + '' + fmtNumber(errors) + '' + '' + (link === 'DOWN' ? 'DOWN' : link) + '' + ''; } function renderNetwork(data) { var network = data.network || {}; var ifaces = network.interfaces || []; var rx = num(network.total_rx_bps); var tx = num(network.total_tx_bps); var count = num(network.interface_count, ifaces.length); var rxTotal = 0; var txTotal = 0; ifaces.forEach(function (iface) { rxTotal += num(iface.rx_total); txTotal += num(iface.tx_total); }); setText('net-rx', fmtRate(rx)); setText('net-tx', fmtRate(tx)); setText('net-rx-big', fmtRate(rx)); setText('net-tx-big', fmtRate(tx)); setText('net-rx-total', '累计 RX ' + fmtBytes(rxTotal)); setText('net-tx-total', '累计 TX ' + fmtBytes(txTotal)); setText('net-ifaces', String(count)); setText('net-errors', fmtNumber(num(network.errors) + num(network.drops)) + ' 错误'); setText('net-badge', String(count) + ' IFACES'); setText('iface-badge', String(count) + ' IFACES'); setText('net-sum-ifaces', String(count)); setText('net-sum-errors', fmtNumber(network.errors)); setText('net-sum-drops', fmtNumber(network.drops)); setText('net-sum-primary', network.primary_iface || '—'); if (!ifaces.length) { setHtml('iface-body', '未发现网络接口'); } else { setHtml('iface-body', ifaces.slice(0, 6).map(ifaceRow).join('')); } } function procRows(processes, mode, limit) { if (!processes.length) { return '暂无进程数据'; } var sorted = processes.slice().sort(function (a, b) { return mode === 'mem' ? num(b.mem_percent) - num(a.mem_percent) : num(b.cpu_percent) - num(a.cpu_percent); }).slice(0, limit); return sorted.map(function (proc) { var cpu = num(proc.cpu_percent); var cpuClass = cpu >= 100 ? 'critical' : cpu >= 50 ? 'hot' : ''; if (mode === 'mem') { return '' + escapeHtml(proc.pid) + '' + '
' + escapeHtml(proc.name) + '' + escapeHtml(proc.cmd || '') + '
' + '' + fmtPct(proc.mem_percent, 1) + '' + '' + fmtBytes(proc.rss_bytes) + '' + '' + fmtPct(cpu, 1) + ''; } return '' + escapeHtml(proc.pid) + '' + '
' + escapeHtml(proc.name) + '' + escapeHtml(proc.cmd || '') + '
' + '' + fmtPct(cpu, 1) + '' + '' + fmtPct(proc.mem_percent, 1) + '' + '' + fmtBytes(proc.rss_bytes) + ''; }).join(''); } function renderProcesses(data) { var processes = data.processes || []; var host = data.host || {}; setText('proc-count', fmtNumber(host.process_count) + ' PROCS'); setText('mem-proc-count', fmtNumber(host.process_count) + ' PROCS'); setHtml('proc-cpu-body', procRows(processes, 'cpu', 7)); setHtml('proc-mem-body', procRows(processes, 'mem', 7)); } /* ------------------------------------------------------------------ */ /* Ticker */ /* ------------------------------------------------------------------ */ function renderTicker(data) { var host = data.host || {}; var cpu = data.cpu || {}; var memory = data.memory || {}; var load = data.load || {}; var network = data.network || {}; var disks = data.disks || []; var alerts = data.alerts || []; var topDisk = disks.slice().sort(function (a, b) { return num(b.percent) - num(a.percent); })[0]; var level = alerts.some(function (a) { return a.severity === 'crit'; }) ? 'crit' : alerts.length ? 'warn' : 'ok'; var ticker = document.querySelector('.ticker'); if (ticker) ticker.setAttribute('data-level', level); var alertText = alerts.length ? '⚠ ' + alerts.slice(0, 3).map(function (alert) { return alert.message; }).join(' · ') : '● 系统运行正常'; var stats = 'CPU ' + fmtPct(cpu.percent, 0) + ' · 内存 ' + fmtPct(memory.percent, 0) + ' · 负载 ' + num(load.load1).toFixed(2) + (topDisk ? ' · 磁盘 ' + topDisk.mount + ' ' + fmtPct(topDisk.percent, 0) : '') + ' · 网络 ↓ ' + fmtRate(network.total_rx_bps) + ' ↑ ' + fmtRate(network.total_tx_bps); var stamp = currentClockLabel(); var text = alertText + ' /// ' + stats + ' /// ' + (host.hostname || '') + ' · ' + stamp + ' · UPTIME ' + fmtUptime(host.uptime_seconds); if (text !== state.tickerText) { state.tickerText = text; var a = $('ticker-a'); var b = $('ticker-b'); if (a) a.textContent = text; if (b) b.textContent = text; if (!state.tickerSized) { window.requestAnimationFrame(function () { var width = a ? a.scrollWidth : 0; if (width > 0) { var duration = Math.max(38, Math.min(150, width / 24)); var track = $('ticker-track'); if (track) track.style.animationDuration = duration.toFixed(1) + 's'; state.tickerSized = true; } }); } } } function renderAll(data) { renderHost(data); renderCpu(data); renderMemory(data); renderLoad(data); renderDisks(data); renderNetwork(data); renderProcesses(data); renderTicker(data); updateCharts(data); } function updateCharts(data) { var history = data.history || {}; var cores = num((data.cpu && data.cpu.cores) || 16, 16); sparks.cpu.setData({ cpu: history.cpu || [] }); sparks.cpu2.setData({ cpu: history.cpu || [] }); sparks.mem.setData({ mem: history.mem || [] }); sparks.memLarge.setData({ mem: history.mem || [] }); sparks.load.setOptions({ max: Math.max(cores * 2, maxOf(history.load) * 1.15, 1) }); sparks.load.setData({ load: history.load || [] }); var netData = { rx: history.rx || [], tx: history.tx || [] }; sparks.net.setData(netData); sparks.netBig.setData(netData); sparks.io.setData({ read: history.disk_r || [], write: history.disk_w || [] }); var memValues = history.mem || []; var memMax = memValues.length ? Math.min(100, Math.max(10, maxOf(memValues) + 4)) : 100; var memMin = memValues.length ? Math.max(0, minOf(memValues) - 4) : 0; sparks.mem.setOptions({ max: memMax, min: memMin }); sparks.memLarge.setOptions({ max: memMax, min: memMin }); } /* ------------------------------------------------------------------ */ /* Link / data flow */ /* ------------------------------------------------------------------ */ function setLinkState(mode, label) { var pill = $('live-pill'); var led = $('foot-led'); if (pill) { pill.setAttribute('data-state', mode); setText('live-text', label); } if (led) { led.classList.toggle('offline', mode === 'offline'); } } function onData(data, source) { if (!data || !data.host || !data.cpu) return; var first = !state.data; state.data = data; state.lastDataAt = Date.now(); state.source = source || 'sse'; state.anchorTs = num(data.ts, Date.now() / 1000); state.anchorPerf = performance.now(); state.tzOffset = num((data.host || {}).tz_offset, 0); renderAll(data); setLinkState('live', source === 'poll' ? '轮询' : 'LIVE'); if (first) { window.setTimeout(function () { refitCharts(); }, 60); } } function refitCharts() { Object.keys(sparks).forEach(function (key) { if (sparks[key] && sparks[key].resize) sparks[key].resize(); }); } function startPolling() { if (state.pollTimer) return; state.pollTimer = window.setInterval(pollSnapshot, 2000); pollSnapshot(); } function stopPolling() { if (state.pollTimer) { window.clearInterval(state.pollTimer); state.pollTimer = null; } } function pollSnapshot() { if (state.pollInFlight) return; state.pollInFlight = true; window.fetch('/api/snapshot', { cache: 'no-store', credentials: 'same-origin' }) .then(function (response) { if (!response.ok) throw new Error('HTTP ' + response.status); return response.json(); }) .then(function (data) { onData(data, 'poll'); }) .catch(function () { if (!state.data) setLinkState('offline', '离线'); }) .then(function () { state.pollInFlight = false; }); } function connectStream() { if (!('EventSource' in window)) { startPolling(); return; } if (state.es) { try { state.es.close(); } catch (err) { /* ignore */ } } var es = new window.EventSource('/api/stream'); state.es = es; es.onopen = function () { if (state.reconnectTimer) { window.clearTimeout(state.reconnectTimer); state.reconnectTimer = null; } setLinkState('live', 'LIVE'); stopPolling(); }; es.onmessage = function (event) { var data; try { data = JSON.parse(event.data); } catch (err) { return; } stopPolling(); onData(data, 'sse'); if (state.reconnectTimer) { window.clearTimeout(state.reconnectTimer); state.reconnectTimer = null; } }; es.onerror = function () { setLinkState('connecting', '重连中'); startPolling(); if (es.readyState === 2 && !state.reconnectTimer) { state.reconnectTimer = window.setTimeout(function () { state.reconnectTimer = null; connectStream(); }, 5000); } }; } function watchdog() { if (!state.data) return; var age = Date.now() - state.lastDataAt; if (age > 8000) startPolling(); if (age > 30000) setLinkState('offline', '离线'); } /* ------------------------------------------------------------------ */ /* Rotation / navigation */ /* ------------------------------------------------------------------ */ function startProgress(duration) { var progress = $('page-progress'); if (!progress) return; progress.style.animation = 'none'; void progress.offsetWidth; progress.style.animation = 'page-progress-fill ' + duration + 'ms linear forwards'; progress.style.animationPlayState = state.paused ? 'paused' : 'running'; } function scheduleRotation(duration) { window.clearTimeout(state.rotateTimer); state.rotateTimer = null; var ms = duration || state.rotateMs; state.remaining = ms; state.startedAt = performance.now(); startProgress(ms); if (state.paused) return; state.rotateTimer = window.setTimeout(function () { goTo((state.page + 1) % PAGES.length, true); }, ms); } function updateNav() { setText('page-index', pad2(state.page + 1)); setText('page-title', PAGES[state.page].title); setText('page-sub', PAGES[state.page].sub); var dots = document.querySelectorAll('.dot'); for (var i = 0; i < dots.length; i += 1) { dots[i].classList.toggle('is-active', i === state.page); dots[i].setAttribute('aria-selected', i === state.page ? 'true' : 'false'); dots[i].tabIndex = i === state.page ? 0 : -1; } } function goTo(index, fromTimer) { index = ((index % PAGES.length) + PAGES.length) % PAGES.length; if (index === state.page && !fromTimer) { scheduleRotation(); updatePauseUi(); return; } var previous = state.page; pages.forEach(function (page, i) { page.classList.toggle('is-active', i === index); page.classList.toggle('is-prev', i === previous && i !== index); page.setAttribute('aria-hidden', i === index ? 'false' : 'true'); }); state.page = index; updateNav(); scheduleRotation(); window.setTimeout(refitCharts, 80); } function updatePauseUi() { var nav = document.querySelector('.nav'); if (nav) nav.classList.toggle('is-paused', state.paused); setText('rotation-state', state.paused ? '已暂停' : '自动轮播'); } function togglePause(force) { var next = typeof force === 'boolean' ? force : !state.paused; if (next === state.paused) return; if (!state.paused && next) { var elapsed = performance.now() - state.startedAt; state.remaining = Math.max(1200, (state.remaining || state.rotateMs) - elapsed); } state.paused = next; updatePauseUi(); if (state.paused) { window.clearTimeout(state.rotateTimer); state.rotateTimer = null; var progress = $('page-progress'); if (progress) progress.style.animationPlayState = 'paused'; } else { scheduleRotation(state.remaining); } } function buildDots() { var holder = $('dots'); if (!holder) return; PAGES.forEach(function (page, index) { var button = document.createElement('button'); button.type = 'button'; button.className = 'dot'; button.textContent = pad2(index + 1); button.setAttribute('role', 'tab'); button.setAttribute('aria-label', page.title + ' / ' + page.sub); button.title = page.title + ' · ' + page.sub; button.addEventListener('click', function () { goTo(index, false); }); holder.appendChild(button); }); } function initPages() { pages.forEach(function (page, i) { page.classList.toggle('is-active', i === state.page); page.setAttribute('aria-hidden', i === state.page ? 'false' : 'true'); }); updateNav(); updatePauseUi(); scheduleRotation(); } /* ------------------------------------------------------------------ */ /* Interaction */ /* ------------------------------------------------------------------ */ function bindKeys() { document.addEventListener('keydown', function (event) { var tag = (event.target && event.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (event.code === 'Space' || event.key === ' ') { event.preventDefault(); togglePause(); } else if (event.key === 'ArrowRight') { event.preventDefault(); goTo(state.page + 1, false); } else if (event.key === 'ArrowLeft') { event.preventDefault(); goTo(state.page - 1, false); } else if (event.key >= '1' && event.key <= '5') { goTo(parseInt(event.key, 10) - 1, false); } else if (event.key === 'f' || event.key === 'F') { toggleFullscreen(); } }); } function toggleFullscreen() { if (document.fullscreenElement) { if (document.exitFullscreen) document.exitFullscreen().catch(function () {}); } else if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen().catch(function () {}); } } function fitStage() { var scale = Math.min(window.innerWidth / 1024, window.innerHeight / 768); stage.style.transform = 'scale(' + Math.max(scale, 0.1) + ')'; } function bindCursorHide() { document.addEventListener('mousemove', function () { document.body.classList.remove('hide-cursor'); window.clearTimeout(state.cursorTimer); state.cursorTimer = window.setTimeout(function () { document.body.classList.add('hide-cursor'); }, 2500); }); } function initSparks() { sparks.cpu = new Spark($('cpu-spark'), { series: [{ key: 'cpu', color: COLORS.cyan, alpha: 0.2 }], max: 100, threshold: 80 }); sparks.cpu2 = new Spark($('cpu-spark-2'), { series: [{ key: 'cpu', color: COLORS.cyan, alpha: 0.17 }], max: 100, threshold: 80 }); sparks.mem = new Spark($('mem-spark-large'), { series: [{ key: 'mem', color: COLORS.blue, alpha: 0.2 }], max: 100, threshold: 85 }); sparks.memLarge = new Spark($('mem-spark-2'), { series: [{ key: 'mem', color: COLORS.blue, alpha: 0.16 }], max: 100, threshold: 85 }); sparks.load = new Spark($('load-spark'), { series: [{ key: 'load', color: COLORS.amber, alpha: 0.18 }], max: 16 }); sparks.net = new Spark($('net-spark'), { series: [ { key: 'tx', color: COLORS.blue, alpha: 0.08 }, { key: 'rx', color: COLORS.cyan, alpha: 0.16 } ] }); sparks.netBig = new Spark($('net-spark-big'), { series: [ { key: 'tx', color: COLORS.blue, alpha: 0.1 }, { key: 'rx', color: COLORS.cyan, alpha: 0.18 } ] }); sparks.io = new Spark($('io-spark'), { series: [ { key: 'write', color: COLORS.violet, alpha: 0.08 }, { key: 'read', color: COLORS.cyan, alpha: 0.16 } ] }); } function init() { buildDots(); initPages(); initSparks(); bindKeys(); bindCursorHide(); fitStage(); tickClock(); connectStream(); window.setInterval(tickClock, 250); window.setInterval(watchdog, 4000); window.addEventListener('resize', fitStage); document.addEventListener('fullscreenchange', function () { window.setTimeout(fitStage, 80); window.setTimeout(refitCharts, 120); }); window.addEventListener('load', function () { fitStage(); refitCharts(); }); document.addEventListener('visibilitychange', function () { if (document.hidden) { if (!state.paused) { var elapsed = performance.now() - state.startedAt; state.remaining = Math.max(1200, (state.remaining || state.rotateMs) - elapsed); window.clearTimeout(state.rotateTimer); state.rotateTimer = null; var progress = $('page-progress'); if (progress) progress.style.animationPlayState = 'paused'; state.visibilityPaused = true; } } else if (state.visibilityPaused && !state.paused) { state.visibilityPaused = false; scheduleRotation(state.remaining); } }); } init(); })();