librelad 28e007d087 feat(system): distro icon beside the OS on the System host strip
Bundle a small set of distro marks (Debian/Ubuntu/Fedora/Arch) plus a
generic Linux/Tux fallback under /icons/os/, and show the icon next to the
OS value, keyed off the cleaned distro name. Locally bundled — no external
calls — and unknown distros fall back to the generic glyph.

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

448 lines
23 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/system → index (gauges + trends + per-app table)
// /admin/system/metric/<key> → single-metric deep-dive page
// /admin/system/app/<name> → per-container app deep-dive
// /admin/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/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);
// Locate 'system' and read the sub-view after it, so dispatch works for
// /admin/system/<sub>/<arg> regardless of leading segments.
const i = segs.indexOf('system');
const sub = i >= 0 ? segs[i + 1] : undefined;
const arg = i >= 0 ? segs[i + 2] : undefined;
if (sub === 'metric' && arg) return { view: 'metric', key: decodeURIComponent(arg) };
if (sub === 'app' && arg) return { view: 'app', name: decodeURIComponent(arg) };
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, appStorage] = 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.fetchJson('/data/system/app_storage.json')
]);
this.d = { metrics, history, apps, appsHist, info, storage, appStorage };
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/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/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';
// The disk ring colours its leading portion to show the slice that's
// LibrePortal (app data on disk + Docker images/cache) — one ring, total
// disk used overall, the LibrePortal part highlighted within it.
const lpBytes = ((this.d.appStorage && this.d.appStorage.total_local) || 0)
+ ((this.d.storage && this.d.storage.total) || 0);
const diskTotal = Number(rootDisk.total) || 0;
const lpPct = diskTotal > 0 ? (lpBytes / diskTotal) * 100 : 0;
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', segment: { value: lpPct, color: 'accent' } }))}
${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._osStat(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-cta" data-sys-storage title="Open the full storage breakdown">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
Open storage breakdown
</button>
</div>`;
}
const C = window.LPCharts;
// Summary breakdown: one "Applications" total + Docker images/cache. The
// per-app split lives on the full Storage page this links to.
const segments = SP.summarySegments(this.d.appStorage, s);
const grandTotal = segments.reduce((t, seg) => t + ((seg.data && seg.data.size) || 0), 0);
const donut = SP.donutSvg(segments, grandTotal, 'in use');
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 = grandTotal ? (sz / grandTotal) * 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">${this.escape(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-cta" data-sys-storage title="Open the full storage breakdown">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
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>`;
}
// OS stat with a distro icon (bundled under /icons/os/, generic Linux glyph
// for anything we don't have a logo for).
_osStat(os) {
const slug = String(os || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'linux';
return `<div class="sys-stat">
<span class="sys-stat-label">OS</span>
<strong class="sys-stat-value sys-os-value">
<img class="sys-os-icon" src="/icons/os/${slug}.svg" alt="" onerror="this.onerror=null;this.src='/icons/os/linux.svg'">
${this.escape(os || '—')}
</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;