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>
127 lines
5.0 KiB
JavaScript
127 lines
5.0 KiB
JavaScript
// Tiny Docker Engine API client over the bind-mounted unix socket.
|
|
//
|
|
// Extracted from service-routes.js so other routes (per-container stats,
|
|
// system df, etc.) can talk to the daemon without duplicating the socket-
|
|
// discovery + http-over-unix-socket dance.
|
|
//
|
|
// We deliberately do NOT add the `dockerode` package — node's built-in http
|
|
// agent already supports `socketPath`, and the small subset of the Engine
|
|
// API we use fits in a couple of dozen lines. Zero extra deps, easy to audit.
|
|
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
|
|
// Whichever socket the host bind-mounted into the container is the one we
|
|
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
|
|
// /run/user/<uid>/docker.sock under the runtime dir.
|
|
function detectDockerSocket() {
|
|
if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
|
|
try {
|
|
for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const sock = `/run/user/${entry.name}/docker.sock`;
|
|
if (fs.existsSync(sock)) return sock;
|
|
}
|
|
} catch { /* /run/user not readable — fine */ }
|
|
return null;
|
|
}
|
|
|
|
const DOCKER_SOCKET = detectDockerSocket();
|
|
const DOCKER_API_VERSION = 'v1.41'; // Docker 20.10+
|
|
|
|
// Simple JSON GET (or other method without a body). Returns parsed JSON.
|
|
function dockerRequest(method, pathname, query) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
const req = http.request(
|
|
{
|
|
socketPath: DOCKER_SOCKET,
|
|
method,
|
|
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
headers: { Host: 'docker', Accept: 'application/json' },
|
|
},
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
try { resolve(body ? JSON.parse(body) : null); }
|
|
catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
|
|
} else {
|
|
reject(new Error(`Docker API ${res.statusCode}: ${body}`));
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Streaming GET — caller gets the raw IncomingMessage so they can pipe
|
|
// or parse multiplexed log frames themselves.
|
|
function dockerStream(pathname, query) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
const req = http.request(
|
|
{
|
|
socketPath: DOCKER_SOCKET,
|
|
method: 'GET',
|
|
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
headers: { Host: 'docker' },
|
|
},
|
|
(res) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve({ stream: res, req });
|
|
} else {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => reject(new Error(
|
|
`Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
|
|
)));
|
|
}
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Docker's log frames over the API are multiplexed when no TTY is attached.
|
|
// Each frame: 8-byte header [stream(1) 0 0 0 size(4 BE)] + N payload bytes.
|
|
// stream: 0=stdin (unused), 1=stdout, 2=stderr. This decoder concatenates
|
|
// the payload as a single string with no markers (callers don't care about
|
|
// per-stream tagging for our use cases — they just want the text). If a
|
|
// container WAS started with -t, frames are raw text with no header; we
|
|
// detect that by failing to parse a sane header and falling back to a raw
|
|
// utf-8 decode.
|
|
function decodeMultiplexedLog(buf) {
|
|
if (!Buffer.isBuffer(buf) || buf.length === 0) return '';
|
|
const out = [];
|
|
let i = 0;
|
|
let sawValidFrame = false;
|
|
while (i + 8 <= buf.length) {
|
|
const stream = buf[i];
|
|
if (stream > 2) break; // not a header — bail to raw fallback
|
|
const size = buf.readUInt32BE(i + 4);
|
|
const end = i + 8 + size;
|
|
if (end > buf.length) break;
|
|
out.push(buf.slice(i + 8, end).toString('utf8'));
|
|
sawValidFrame = true;
|
|
i = end;
|
|
}
|
|
if (!sawValidFrame) return buf.toString('utf8');
|
|
return out.join('');
|
|
}
|
|
|
|
module.exports = {
|
|
DOCKER_SOCKET,
|
|
DOCKER_API_VERSION,
|
|
dockerRequest,
|
|
dockerStream,
|
|
decodeMultiplexedLog,
|
|
};
|