(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) + ' | ' +
'' +
'' + fmtPct(cpu, 1) + ' |
';
}
return '| ' + escapeHtml(proc.pid) + ' | ' +
'' + escapeHtml(proc.name) + '' + escapeHtml(proc.cmd || '') + ' | ' +
'' + fmtPct(cpu, 1) + ' | ' +
'' + fmtPct(proc.mem_percent, 1) + ' | ' +
'
';
}).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();
})();