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>
421 lines
17 KiB
JavaScript
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;
|