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

383 lines
18 KiB
JavaScript

// Admin → System → App — per-container deep-dive page.
//
// Mounted at /admin/config/system/app/<name>. Lists every container in the
// compose project (or single-container "app") and renders a rich card per
// container:
//
// - Status badge + uptime + restart count
// - Live cpu/mem/network/blkio (polled every 2s from /api/system/containers/<id>/stats)
// - Memory-limit gauge if a limit is set; otherwise text "unlimited"
// - CPU quota / shares summary
// - Health-check state + last log entries (if a healthcheck is configured)
// - Image, image digest (short), created/started timestamps
// - Networks (name, IP, MAC, gateway) and published ports
// - Mounts (volumes + binds), with type/mode badges
// - Recent log tail (collapsible, last 200 lines, refresh button)
//
// One backend hit at mount: GET /api/system/containers (list). Per-card
// detail (limits, mounts, networks) comes from GET /api/system/containers/:id.
// Live numbers come from GET /api/system/containers/:id/stats every 2s.
// Stats endpoint is cached server-side, so multiple tabs share the cost.
class SystemAppPage {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.appName = null;
this.members = []; // [{ id, name, ... }] from /containers
this.details = new Map(); // id -> /containers/:id detail
this.stats = new Map(); // id -> latest /containers/:id/stats sample
this._timer = null;
this._onClick = this._onClick.bind(this);
}
root() { return document.getElementById(this.rootId); }
async mount(name) {
this.appName = name;
await this._loadList();
this._renderShell();
// Kick off detail + stats fetches for each member in parallel.
await Promise.all(this.members.map(m => this._loadDetail(m.id)));
await Promise.all(this.members.map(m => this._loadStats(m.id)));
this._renderCards();
this._bind();
// Poll live stats every 2s. Containers refresh list every 15s.
this._timer = setInterval(() => {
if (!document.querySelector('.sys-app-page')) {
clearInterval(this._timer); this._timer = null;
return;
}
this._tickStats();
}, 2000);
this._slowTimer = setInterval(() => {
if (!document.querySelector('.sys-app-page')) {
clearInterval(this._slowTimer); this._slowTimer = null;
return;
}
this._loadList().then(() => this._renderHeader());
}, 15000);
}
dispose() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._slowTimer) { clearInterval(this._slowTimer); this._slowTimer = null; }
const r = this.root();
if (r) r.removeEventListener('click', this._onClick);
}
async _loadList() {
try {
const r = await fetch('/api/system/containers');
const j = await r.json().catch(() => ({}));
const apps = Array.isArray(j?.apps) ? j.apps : [];
const me = apps.find(a => a.app === this.appName);
this.members = me && Array.isArray(me.members) ? me.members : [];
} catch (_) {
this.members = [];
}
}
async _loadDetail(id) {
try {
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}`);
if (!r.ok) return;
const d = await r.json();
this.details.set(id, d);
} catch (_) { /* leave missing */ }
}
async _loadStats(id) {
try {
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/stats`);
if (!r.ok) return;
this.stats.set(id, await r.json());
} catch (_) { /* leave missing */ }
}
async _tickStats() {
// Only stat running containers — stats for stopped containers return
// zeros and waste a daemon roundtrip.
const running = this.members.filter(m => m.state === 'running').map(m => m.id);
await Promise.all(running.map(id => this._loadStats(id)));
for (const id of running) this._renderLive(id);
}
_bind() {
const r = this.root();
if (r) r.addEventListener('click', this._onClick);
}
_onClick(e) {
const lt = e.target.closest('[data-logs-toggle]');
if (lt) {
const id = lt.dataset.logsToggle;
const body = this.root().querySelector(`[data-logs-body="${id}"]`);
if (body) {
const open = !body.hidden;
body.hidden = open;
lt.textContent = open ? 'Show logs' : 'Hide logs';
if (!open && !body.dataset.loaded) {
this._loadLogs(id, body);
}
}
return;
}
const lr = e.target.closest('[data-logs-refresh]');
if (lr) {
const id = lr.dataset.logsRefresh;
const body = this.root().querySelector(`[data-logs-body="${id}"]`);
if (body) { body.dataset.loaded = ''; this._loadLogs(id, body); }
return;
}
const back = e.target.closest('[data-back]');
if (back && window.navigateToRoute) {
window.navigateToRoute('/admin/config/system');
}
}
async _loadLogs(id, bodyEl) {
bodyEl.innerHTML = '<div class="sys-app-logs-loading">Loading…</div>';
try {
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/logs?tail=200`);
const text = await r.text();
bodyEl.dataset.loaded = '1';
// Render as a pre-block; strip ANSI just in case (rare in our
// containers but cheap to do).
const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
bodyEl.innerHTML = `<pre class="sys-app-logs-pre">${(window.SystemFmt?.escape || ((s)=>s))(clean) || '<em>empty</em>'}</pre>`;
const pre = bodyEl.querySelector('pre');
if (pre) pre.scrollTop = pre.scrollHeight;
} catch (err) {
bodyEl.innerHTML = `<div class="sys-app-logs-err">Failed to load logs: ${(err && err.message) || err}</div>`;
}
}
_renderShell() {
const r = this.root();
if (!r) return;
const fmt = window.SystemFmt;
const totalRunning = this.members.filter(m => m.state === 'running').length;
const header = `
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">
<a href="/admin/config/system" data-back>Admin · System</a>
</div>
<h1>${fmt.escape(this.appName)}</h1>
<p class="sys-app-sub" data-app-sub>${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running</p>
</div>
</div>`;
if (!this.members.length) {
r.innerHTML = `
<div class="admin-page sys-app-page">
${header}
<div class="sys-app-empty">
No containers found for "<strong>${fmt.escape(this.appName)}</strong>".
It may not be installed, or its compose project label differs from the app name.
</div>
</div>`;
return;
}
r.innerHTML = `
<div class="admin-page sys-app-page">
${header}
<div class="sys-app-grid" data-app-grid>
${this.members.map(m => this._cardSkeleton(m)).join('')}
</div>
</div>`;
}
_renderHeader() {
const r = this.root();
if (!r) return;
const sub = r.querySelector('[data-app-sub]');
if (!sub) return;
const totalRunning = this.members.filter(m => m.state === 'running').length;
sub.textContent = `${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running`;
}
_cardSkeleton(member) {
const fmt = window.SystemFmt;
const statusCls = member.state === 'running' ? 'ok' : (member.state === 'restarting' ? 'warn' : 'none');
const service = member.service ? `<span class="sys-app-card-svc">${fmt.escape(member.service)}</span>` : '';
return `
<article class="sys-app-card" data-cid="${fmt.escape(member.id)}">
<header class="sys-app-card-head">
<div>
<div class="sys-app-card-status">
<span class="admin-status-dot ${statusCls}"></span>
<span class="sys-app-card-state">${fmt.escape(member.state || 'unknown')}</span>
${service}
</div>
<h2 class="sys-app-card-name">${fmt.escape(member.name)}</h2>
<div class="sys-app-card-meta">
<span title="${fmt.escape(member.image_id || '')}">${fmt.escape(member.image || '—')}</span>
<span class="sys-app-card-sep">·</span>
<span>${fmt.escape(member.short || '')}</span>
</div>
</div>
<div class="sys-app-card-status-line">${fmt.escape(member.status || '')}</div>
</header>
<div class="sys-app-card-stats" data-live="${fmt.escape(member.id)}">
<div class="sys-app-stat"><span class="sys-app-stat-k">CPU</span><strong class="sys-app-stat-v" data-live-k="cpu">—</strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">Memory</span><strong class="sys-app-stat-v" data-live-k="mem">—</strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">↓ rx</span><strong class="sys-app-stat-v" data-live-k="rx">—</strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">↑ tx</span><strong class="sys-app-stat-v" data-live-k="tx">—</strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">Block r/w</span><strong class="sys-app-stat-v" data-live-k="blk">—</strong></div>
<div class="sys-app-stat"><span class="sys-app-stat-k">PIDs</span><strong class="sys-app-stat-v" data-live-k="pids">—</strong></div>
</div>
<div class="sys-app-card-body" data-body="${fmt.escape(member.id)}">
<div class="sys-app-card-loading">Loading container detail…</div>
</div>
<div class="sys-app-card-logs">
<div class="sys-app-card-logs-head">
<button type="button" class="sys-app-logs-toggle" data-logs-toggle="${fmt.escape(member.id)}">Show logs</button>
<button type="button" class="sys-app-logs-refresh" data-logs-refresh="${fmt.escape(member.id)}" title="Refresh">↻</button>
</div>
<div class="sys-app-card-logs-body" data-logs-body="${fmt.escape(member.id)}" hidden></div>
</div>
</article>`;
}
_renderCards() {
for (const m of this.members) this._renderDetail(m.id);
for (const m of this.members) this._renderLive(m.id);
}
_renderDetail(id) {
const r = this.root();
if (!r) return;
const body = r.querySelector(`[data-body="${id}"]`);
if (!body) return;
const d = this.details.get(id);
if (!d) {
body.innerHTML = `<div class="sys-app-card-err">Couldn't load container detail.</div>`;
return;
}
const fmt = window.SystemFmt;
const lim = d.limits || {};
const memLimit = lim.memory;
const cpuLimit = lim.nano_cpus
? `${(lim.nano_cpus / 1e9).toFixed(2)} CPU${(lim.nano_cpus / 1e9) === 1 ? '' : 's'}`
: (lim.cpu_quota && lim.cpu_period
? `${(lim.cpu_quota / lim.cpu_period).toFixed(2)} CPU equiv`
: 'unlimited');
const limitsRow = `
<div class="sys-app-card-limits">
<div class="sys-app-limit">
<span class="sys-app-limit-k">Memory limit</span>
<strong class="sys-app-limit-v">${memLimit ? fmt.bytes(memLimit) : 'unlimited'}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">CPU limit</span>
<strong class="sys-app-limit-v">${fmt.escape(cpuLimit)}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">PIDs limit</span>
<strong class="sys-app-limit-v">${lim.pids ? lim.pids : 'unlimited'}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Restart policy</span>
<strong class="sys-app-limit-v">${fmt.escape(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Restart count</span>
<strong class="sys-app-limit-v">${d.state?.restart_count ?? 0}</strong>
</div>
<div class="sys-app-limit">
<span class="sys-app-limit-k">Started</span>
<strong class="sys-app-limit-v">${d.state?.started_at ? fmt.timeAgoIso(d.state.started_at) : '—'}</strong>
</div>
</div>`;
const health = d.state?.health
? `<div class="sys-app-section">
<h3>Healthcheck</h3>
<div class="sys-app-health">
<span class="sys-app-health-pill sys-app-health-${fmt.escape(d.state.health.status || 'unknown')}">${fmt.escape(d.state.health.status || 'unknown')}</span>
${d.state.health.failing_streak ? `<span class="sys-app-health-fail">${d.state.health.failing_streak} failing in a row</span>` : ''}
</div>
${(d.state.health.log || []).slice(-3).reverse().map(l =>
`<details class="sys-app-health-log"><summary>${fmt.escape(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}</summary><pre>${fmt.escape(l.output || '')}</pre></details>`
).join('')}
</div>` : '';
const nets = (d.networks || []).length
? `<div class="sys-app-section">
<h3>Networks</h3>
<table class="sys-app-table">
<thead><tr><th>Name</th><th>IP</th><th>Gateway</th><th>MAC</th></tr></thead>
<tbody>${d.networks.map(n => `
<tr><td>${fmt.escape(n.name)}</td><td>${fmt.escape(n.ip || '—')}</td><td>${fmt.escape(n.gateway || '—')}</td><td>${fmt.escape(n.mac || '—')}</td></tr>
`).join('')}</tbody>
</table>
</div>` : '';
const ports = (d.ports || []).filter(p => p.host).length
? `<div class="sys-app-section">
<h3>Published ports</h3>
<ul class="sys-app-ports">${(d.ports || []).filter(p => p.host).map(p =>
`<li><strong>${p.host}</strong> → ${p.container}/${fmt.escape(p.proto || '')}</li>`
).join('')}</ul>
</div>` : '';
const mounts = (d.mounts || []).length
? `<div class="sys-app-section">
<h3>Mounts</h3>
<table class="sys-app-table">
<thead><tr><th>Type</th><th>Source</th><th>Target</th><th>Mode</th></tr></thead>
<tbody>${d.mounts.map(m => `
<tr><td><span class="sys-app-mount-type sys-app-mount-${fmt.escape(m.type || '')}">${fmt.escape(m.type || '')}</span></td>
<td class="sys-app-mount-path">${fmt.escape(m.source || '')}</td>
<td class="sys-app-mount-path">${fmt.escape(m.target || '')}</td>
<td>${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''}</td></tr>
`).join('')}</tbody>
</table>
</div>` : '';
body.innerHTML = `${limitsRow}${health}${nets}${ports}${mounts}`;
}
_renderLive(id) {
const r = this.root();
if (!r) return;
const card = r.querySelector(`[data-live="${id}"]`);
if (!card) return;
const s = this.stats.get(id);
const d = this.details.get(id);
const memLimit = d?.limits?.memory || 0;
const fmt = window.SystemFmt;
if (!s) {
// Container stopped or stats not loaded — clear the row.
for (const v of card.querySelectorAll('.sys-app-stat-v')) v.textContent = '—';
return;
}
const cpu = s.cpu_percent ?? 0;
const memUsed = s.memory?.used ?? 0;
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : (s.memory?.percent ?? 0);
const rx = s.network?.rx_total ?? 0;
const tx = s.network?.tx_total ?? 0;
const br = s.blkio?.read ?? 0;
const bw = s.blkio?.write ?? 0;
const pids = s.pids?.current ?? 0;
const set = (k, v) => { const el = card.querySelector(`[data-live-k="${k}"]`); if (el) el.textContent = v; };
set('cpu', `${cpu.toFixed(1)}%`);
set('mem', memLimit > 0 ? `${fmt.bytes(memUsed)} (${memPct.toFixed(0)}%)` : fmt.bytes(memUsed));
set('rx', fmt.bytes(rx));
set('tx', fmt.bytes(tx));
set('blk', `${fmt.bytes(br)} / ${fmt.bytes(bw)}`);
set('pids', pids ? String(pids) : '—');
// Memory limit headroom — colour the cell when above 80%.
const memEl = card.querySelector('[data-live-k="mem"]');
if (memEl) {
memEl.classList.toggle('warn', memLimit > 0 && memPct >= 80);
memEl.classList.toggle('danger', memLimit > 0 && memPct >= 95);
}
}
}
window.SystemAppPage = SystemAppPage;