librelad dbcab8614f feat(system): route-based sub-pages — metric / per-container / storage
Promotes the admin → System area from a single index page with a transient
overlay into a real router with four addressable sub-pages, plus a docker-
api-backed read surface to drive them.

URLs:
  /admin/config/system                   index (gauges + trends + per-app table)
  /admin/config/system/metric/<key>      single-metric deep-dive
  /admin/config/system/app/<name>        per-container app deep-dive
  /admin/config/system/storage           docker disk-usage breakdown

The path resolves to category=`system` in adminCategoryFromPath, so the
existing SPA dispatch still drops you into AdminSystem; AdminSystem then
reads the rest of the path and mounts the right sub-renderer into
config-section. Each sub-page owns its own DOM + lifecycle and is disposed
when the orchestrator re-mounts on the next navigation. Browser back, page
reload, and shareable URLs all work — no modal, no overlay state, no
fragile open/close lifecycle. Esc on the metric page navigates back to the
index.

Backend (containers/libreportal/backend):
  - utils/docker.js — shared client for the bind-mounted Docker socket
    (extracted from service-routes.js' inline copy). dockerRequest,
    dockerStream, and a multiplex-log decoder for /containers/:id/logs.
  - routes/docker-info-routes.js mounted at /api/system, contributes:
      GET /containers              full list, plus grouped-by-app shape
      GET /containers/:id          inspect projection (limits, mounts,
                                   networks, ports, health, restart count)
      GET /containers/:id/stats    one-shot CPU% / memory / network /
                                   blkio / pids (derived from precpu/cpu
                                   deltas, like `docker stats`)
      GET /containers/:id/logs     last N lines, multiplex-decoded
      GET /storage                 `docker system df` rolled up per
                                   category, plus top-10 images +
                                   top-10 volumes by size

Frontend (containers/libreportal/frontend/js/components/admin):
  - admin-system.js — refactored into orchestrator + index view. _parsePath
    drives dispatch; sub-views are window.SystemMetricPage /
    SystemAppPage / SystemStoragePage classes mounted into config-section.
    The per-app table is now keyboard-focusable rows that navigate to the
    per-container page; the Docker strip grows a "Storage" tile that
    navigates to the storage page.
  - system-metric-page.js (renamed from system-detail.js, rewritten as an
    in-flow page renderer). Same chart visuals as the old overlay — grid,
    axis, area gradient, peak/min/now markers, hover crosshair + tooltip
    scrubbing, per-metric accent theming — but rendered into the page
    instead of a fixed-position panel. Range picker reflects to ?range=
    so refresh preserves the selection. 1 Hz SSE feed splices into the
    chart tail in real time.
  - system-app-page.js — for each container in the app stack: status,
    image, image-id, uptime; live stats card (cpu / mem with limit-pct /
    rx / tx / blkio r-w / pids, polled every 2s with warn+danger colour
    cues at 80% and 95% of memory limit); limits panel (memory, cpu,
    pids, restart policy, restart count, started-ago); healthcheck
    status + last 3 probes; networks table (name, IP, gateway, MAC);
    published ports; mounts table with type badges; collapsible log tail
    with refresh.
  - system-storage-page.js — donut chart (cumulative-arc, hand-rolled
    SVG) splits total in-use disk by images / volumes / containers /
    build cache; per-category cards with size + reclaimable; top-10
    images and top-10 volumes tables with "unused" / "orphan" badges.

CSS (containers/libreportal/frontend/css/admin.css):
  Overlay-specific rules (.sys-detail wrapper, backdrop, panel, close
  button, body lock) removed. Inner chart rules (stats grid, svg, grid,
  axes, peak/min/now, crosshair, tooltip, foot) retained and reused by
  the metric page. New blocks for .sys-metric-page, .sys-app-page (with
  stat warn/danger colour states, health pills, mount-type badges, log
  pre styling), .sys-storage-page (donut + legend + headline + per-
  category cards + orphan/unused badges), .sys-app-row (clickable
  rows with arrow + accent hover), .sys-stat-link (clickable Docker
  strip tile).

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 21:53:13 +01:00

356 lines
17 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' && window.SystemAppPage) {
this._subview = new window.SystemAppPage(this.rootId);
await this._subview.mount(parsed.name);
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] = 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;
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;
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/app/${encodeURIComponent(name)}`);
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>`;
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(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', 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 || {}, dk = m.docker || {};
const info = this.d.info || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
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('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/', 'disk')}
${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')}
${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-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>`;
// Docker strip — now a navigational tile too. "Storage" leads to the
// dedicated breakdown page; the rest are display-only.
const dockerStrip = `
<div class="sys-section-head"><h2>Docker</h2></div>
<div class="sys-strip">
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
${this.stat('Images', String(dk.images ?? 0))}
${this.stat('Volumes', String(dk.volumes ?? 0))}
<button type="button" class="sys-stat sys-stat-link" data-sys-storage aria-label="Open storage breakdown">
<span class="sys-stat-label">Storage</span>
<strong class="sys-stat-value">Open breakdown →</strong>
</button>
</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 deep-dive · 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}
${infoStrip}
${dockerStrip}
${appsTable}
</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;