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

421 lines
17 KiB
JavaScript

// Read-only Docker inspection routes for the Admin → System deep-dive pages.
//
// GET /api/system/containers
// List every container with a per-app summary (compose-project grouping
// mirrors what metrics_apps.json does, but with extra per-container
// fields the deep-dive pages need: state, status, image, ports, mounts,
// restart count). Cached for STAT_TTL_MS.
//
// GET /api/system/containers/:id
// Full container detail straight from `docker inspect`. Includes
// resource limits, mounts, networks, env, health-check state, restart
// policy. Cached briefly so the page can poll without thrashing the
// daemon.
//
// GET /api/system/containers/:id/stats
// One-shot live stats sample (one frame of /containers/<id>/stats).
// Returns the same shape Docker emits, plus a derived `cpu_percent`
// and `mem_percent` so the frontend doesn't have to recompute.
//
// GET /api/system/containers/:id/logs?tail=N
// Last N lines of combined stdout/stderr, multiplex-decoded.
//
// GET /api/system/storage
// `docker system df` — total + reclaimable per category (images,
// containers, volumes, build cache). Cached for STORAGE_TTL_MS because
// this is one of the more expensive calls on a busy daemon.
//
// Mounted at /api/system in routes.js (so paths are /api/system/containers
// etc.). Uses the shared docker util (utils/docker.js) which talks to the
// bind-mounted unix socket — no `docker` CLI inside the container, no
// extra deps.
const express = require('express');
const { dockerRequest, dockerStream, decodeMultiplexedLog, DOCKER_SOCKET } = require('../utils/docker.js');
const router = express.Router();
const STAT_TTL_MS = 1500;
const STORAGE_TTL_MS = 5000;
const LIST_TTL_MS = 1500;
// Trivial per-key TTL cache so concurrent tabs don't pile up daemon calls.
function makeTtlCache(ttl) {
const m = new Map();
return {
async get(key, loader) {
const now = Date.now();
const hit = m.get(key);
if (hit && (now - hit.at) < ttl) return hit.value;
const value = await loader();
m.set(key, { value, at: now });
return value;
},
invalidate(key) { m.delete(key); },
};
}
const listCache = makeTtlCache(LIST_TTL_MS);
const statCache = makeTtlCache(STAT_TTL_MS);
const inspectCache = makeTtlCache(STAT_TTL_MS);
const storageCache = makeTtlCache(STORAGE_TTL_MS);
// Container id slug used on the wire. Real Docker ids are 64-char hex; the
// short form is the first 12 chars. We accept either, plus container
// *names* (which can include /., _, -). Anything else is rejected.
const SAFE_CONTAINER_REF = /^[a-zA-Z0-9_.\-]{1,128}$/;
function safeRef(s) { return typeof s === 'string' && SAFE_CONTAINER_REF.test(s); }
function reduceContainer(c) {
// Compose project label tells us which "app" (in LibrePortal terms) a
// container belongs to. Containers without that label fall back to
// their own name as a single-container app.
const labels = c.Labels || {};
const project = labels['com.docker.compose.project'] || (c.Names && c.Names[0] ? c.Names[0].replace(/^\//, '') : null);
const service = labels['com.docker.compose.service'] || null;
const name = (c.Names && c.Names[0]) ? c.Names[0].replace(/^\//, '') : c.Id?.slice(0, 12) || '';
const networks = c.NetworkSettings && c.NetworkSettings.Networks
? Object.entries(c.NetworkSettings.Networks).map(([n, v]) => ({ name: n, ip: v?.IPAddress || null }))
: [];
const ports = Array.isArray(c.Ports)
? c.Ports.map(p => ({ ip: p.IP || null, host: p.PublicPort || null, container: p.PrivatePort, proto: p.Type }))
: [];
return {
id: c.Id,
short: (c.Id || '').slice(0, 12),
name,
image: c.Image,
image_id: c.ImageID,
project,
service,
state: c.State,
status: c.Status,
created: c.Created, // epoch seconds
labels,
ports,
networks,
mounts: Array.isArray(c.Mounts) ? c.Mounts.map(m => ({
type: m.Type,
source: m.Source,
target: m.Destination,
mode: m.Mode,
rw: m.RW,
})) : [],
};
}
// CPU% from a Docker stats frame. The daemon emits cumulative CPU usage in
// nanoseconds plus the system-wide CPU time; the percentage is the delta
// over the previous frame normalised by online CPUs. The first call has no
// prev frame, so Docker conveniently sends both `cpu_stats` (current) and
// `precpu_stats` (previous) in every frame.
function cpuPercent(s) {
const cpu = s.cpu_stats, pre = s.precpu_stats;
if (!cpu || !pre) return 0;
const cpuDelta = (cpu.cpu_usage?.total_usage || 0) - (pre.cpu_usage?.total_usage || 0);
const sysDelta = (cpu.system_cpu_usage || 0) - (pre.system_cpu_usage || 0);
const onlineCpus = cpu.online_cpus
|| (cpu.cpu_usage?.percpu_usage?.length)
|| 1;
if (sysDelta <= 0 || cpuDelta < 0) return 0;
return +((cpuDelta / sysDelta) * onlineCpus * 100).toFixed(2);
}
function memUsage(s) {
const m = s.memory_stats || {};
const usage = m.usage || 0;
// Docker counts page-cache in `usage`; the "real" working set excludes
// cached memory. Matches what `docker stats` shows.
const cache = (m.stats && (m.stats.cache || m.stats.total_inactive_file)) || 0;
const used = Math.max(0, usage - cache);
const limit = m.limit || 0;
return {
used, cache, limit,
percent: limit ? +((used / limit) * 100).toFixed(2) : 0,
};
}
function netUsage(s) {
const nets = s.networks || {};
let rx = 0, tx = 0;
for (const v of Object.values(nets)) {
rx += v.rx_bytes || 0;
tx += v.tx_bytes || 0;
}
return { rx_total: rx, tx_total: tx };
}
function blkioUsage(s) {
const b = (s.blkio_stats && s.blkio_stats.io_service_bytes_recursive) || [];
let read = 0, write = 0;
for (const e of b) {
if (e.op === 'Read' || e.op === 'read') read += e.value || 0;
else if (e.op === 'Write' || e.op === 'write') write += e.value || 0;
}
return { read, write };
}
function pidsUsage(s) {
const p = s.pids_stats || {};
return { current: p.current || 0, limit: p.limit || 0 };
}
// ---------------------------------------------------------------------------
router.get('/containers', async (req, res) => {
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
try {
const list = await listCache.get('all', () =>
dockerRequest('GET', '/containers/json', { all: 'true' })
);
const containers = (Array.isArray(list) ? list : []).map(reduceContainer);
// Group by project for convenience — frontend uses both shapes.
const byApp = new Map();
for (const c of containers) {
const key = c.project || c.name;
if (!byApp.has(key)) byApp.set(key, []);
byApp.get(key).push(c);
}
const apps = [...byApp.entries()].map(([app, members]) => {
const running = members.filter(c => c.state === 'running').length;
return { app, containers: members.length, running, members };
}).sort((a, b) => b.running - a.running || a.app.localeCompare(b.app));
res.set('Cache-Control', 'no-store');
res.json({ containers, apps, updated: new Date().toISOString() });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/containers/:id', async (req, res) => {
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
const { id } = req.params;
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
try {
const detail = await inspectCache.get(`inspect:${id}`, () =>
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/json`)
);
if (!detail) return res.status(404).json({ error: 'not_found' });
// Project the verbose inspect payload down to what the deep-dive
// page actually wants. Keeps the wire small and the frontend
// contract stable.
const host = detail.HostConfig || {};
const state = detail.State || {};
const cfg = detail.Config || {};
const netSettings = detail.NetworkSettings || {};
const out = {
id: detail.Id,
short: (detail.Id || '').slice(0, 12),
name: (detail.Name || '').replace(/^\//, ''),
image: cfg.Image || detail.Image,
image_id: detail.Image,
created: detail.Created,
project: (cfg.Labels && cfg.Labels['com.docker.compose.project']) || null,
service: (cfg.Labels && cfg.Labels['com.docker.compose.service']) || null,
labels: cfg.Labels || {},
state: {
status: state.Status,
running: !!state.Running,
paused: !!state.Paused,
restarting: !!state.Restarting,
oom_killed: !!state.OOMKilled,
dead: !!state.Dead,
pid: state.Pid || 0,
exit_code: state.ExitCode ?? null,
error: state.Error || '',
started_at: state.StartedAt || null,
finished_at: state.FinishedAt || null,
restart_count: detail.RestartCount || 0,
health: state.Health ? {
status: state.Health.Status,
failing_streak: state.Health.FailingStreak,
log: (state.Health.Log || []).slice(-5).map(l => ({
start: l.Start, end: l.End, exit_code: l.ExitCode, output: (l.Output || '').slice(0, 800)
})),
} : null,
},
limits: {
memory: host.Memory || 0, // bytes; 0 = unlimited
memory_swap: host.MemorySwap || 0,
memory_reservation: host.MemoryReservation || 0,
cpu_shares: host.CpuShares || 0,
cpu_quota: host.CpuQuota || 0, // microseconds in CpuPeriod
cpu_period: host.CpuPeriod || 0,
nano_cpus: host.NanoCpus || 0, // 1e9 = 1 cpu
pids: host.PidsLimit || 0,
restart_policy: (host.RestartPolicy && host.RestartPolicy.Name) || 'no',
restart_max: (host.RestartPolicy && host.RestartPolicy.MaximumRetryCount) || 0,
},
mounts: (detail.Mounts || []).map(m => ({
type: m.Type,
source: m.Source,
target: m.Destination,
mode: m.Mode,
rw: m.RW,
})),
networks: netSettings.Networks
? Object.entries(netSettings.Networks).map(([name, v]) => ({
name,
ip: v?.IPAddress || null,
mac: v?.MacAddress || null,
gateway: v?.Gateway || null,
}))
: [],
ports: netSettings.Ports
? Object.entries(netSettings.Ports).flatMap(([k, bindings]) => {
const [containerPort, proto] = k.split('/');
if (!Array.isArray(bindings) || !bindings.length) {
return [{ container: parseInt(containerPort, 10), proto, host: null, ip: null }];
}
return bindings.map(b => ({
container: parseInt(containerPort, 10),
proto,
host: b.HostPort ? parseInt(b.HostPort, 10) : null,
ip: b.HostIp || null,
}));
})
: [],
};
res.set('Cache-Control', 'no-store');
res.json(out);
} catch (err) {
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
res.status(500).json({ error: err.message });
}
});
router.get('/containers/:id/stats', async (req, res) => {
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
const { id } = req.params;
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
try {
const sample = await statCache.get(`stats:${id}`, () =>
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/stats`, { stream: 'false' })
);
if (!sample) return res.status(404).json({ error: 'not_found' });
res.set('Cache-Control', 'no-store');
res.json({
t: Date.now(),
cpu_percent: cpuPercent(sample),
memory: memUsage(sample),
network: netUsage(sample),
blkio: blkioUsage(sample),
pids: pidsUsage(sample),
});
} catch (err) {
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
res.status(500).json({ error: err.message });
}
});
router.get('/containers/:id/logs', async (req, res) => {
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
const { id } = req.params;
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
try {
const { stream } = await dockerStream(`/containers/${encodeURIComponent(id)}/logs`, {
stdout: 'true', stderr: 'true', tail: String(tail), timestamps: 'true',
});
const chunks = [];
for await (const c of stream) chunks.push(c);
const text = decodeMultiplexedLog(Buffer.concat(chunks));
res.set('Cache-Control', 'no-store');
res.type('text/plain').send(text);
} catch (err) {
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
res.status(500).json({ error: err.message });
}
});
router.get('/storage', async (req, res) => {
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
try {
const df = await storageCache.get('df', () => dockerRequest('GET', '/system/df'));
if (!df) return res.status(500).json({ error: 'no_data' });
// Roll the verbose response up into headline numbers per category.
const sumImages = (df.Images || []).reduce(
(a, im) => {
a.count++;
a.size += im.Size || 0;
a.shared += im.SharedSize || 0;
if (!im.Containers || im.Containers <= 0) a.reclaimable += im.Size || 0;
return a;
},
{ count: 0, size: 0, shared: 0, reclaimable: 0 }
);
const sumContainers = (df.Containers || []).reduce(
(a, c) => {
a.count++;
a.size += c.SizeRw || 0;
if (c.State && c.State !== 'running') a.reclaimable += c.SizeRw || 0;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
const sumVolumes = (df.Volumes || []).reduce(
(a, v) => {
const sz = (v.UsageData && v.UsageData.Size) || 0;
const refs = (v.UsageData && v.UsageData.RefCount) || 0;
a.count++;
a.size += sz;
if (refs <= 0) a.reclaimable += sz;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
const sumBuild = (df.BuildCache || []).reduce(
(a, b) => {
a.count++;
a.size += b.Size || 0;
if (!b.InUse) a.reclaimable += b.Size || 0;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
// Largest images for "what's eating disk" surface; cap to 10 to
// keep the payload tight.
const topImages = (df.Images || [])
.slice()
.sort((a, b) => (b.Size || 0) - (a.Size || 0))
.slice(0, 10)
.map(im => ({
id: im.Id,
repo_tags: im.RepoTags || [],
size: im.Size || 0,
shared_size: im.SharedSize || 0,
containers: im.Containers || 0,
created: im.Created,
}));
// Largest volumes — reclaimable or otherwise. Useful when /docker
// is hitting 90%+.
const topVolumes = (df.Volumes || [])
.slice()
.sort((a, b) => ((b.UsageData?.Size) || 0) - ((a.UsageData?.Size) || 0))
.slice(0, 10)
.map(v => ({
name: v.Name,
driver: v.Driver,
size: (v.UsageData && v.UsageData.Size) || 0,
ref_count: (v.UsageData && v.UsageData.RefCount) || 0,
}));
const total = sumImages.size + sumContainers.size + sumVolumes.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumVolumes.reclaimable + sumBuild.reclaimable;
res.set('Cache-Control', 'no-store');
res.json({
total, reclaimable,
images: sumImages,
containers: sumContainers,
volumes: sumVolumes,
build_cache: sumBuild,
top_images: topImages,
top_volumes: topVolumes,
updated: new Date().toISOString(),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;