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

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,
};