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>
383 lines
18 KiB
JavaScript
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;
|