librelad b020a3f43a feat(system): re-add Disk usage to the System trends grid
The trends grid lost its disk chart; add it back as a 6th card plotting
root-filesystem % over time, alongside CPU/Memory/Network/Load/Swap. The
'disk' history series and metric-detail entry already existed, so the
expand-to-detail flow works unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 21:38:47 +01:00

414 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Admin → System — orchestrator + index view.
//
// One AdminSystem instance per page mount. Reads the URL path on init and
// dispatches to one of four sub-views:
//
// /admin/config/system → index (gauges + trends + per-app table)
// /admin/config/system/metric/<key> → single-metric deep-dive page
// /admin/config/system/app/<name> → per-container app deep-dive
// /admin/config/system/storage → Docker disk breakdown
//
// Sub-views are separate page renderers (system-metric-page.js etc.) that
// each own their own DOM + lifecycle inside #config-section. We mount one
// at a time; switching means tearing down the active renderer and starting
// a fresh one. The SPA re-runs handleAdmin() on every navigation, which
// re-runs ConfigManager.renderConfig('system') → AdminSystem.init, so the
// dispatch happens organically with the router.
//
// Live (1 Hz, via LiveSystem SSE) data flows directly to whichever sub-view
// is mounted. The index view ticks gauges in place; the metric page splices
// the live value into its chart's tail.
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 = {};
// Active sub-view renderer. Disposed on each init().
this._subview = null;
}
root() { return document.getElementById(this.rootId); }
// Path → view dispatch. AdminPath base is /admin/config/system; sub-paths
// add segments after that. Falls through to 'index' for an unknown shape
// so a typo'd URL doesn't blank the page.
_parsePath() {
const segs = String(window.location.pathname || '').split('/').filter(Boolean);
// segs = ['admin','config','system', ...]
const sub = segs[3];
if (sub === 'metric' && segs[4]) return { view: 'metric', key: decodeURIComponent(segs[4]) };
if (sub === 'app' && segs[4]) return { view: 'app', name: decodeURIComponent(segs[4]) };
if (sub === 'storage') return { view: 'storage' };
return { view: 'index' };
}
async init() {
// Tear down any previous sub-view first (re-mount across nav).
this._stopLive();
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._subview && typeof this._subview.dispose === 'function') {
try { this._subview.dispose(); } catch (_) {}
}
this._subview = null;
const parsed = this._parsePath();
const r = this.root();
if (!r) return;
// Sub-pages live in their own classes; index lives here.
if (parsed.view === 'metric' && window.SystemMetricPage) {
this._subview = new window.SystemMetricPage(this.rootId);
await this._subview.mount(parsed.key);
return;
}
if (parsed.view === 'app') {
// Per-app container deep-dive lives on the app page's Services
// tab, not under /admin/system. Redirect any direct hit so old
// bookmarks still resolve.
if (window.navigateToRoute) {
window.navigateToRoute(`/app/${encodeURIComponent(parsed.name)}/services`);
}
return;
}
if (parsed.view === 'storage' && window.SystemStoragePage) {
this._subview = new window.SystemStoragePage(this.rootId);
await this._subview.mount();
return;
}
// Default: index view.
r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
await this.refresh();
this.bind();
if (window.LiveSystem) {
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
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() {
const [metrics, history, apps, appsHist, info, storage] = 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.fetchJson('/api/system/storage')
]);
this.d = { metrics, history, apps, appsHist, info, storage };
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;
this.refresh();
return;
}
// Expand a metric → navigate to its detail page. The SPA picks up
// the new URL, ConfigManager re-renders, AdminSystem.init mounts
// the metric page.
const ex = e.target.closest('[data-sys-expand]');
if (ex) {
const k = ex.dataset.sysExpand;
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/metric/${encodeURIComponent(k)}`);
return;
}
const ap = e.target.closest('[data-sys-app]');
if (ap) {
const name = ap.dataset.sysApp;
// App rows jump to the app page's Services tab — the existing
// home for per-container detail (logs, restart, IP, ports)
// which the new docker-info endpoints now enrich with live
// stats, limits, mounts, networks and healthcheck.
if (window.navigateToRoute) window.navigateToRoute(`/app/${encodeURIComponent(name)}/services`);
return;
}
const st = e.target.closest('[data-sys-storage]');
if (st) {
if (window.navigateToRoute) window.navigateToRoute('/admin/config/system/storage');
return;
}
});
}
/* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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`; }
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 `<div class="sys-range">${opts.map(([m, l]) =>
`<button type="button" class="sys-range-btn ${this.range === m ? 'active' : ''}" data-sys-range="${m}">${l}</button>`
).join('')}</div>`;
}
chartCard(title, bodyHtml, meta, expandKey) {
const exBtn = expandKey
? `<button type="button" class="sys-expand" data-sys-expand="${expandKey}" aria-label="Expand ${title}">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>` : '';
return `<div class="sys-chart-card">
<div class="sys-chart-head"><span>${title}</span><span class="sys-chart-head-right">${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}${exBtn}</span></div>
<div class="sys-chart-body">${bodyHtml}</div>
</div>`;
}
_gaugesHtml() {
const C = window.LPCharts;
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
const wrap = (key, inner) =>
`<button type="button" class="sys-gauge-wrap" data-sys-expand="${key}" aria-label="Expand ${key}">
${inner}
<span class="sys-gauge-expand">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>`;
// Load is read relative to core count: a load equal to the number of
// cores means "fully used, no queue" — normal, not alarming. Only flag
// it red once load clearly exceeds capacity (tasks genuinely queuing).
// The ring fills toward 2x cores so load == cores sits mid-gauge
// instead of maxing out (which read as a constant red alarm on a
// low-core box). The old backend load1_percent capped at cores == 100%,
// so anything near capacity pinned the gauge red.
const cores = cpu.cores || 1;
const load1 = Number(cpu.load1 ?? 0);
const loadRatio = load1 / cores;
const loadColor = loadRatio >= 1.7 ? 'status-danger'
: loadRatio >= 1.0 ? 'status-warning'
: 'status-success';
return `
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''}` }))}`;
}
_applyLive(s) {
if (!s || !document.querySelector('.sys-page')) return;
const m = this.d.metrics || {};
this.d.metrics = {
...m,
cpu: s.cpu || m.cpu,
memory: s.memory || m.memory,
disks: Array.isArray(s.disks) && s.disks.length ? s.disks : m.disks,
network: s.network || m.network,
docker: s.docker || m.docker,
updated: new Date(s.t || Date.now()).toISOString()
};
const gaugesEl = document.querySelector('.sys-page .sys-gauges');
if (gaugesEl) gaugesEl.innerHTML = this._gaugesHtml();
const subEl = document.querySelector('.sys-page .page-header-title p');
if (subEl) subEl.textContent = `Live host and per-app statistics. Updated ${new Date(s.t || Date.now()).toLocaleTimeString()}.`;
}
render() {
const root = this.root();
if (!root) return;
const C = window.LPCharts;
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const info = this.d.info || {};
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
const rx = this.series('net_rx'), tx = this.series('net_tx');
const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
const hasSwap = (mem.swap_total || 0) > 0;
const charts = `
<div class="sys-section-head">
<h2>Trends</h2>
${this.rangeBtns()}
</div>
<div class="sys-charts">
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'cpu')}
${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'mem')}
${this.chartCard('Network',
C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) +
`<div class="sys-net-legend"><span><i class="dot ok"></i> ${this.rate(lastRx)}</span><span><i class="dot accent"></i> ${this.rate(lastTx)}</span></div>`,
'rx / tx', 'net_rx')}
${this.chartCard('Load (1m)', C.areaChart(this.series('load1'), { color: 'accent', fmt: v => v.toFixed(2) }), `${cpu.cores || '?'} cores`, 'load1')}
${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-success', max: 100, fmt: v => `${Math.round(v)}%` }), 'root %', 'disk')}
${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
</div>`;
const infoStrip = `
<div class="sys-section-head"><h2>Host</h2></div>
<div class="sys-strip">
${this.stat('OS', info.os || '—')}
${this.stat('Kernel', info.kernel || '—')}
${this.stat('Uptime', (info.uptime || '—').replace(/^up /, ''))}
${this.stat('CPU', info.cpu || '—')}
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
</div>`;
const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : [];
const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {};
const appsBody = apps.length ? apps.map(a => {
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
const statusCls = a.status === 'running' ? 'ok' : 'none';
return `<tr class="sys-app-row" data-sys-app="${this.escape(a.app)}" tabindex="0" role="button" aria-label="Open ${this.escape(a.app)} details">
<td class="sys-app-name"><span class="admin-status-dot ${statusCls}"></span>${this.escape(a.app)}
<span class="sys-app-sub">${a.running}/${a.containers} up</span></td>
<td>${C.bar(a.cpu_percent)}<span class="sys-cell-val">${(a.cpu_percent || 0).toFixed(1)}%</span></td>
<td>${C.bar(a.mem_percent)}<span class="sys-cell-val">${this.bytes(a.mem_bytes)}</span></td>
<td class="sys-net-cell">↓${this.bytes(a.net_rx)}${this.bytes(a.net_tx)}</td>
<td class="sys-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
<td class="sys-app-arrow"></td>
</tr>`;
}).join('') : `<tr><td colspan="6" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
const appsTable = `
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">click a row to open the app's Services tab · sorted by CPU</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th><th></th></tr></thead>
<tbody>${appsBody}</tbody>
</table>
</div>`;
const updated = m.updated ? new Date(m.updated).toLocaleTimeString() : '—';
root.innerHTML = `
<div class="admin-page sys-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>System</h1>
<p>Live host and per-app statistics. Updated ${updated}.</p>
</div>
</div>
${gauges}
${charts}
${this._storageSection()}
${infoStrip}
${appsTable}
</div>`;
}
// Docker storage summary: the breakdown donut + per-category legend +
// reclaimable, promoted onto the index so it's discoverable. Donut/segments
// are shared with the full breakdown page; this links through to it.
_storageSection() {
const dk = (this.d.metrics && this.d.metrics.docker) || {};
const s = this.d.storage;
const SP = window.SystemStoragePage;
const head = `
<div class="sys-section-head">
<h2>Storage</h2>
<span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images</span>
</div>`;
if (!s || !s.total || !SP) {
const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.';
return head + `
<div class="sys-storage-summary sys-storage-summary-empty">
<span>${msg}</span>
<button type="button" class="sys-storage-more" data-sys-storage>Open storage breakdown →</button>
</div>`;
}
const C = window.LPCharts;
const segments = SP.segmentsFrom(s);
const donut = SP.donutSvg(segments, s.total);
const recl = s.reclaimable || 0;
const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0;
const rows = segments.map(seg => {
const sz = (seg.data && seg.data.size) || 0;
const pct = s.total ? (sz / s.total) * 100 : 0;
return `
<div class="sys-storage-srow">
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
<span class="sys-storage-srow-k">${seg.label}</span>
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
</div>`;
}).join('');
return head + `
<div class="sys-storage-summary">
<button type="button" class="sys-storage-summary-donut" data-sys-storage aria-label="Open storage breakdown">${donut}</button>
<div class="sys-storage-summary-main">
<div class="sys-storage-srows">${rows}</div>
<div class="sys-storage-summary-foot">
<span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span>
<button type="button" class="sys-storage-more" data-sys-storage>View storage breakdown →</button>
</div>
</div>
</div>`;
}
stat(label, value) {
return `<div class="sys-stat"><span class="sys-stat-label">${this.escape(label)}</span><strong class="sys-stat-value">${this.escape(value)}</strong></div>`;
}
}
// Lightweight formatter helpers shared with sub-pages so they don't each
// reimplement bytes()/rate(). Attached as a global so a sub-page that mounts
// before AdminSystem still has them.
window.SystemFmt = window.SystemFmt || {
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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`; },
timeAgo(unixSec) {
if (!unixSec) return '';
const diff = Math.floor(Date.now() / 1000) - unixSec;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
},
timeAgoIso(iso) {
if (!iso) return '';
const t = Math.floor(new Date(iso).getTime() / 1000);
return Number.isFinite(t) && t > 0 ? this.timeAgo(t) : '';
},
rgbVar(name) {
return (getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
},
};
window.AdminSystem = AdminSystem;