// Admin → System — in-depth host + per-app statistics. Live ring gauges for the
// headline numbers, SVG trend charts driven by the metrics history ring buffer,
// a Docker summary, and a per-app resource table.
//
// Two data paths:
// - Live (1 Hz, via LiveSystem SSE): CPU%, memory, load, disks, network,
// docker totals. Updates the gauges in place each second so they tick like
// a real instrument.
// - Periodic (every 30 s, via fetch): the history ring buffer + per-app
// table. These regenerate at most once a minute on the host, so polling
// them faster would be wasted bandwidth.
// Renders into #config-section.
class AdminSystem {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.range = 60; // minutes of history to chart
this._timer = null;
this._unsubLive = null;
this.d = {};
}
root() { return document.getElementById(this.rootId); }
async init() {
const r = this.root();
if (r) r.innerHTML = '
Loading system stats…
';
await this.refresh();
this.bind();
this._stopLive();
// 1 Hz live gauges via the shared EventSource manager.
if (window.LiveSystem) {
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
// History/per-app refresh stays slower — those files only regenerate
// once a minute on the host. Stop both paths once the user navigates off.
if (this._timer) clearInterval(this._timer);
this._timer = setInterval(() => {
if (!document.querySelector('.sys-page')) {
clearInterval(this._timer); this._timer = null;
this._stopLive();
return;
}
this.refresh();
}, 30000);
}
_stopLive() {
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
}
async refresh() {
// History now comes from /api/system/history (binary ring backed,
// supports up to 7 days). Everything else stays on the static JSONs
// the host generator writes.
const [metrics, history, apps, appsHist, info] = await Promise.all([
this.fetchJson('/data/system/metrics.json'),
this.fetchJson(`/api/system/history?range=${this.range}`),
this.fetchJson('/data/system/metrics_apps.json'),
this.fetchJson('/data/system/metrics_apps_history.json'),
this.fetchJson('/data/system/system_info.json')
]);
this.d = { metrics, history, apps, appsHist, info };
this.render();
}
async fetchJson(url) {
try { const r = await fetch(`${url}?t=${Date.now()}`); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
bind() {
if (this._bound) return;
this._bound = true;
document.addEventListener('click', (e) => {
if (!document.querySelector('.sys-page')) return;
const rb = e.target.closest('[data-sys-range]');
if (rb) {
this.range = parseInt(rb.dataset.sysRange) || 60;
// 7d needs a server hit because we don't keep that locally.
this.refresh();
return;
}
// Expand any metric surface — gauges, chart cards, table rows.
const ex = e.target.closest('[data-sys-expand]');
if (ex && window.systemDetail) {
const k = ex.dataset.sysExpand;
const def = this._metricDefs()[k];
if (def) window.systemDetail.open(def);
}
});
}
// Metric definitions consumed by the fullscreen detail overlay. Each maps
// the history-ring key to its labels, units, formatter, and accent color.
_metricDefs() {
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const root = disks.find(d => d.mount === '/') || disks[0] || {};
const pctFmt = (v) => `${v.toFixed(1)}%`;
const rateFmt = (v) => this.rate(v);
const loadFmt = (v) => v.toFixed(2);
// Pull the literal RGB triplet for each metric so the overlay can
// theme its gradient + accents per metric. Falls back to --accent-rgb.
const rgbVar = (name) =>
(getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
return {
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${cpu.cores || '?'} cores`, accentRgb: rgbVar('accent') },
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}`, accentRgb: rgbVar('status-info') },
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pctFmt, sublabel: mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'no swap', accentRgb: rgbVar('status-warning') },
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pctFmt, sublabel: root.mount || '/', accentRgb: rgbVar('status-warning') },
load1: { key: 'load1', label: 'Load average', fmt: loadFmt, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'} (${cpu.cores || '?'} cores)`, accentRgb: rgbVar('accent') },
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('status-success') },
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('accent') },
};
}
/* ---- formatting helpers ---- */
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
bytes(n) {
n = Number(n) || 0;
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
rate(n) { return `${this.bytes(n)}/s`; }
// Last N minutes of a given key from the history ring buffer.
series(key) {
const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : [];
return pts.slice(-this.range).map(p => Number(p[key]) || 0);
}
rangeBtns() {
const opts = [[60, '1h'], [360, '6h'], [1440, '24h'], [10080, '7d']];
return `