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>
This commit is contained in:
librelad 2026-05-27 21:53:13 +01:00
parent 5915014c2e
commit dbcab8614f
9 changed files with 2030 additions and 313 deletions

View File

@ -0,0 +1,420 @@
// 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;

View File

@ -16,6 +16,7 @@ const taskRoutes = require('./task-routes.js');
const serviceRoutes = require('./service-routes.js');
const setupRoutes = require('./setup-routes.js');
const systemRoutes = require('./system-routes.js');
const dockerInfoRoutes = require('./docker-info-routes.js');
const { testConnection } = require('../utils/mail.js');
module.exports = {
@ -32,6 +33,7 @@ module.exports = {
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc)
app.use('/api/system', requireAuth, dockerInfoRoutes); // /containers/*, /storage
app.post('/api/test-mail-connection', requireAuth, testConnection);
app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => {

View File

@ -0,0 +1,126 @@
// 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,
};

View File

@ -358,87 +358,42 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
.sys-expand:focus-visible { outline: 2px solid rgba(var(--accent-rgb), 0.6); outline-offset: 2px; }
/* =========================================================================
Admin System fullscreen single-metric deep-dive overlay (.sys-detail)
Admin System sub-pages (metric / app / storage)
========================================================================= */
body.sys-detail-active { overflow: hidden; }
.sys-detail {
position: fixed; inset: 0;
z-index: 2000;
display: none;
pointer-events: none;
/* Shared breadcrumb-link style for sub-page back navigation. */
.admin-breadcrumb a {
color: inherit;
text-decoration: none;
transition: color .15s ease;
}
.sys-detail.open { display: block; pointer-events: auto; }
.sys-detail-backdrop {
position: absolute; inset: 0;
background:
radial-gradient(60% 50% at 50% 35%, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%),
rgba(0, 0, 0, 0.62);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
animation: sys-detail-fade .22s ease;
}
@keyframes sys-detail-fade { from { opacity: 0; } to { opacity: 1; } }
.admin-breadcrumb a:hover { color: var(--accent); }
.sys-detail-panel {
/* ---- Metric deep-dive page (.sys-metric-page) ---- */
.sys-metric-page {
--metric-rgb: var(--accent-rgb);
position: absolute;
inset: 24px;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 18px;
padding: 22px 26px 18px;
background:
radial-gradient(80% 60% at 80% 0%, rgba(var(--metric-rgb), 0.10) 0%, transparent 60%),
linear-gradient(160deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.18) 100%),
var(--surface-bg-solid, #0f1729);
border: 1px solid rgba(var(--metric-rgb), 0.32);
border-radius: 18px;
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 -1px 0 rgba(var(--metric-rgb), 0.4) inset;
animation: sys-detail-rise .28s cubic-bezier(.2,.7,.2,1.0);
color: var(--text-primary);
}
@keyframes sys-detail-rise {
from { opacity: 0; transform: translateY(14px) scale(.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.sys-detail-head {
display: flex;
.sys-metric-page .page-header {
align-items: flex-start;
justify-content: space-between;
gap: 16px;
display: flex;
}
.sys-detail-eyebrow {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(var(--text-rgb), 0.45);
margin-bottom: 4px;
}
.sys-detail-name {
font-size: 1.7rem;
font-weight: 700;
margin: 0 0 4px;
.sys-metric-name {
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 110%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: -0.01em;
}
.sys-detail-sub {
margin: 0;
font-size: 0.88rem;
.sys-metric-sub {
color: rgba(var(--text-rgb), 0.6);
}
.sys-detail-actions {
.sys-metric-actions {
display: flex;
align-items: center;
gap: 14px;
margin-top: 6px;
}
.sys-detail-range { display: inline-flex; gap: 4px; padding: 4px; background: rgba(var(--text-rgb), 0.06); border-radius: 999px; }
.sys-detail-range-btn {
@ -457,19 +412,6 @@ body.sys-detail-active { overflow: hidden; }
background: rgba(var(--metric-rgb), 0.95);
box-shadow: 0 2px 12px rgba(var(--metric-rgb), 0.35);
}
.sys-detail-close {
all: unset;
width: 36px; height: 36px;
display: inline-flex;
align-items: center; justify-content: center;
border-radius: 10px;
color: rgba(var(--text-rgb), 0.7);
background: rgba(var(--text-rgb), 0.06);
cursor: pointer;
transition: background .15s ease, color .15s ease;
}
.sys-detail-close:hover { color: var(--text-primary); background: rgba(var(--text-rgb), 0.14); }
.sys-detail-close:focus-visible { outline: 2px solid rgba(var(--metric-rgb), 0.6); outline-offset: 2px; }
.sys-detail-stats {
display: grid;
@ -516,15 +458,17 @@ body.sys-detail-active { overflow: hidden; }
font-variant-numeric: tabular-nums;
}
.sys-detail-canvas {
.sys-detail-canvas,
.sys-metric-canvas {
position: relative;
min-height: 260px;
min-height: 420px;
background:
radial-gradient(120% 80% at 50% 100%, rgba(var(--metric-rgb), 0.08) 0%, transparent 60%),
rgba(0, 0, 0, 0.22);
border: 1px solid rgba(var(--text-rgb), 0.06);
border-radius: 14px;
overflow: hidden;
margin-top: 16px;
}
.sys-detail-svg { width: 100%; height: 100%; display: block; }
.sys-detail-loading,
@ -606,15 +550,538 @@ body.sys-detail-active { overflow: hidden; }
color: rgba(var(--text-rgb), 0.45);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
margin-top: 10px;
}
.sys-detail-foot-hint kbd,
.sys-detail-foot-hint { letter-spacing: 0.02em; }
/* Narrower viewports: collapse the stat grid to 2×2 and shrink padding. */
/* Index per-app table rows are now clickable to open the app deep-dive. */
.sys-app-row { cursor: pointer; }
.sys-app-row:hover td { background: rgba(var(--accent-rgb), 0.06) !important; }
.sys-app-row:focus { outline: none; }
.sys-app-row:focus-visible td { background: rgba(var(--accent-rgb), 0.10) !important; }
.sys-app-arrow {
text-align: right;
color: rgba(var(--text-rgb), 0.35);
font-size: 1.1rem;
width: 1em;
}
.sys-app-row:hover .sys-app-arrow { color: var(--accent); }
/* Clickable stat tile in the Docker strip same chrome as .sys-stat but
feels like a button. */
.sys-stat-link {
all: unset;
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 14px;
background: var(--card-bg);
border: 1px solid rgba(var(--accent-rgb), 0.25);
border-radius: 10px;
cursor: pointer;
transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.sys-stat-link:hover {
border-color: rgba(var(--accent-rgb), 0.55);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.16);
}
.sys-stat-link .sys-stat-value { color: var(--accent); }
@media (max-width: 900px) {
.sys-detail-panel { inset: 10px; padding: 16px 16px 12px; gap: 12px; }
.sys-detail-name { font-size: 1.3rem; }
.sys-metric-page .page-header { flex-direction: column; }
.sys-detail-stats { grid-template-columns: 1fr 1fr; }
.sys-detail-stat-v { font-size: 1.25rem; }
.sys-detail-actions { flex-direction: column; align-items: flex-end; gap: 8px; }
}
/* =========================================================================
Admin System App per-container deep-dive (.sys-app-page)
========================================================================= */
.sys-app-empty {
padding: 36px 20px;
text-align: center;
color: rgba(var(--text-rgb), 0.55);
background: var(--card-bg);
border: 1px dashed rgba(var(--text-rgb), 0.18);
border-radius: 14px;
}
.sys-app-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
margin-top: 18px;
}
.sys-app-card {
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 14px;
padding: 18px 20px 14px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
}
.sys-app-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.sys-app-card-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(var(--text-rgb), 0.55);
font-weight: 700;
}
.sys-app-card-svc {
padding: 1px 8px;
background: rgba(var(--accent-rgb), 0.18);
color: var(--accent);
border-radius: 999px;
font-size: 0.7rem;
}
.sys-app-card-name {
font-size: 1.25rem;
font-weight: 700;
margin: 4px 0 2px;
color: var(--text-primary);
}
.sys-app-card-meta {
font-size: 0.8rem;
color: rgba(var(--text-rgb), 0.55);
}
.sys-app-card-sep { margin: 0 6px; opacity: 0.5; }
.sys-app-card-status-line {
font-size: 0.78rem;
color: rgba(var(--text-rgb), 0.45);
text-align: right;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.sys-app-card-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
padding: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(var(--text-rgb), 0.06);
border-radius: 10px;
margin-bottom: 12px;
}
.sys-app-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.sys-app-stat-k {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.10em;
color: rgba(var(--text-rgb), 0.5);
font-weight: 700;
}
.sys-app-stat-v {
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
transition: color .2s ease;
}
.sys-app-stat-v.warn { color: var(--status-warning); }
.sys-app-stat-v.danger { color: var(--status-danger); }
.sys-app-card-body { display: flex; flex-direction: column; gap: 14px; }
.sys-app-card-loading,
.sys-app-card-err {
padding: 14px;
color: rgba(var(--text-rgb), 0.5);
text-align: center;
font-size: 0.88rem;
}
.sys-app-card-err { color: var(--status-danger); }
.sys-app-card-limits {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.sys-app-limit {
padding: 10px 12px;
background: rgba(var(--text-rgb), 0.03);
border: 1px solid rgba(var(--text-rgb), 0.06);
border-radius: 8px;
}
.sys-app-limit-k {
display: block;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(var(--text-rgb), 0.45);
font-weight: 700;
margin-bottom: 2px;
}
.sys-app-limit-v {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
word-break: break-word;
}
.sys-app-section h3 {
font-size: 0.85rem;
font-weight: 700;
margin: 0 0 8px;
color: rgba(var(--text-rgb), 0.7);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sys-app-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
background: rgba(var(--text-rgb), 0.03);
border-radius: 8px;
overflow: hidden;
}
.sys-app-table th {
text-align: left;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(var(--text-rgb), 0.45);
padding: 8px 10px;
border-bottom: 1px solid rgba(var(--text-rgb), 0.08);
}
.sys-app-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(var(--text-rgb), 0.04);
color: rgba(var(--text-rgb), 0.85);
font-variant-numeric: tabular-nums;
}
.sys-app-table tr:last-child td { border-bottom: none; }
.sys-app-mount-type {
display: inline-block;
padding: 1px 7px;
border-radius: 4px;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sys-app-mount-volume { background: rgba(var(--status-success-rgb), 0.2); color: var(--status-success); }
.sys-app-mount-bind { background: rgba(var(--accent-rgb), 0.18); color: var(--accent); }
.sys-app-mount-tmpfs { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); }
.sys-app-mount-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
word-break: break-all;
}
.sys-app-ports {
list-style: none;
margin: 0; padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sys-app-ports li {
padding: 4px 10px;
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.30);
border-radius: 999px;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.sys-app-health {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.sys-app-health-pill {
padding: 2px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sys-app-health-healthy { background: rgba(var(--status-success-rgb), 0.18); color: var(--status-success); }
.sys-app-health-starting { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); }
.sys-app-health-unhealthy { background: rgba(var(--status-danger-rgb), 0.18); color: var(--status-danger); }
.sys-app-health-unknown { background: rgba(var(--text-rgb), 0.10); color: rgba(var(--text-rgb), 0.6); }
.sys-app-health-fail { color: var(--status-danger); font-size: 0.78rem; font-weight: 600; }
.sys-app-health-log {
background: rgba(var(--text-rgb), 0.03);
border-radius: 6px;
margin-top: 4px;
padding: 4px 8px;
font-size: 0.78rem;
}
.sys-app-health-log summary { cursor: pointer; color: rgba(var(--text-rgb), 0.65); }
.sys-app-health-log pre {
margin: 6px 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
color: rgba(var(--text-rgb), 0.7);
}
.sys-app-card-logs {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(var(--text-rgb), 0.08);
}
.sys-app-card-logs-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sys-app-logs-toggle {
all: unset;
cursor: pointer;
padding: 4px 12px;
font-size: 0.78rem;
font-weight: 600;
color: rgba(var(--text-rgb), 0.7);
background: rgba(var(--text-rgb), 0.06);
border-radius: 999px;
transition: background .15s ease;
}
.sys-app-logs-toggle:hover { background: rgba(var(--accent-rgb), 0.15); color: var(--accent); }
.sys-app-logs-refresh {
all: unset;
cursor: pointer;
width: 24px; height: 24px;
display: inline-flex; align-items: center; justify-content: center;
color: rgba(var(--text-rgb), 0.5);
border-radius: 6px;
}
.sys-app-logs-refresh:hover { color: var(--accent); background: rgba(var(--accent-rgb), 0.12); }
.sys-app-logs-pre {
margin: 0;
padding: 12px;
background: rgba(0, 0, 0, 0.42);
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
line-height: 1.45;
color: rgba(var(--text-rgb), 0.85);
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.sys-app-logs-loading,
.sys-app-logs-err {
padding: 12px;
color: rgba(var(--text-rgb), 0.5);
font-size: 0.85rem;
}
.sys-app-logs-err { color: var(--status-danger); }
@media (min-width: 1100px) {
.sys-app-grid { grid-template-columns: 1fr; }
}
/* =========================================================================
Admin System Storage (.sys-storage-page)
========================================================================= */
.sys-storage-loading,
.sys-storage-err {
padding: 36px;
text-align: center;
color: rgba(var(--text-rgb), 0.55);
font-size: 0.92rem;
}
.sys-storage-err { color: var(--status-danger); }
.sys-storage-headline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px;
margin-top: 14px;
}
@media (max-width: 800px) {
.sys-storage-headline { grid-template-columns: 1fr; }
}
.sys-storage-head-card {
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 14px;
padding: 18px;
display: flex;
align-items: center;
gap: 18px;
}
.sys-storage-donut {
width: 200px;
height: 200px;
flex-shrink: 0;
}
.sys-storage-donut-total {
fill: var(--text-primary);
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.sys-storage-donut-sub {
fill: rgba(var(--text-rgb), 0.5);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.sys-storage-legend {
list-style: none;
margin: 0; padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.sys-storage-legend li {
display: grid;
grid-template-columns: 14px auto 1fr auto;
align-items: center;
gap: 10px;
font-size: 0.85rem;
}
.sys-storage-swatch {
width: 12px; height: 12px;
border-radius: 3px;
}
.sys-storage-leg-k {
color: var(--text-primary);
font-weight: 600;
}
.sys-storage-leg-v {
color: rgba(var(--text-rgb), 0.65);
font-variant-numeric: tabular-nums;
text-align: right;
}
.sys-storage-leg-r {
grid-column: 2 / -1;
font-size: 0.72rem;
color: rgba(var(--text-rgb), 0.45);
margin-top: -4px;
}
.sys-storage-head-stats {
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
}
.sys-storage-stat {
padding: 18px 20px;
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sys-storage-stat-recl {
border-color: rgba(var(--status-warning-rgb), 0.32);
background: linear-gradient(135deg, rgba(var(--status-warning-rgb), 0.14) 0%, rgba(var(--status-warning-rgb), 0.04) 100%);
}
.sys-storage-stat-k {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.10em;
color: rgba(var(--text-rgb), 0.5);
font-weight: 700;
}
.sys-storage-stat-v {
font-size: 1.8rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.05;
}
.sys-storage-stat-recl .sys-storage-stat-v { color: var(--status-warning); }
.sys-storage-stat-sub {
font-size: 0.78rem;
color: rgba(var(--text-rgb), 0.55);
}
.sys-storage-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.sys-storage-card {
--cat: var(--accent);
--cat-rgb: var(--accent-rgb);
padding: 16px;
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-left: 3px solid var(--cat);
border-radius: 12px;
}
.sys-storage-card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.sys-storage-card-head h3 {
font-size: 0.85rem;
margin: 0;
color: var(--text-primary);
}
.sys-storage-card-count {
font-size: 0.72rem;
color: rgba(var(--text-rgb), 0.5);
font-variant-numeric: tabular-nums;
}
.sys-storage-card-size {
font-size: 1.45rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
margin-bottom: 8px;
}
.sys-storage-card-bar {
height: 6px;
border-radius: 3px;
background: rgba(var(--text-rgb), 0.08);
overflow: hidden;
}
.sys-storage-card-bar > span {
display: block;
height: 100%;
background: var(--cat);
transition: width .4s ease;
}
.sys-storage-card-meta {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.74rem;
color: rgba(var(--text-rgb), 0.5);
}
.sys-storage-card-recl { color: var(--status-warning); font-weight: 600; }
.sys-storage-orphan {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
background: rgba(var(--status-warning-rgb), 0.18);
color: var(--status-warning);
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}

View File

@ -1,15 +1,24 @@
// Admin → System — in-depth host + per-app statistics. Live ring gauges for the
// headline numbers, SVG trend charts driven by the metrics history ring buffer,
// a Docker summary, and a per-app resource table.
// Admin → System — orchestrator + index view.
//
// Two data paths:
// - Live (1 Hz, via LiveSystem SSE): CPU%, memory, load, disks, network,
// docker totals. Updates the gauges in place each second so they tick like
// a real instrument.
// - Periodic (every 30 s, via fetch): the history ring buffer + per-app
// table. These regenerate at most once a minute on the host, so polling
// them faster would be wasted bandwidth.
// Renders into #config-section.
// One AdminSystem instance per page mount. Reads the URL path on init and
// dispatches to one of four sub-views:
//
// /admin/config/system → index (gauges + trends + per-app table)
// /admin/config/system/metric/<key> → single-metric deep-dive page
// /admin/config/system/app/<name> → per-container app deep-dive
// /admin/config/system/storage → Docker disk breakdown
//
// Sub-views are separate page renderers (system-metric-page.js etc.) that
// each own their own DOM + lifecycle inside #config-section. We mount one
// at a time; switching means tearing down the active renderer and starting
// a fresh one. The SPA re-runs handleAdmin() on every navigation, which
// re-runs ConfigManager.renderConfig('system') → AdminSystem.init, so the
// dispatch happens organically with the router.
//
// Live (1 Hz, via LiveSystem SSE) data flows directly to whichever sub-view
// is mounted. The index view ticks gauges in place; the metric page splices
// the live value into its chart's tail.
class AdminSystem {
constructor(rootId = 'config-section') {
this.rootId = rootId;
@ -17,23 +26,62 @@ class AdminSystem {
this._timer = null;
this._unsubLive = null;
this.d = {};
// Active sub-view renderer. Disposed on each init().
this._subview = null;
}
root() { return document.getElementById(this.rootId); }
// Path → view dispatch. AdminPath base is /admin/config/system; sub-paths
// add segments after that. Falls through to 'index' for an unknown shape
// so a typo'd URL doesn't blank the page.
_parsePath() {
const segs = String(window.location.pathname || '').split('/').filter(Boolean);
// segs = ['admin','config','system', ...]
const sub = segs[3];
if (sub === 'metric' && segs[4]) return { view: 'metric', key: decodeURIComponent(segs[4]) };
if (sub === 'app' && segs[4]) return { view: 'app', name: decodeURIComponent(segs[4]) };
if (sub === 'storage') return { view: 'storage' };
return { view: 'index' };
}
async init() {
// Tear down any previous sub-view first (re-mount across nav).
this._stopLive();
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._subview && typeof this._subview.dispose === 'function') {
try { this._subview.dispose(); } catch (_) {}
}
this._subview = null;
const parsed = this._parsePath();
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
if (!r) return;
// Sub-pages live in their own classes; index lives here.
if (parsed.view === 'metric' && window.SystemMetricPage) {
this._subview = new window.SystemMetricPage(this.rootId);
await this._subview.mount(parsed.key);
return;
}
if (parsed.view === 'app' && window.SystemAppPage) {
this._subview = new window.SystemAppPage(this.rootId);
await this._subview.mount(parsed.name);
return;
}
if (parsed.view === 'storage' && window.SystemStoragePage) {
this._subview = new window.SystemStoragePage(this.rootId);
await this._subview.mount();
return;
}
// Default: index view.
r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
await this.refresh();
this.bind();
this._stopLive();
// 1 Hz live gauges via the shared EventSource manager.
if (window.LiveSystem) {
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
// History/per-app refresh stays slower — those files only regenerate
// once a minute on the host. Stop both paths once the user navigates off.
if (this._timer) clearInterval(this._timer);
this._timer = setInterval(() => {
if (!document.querySelector('.sys-page')) {
clearInterval(this._timer); this._timer = null;
@ -49,9 +97,6 @@ class AdminSystem {
}
async refresh() {
// History now comes from /api/system/history (binary ring backed,
// supports up to 7 days). Everything else stays on the static JSONs
// the host generator writes.
const [metrics, history, apps, appsHist, info] = await Promise.all([
this.fetchJson('/data/system/metrics.json'),
this.fetchJson(`/api/system/history?range=${this.range}`),
@ -76,46 +121,33 @@ class AdminSystem {
const rb = e.target.closest('[data-sys-range]');
if (rb) {
this.range = parseInt(rb.dataset.sysRange) || 60;
// 7d needs a server hit because we don't keep that locally.
this.refresh();
return;
}
// Expand any metric surface — gauges, chart cards, table rows.
// Expand a metric → navigate to its detail page. The SPA picks up
// the new URL, ConfigManager re-renders, AdminSystem.init mounts
// the metric page.
const ex = e.target.closest('[data-sys-expand]');
if (ex && window.systemDetail) {
if (ex) {
const k = ex.dataset.sysExpand;
const def = this._metricDefs()[k];
if (def) window.systemDetail.open(def);
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/metric/${encodeURIComponent(k)}`);
return;
}
const ap = e.target.closest('[data-sys-app]');
if (ap) {
const name = ap.dataset.sysApp;
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/app/${encodeURIComponent(name)}`);
return;
}
const st = e.target.closest('[data-sys-storage]');
if (st) {
if (window.navigateToRoute) window.navigateToRoute('/admin/config/system/storage');
return;
}
});
}
// Metric definitions consumed by the fullscreen detail overlay. Each maps
// the history-ring key to its labels, units, formatter, and accent color.
_metricDefs() {
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const root = disks.find(d => d.mount === '/') || disks[0] || {};
const pctFmt = (v) => `${v.toFixed(1)}%`;
const rateFmt = (v) => this.rate(v);
const loadFmt = (v) => v.toFixed(2);
// Pull the literal RGB triplet for each metric so the overlay can
// theme its gradient + accents per metric. Falls back to --accent-rgb.
const rgbVar = (name) =>
(getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
return {
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${cpu.cores || '?'} cores`, accentRgb: rgbVar('accent') },
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}`, accentRgb: rgbVar('status-info') },
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pctFmt, sublabel: mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'no swap', accentRgb: rgbVar('status-warning') },
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pctFmt, sublabel: root.mount || '/', accentRgb: rgbVar('status-warning') },
load1: { key: 'load1', label: 'Load average', fmt: loadFmt, sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''} (${cpu.cores || '?'} cores)`, accentRgb: rgbVar('accent') },
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('status-success') },
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('accent') },
};
}
/* ---- formatting helpers ---- */
/* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
bytes(n) {
n = Number(n) || 0;
@ -125,7 +157,6 @@ class AdminSystem {
}
rate(n) { return `${this.bytes(n)}/s`; }
// Last N minutes of a given key from the history ring buffer.
series(key) {
const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : [];
return pts.slice(-this.range).map(p => Number(p[key]) || 0);
@ -149,11 +180,6 @@ class AdminSystem {
</div>`;
}
// Shared by full render() and the 1 Hz live path so both produce identical
// gauge markup; only `this.d.metrics` differs in source.
// Each gauge is wrapped in a button surface (data-sys-expand=<key>) so a
// click anywhere on the gauge opens the fullscreen detail overlay for
// that metric.
_gaugesHtml() {
const C = window.LPCharts;
const m = this.d.metrics || {};
@ -174,10 +200,6 @@ class AdminSystem {
${wrap('load1', C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''}` }))}`;
}
// Fold a live SSE sample into this.d.metrics and refresh the in-page
// gauges + "updated" stamp without rebuilding the heavier sections.
// The payload shape matches the host generator's metrics.json so we can
// assign straight in; absent fields keep their previous value.
_applyLive(s) {
if (!s || !document.querySelector('.sys-page')) return;
const m = this.d.metrics || {};
@ -201,14 +223,13 @@ class AdminSystem {
if (!root) return;
const C = window.LPCharts;
const m = this.d.metrics || {};
const cpu = m.cpu || {}, mem = m.memory || {}, net = m.network || {}, dk = m.docker || {};
const cpu = m.cpu || {}, mem = m.memory || {}, dk = m.docker || {};
const info = this.d.info || {};
const disks = Array.isArray(m.disks) ? m.disks : [];
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
// Trend charts
const rx = this.series('net_rx'), tx = this.series('net_tx');
const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
const hasSwap = (mem.swap_total || 0) > 0;
@ -229,7 +250,6 @@ class AdminSystem {
${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
</div>`;
// Host info + swap + docker summary
const infoStrip = `
<div class="sys-strip">
${this.stat('OS', info.os || '—')}
@ -239,36 +259,41 @@ class AdminSystem {
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
</div>`;
// Docker strip — now a navigational tile too. "Storage" leads to the
// dedicated breakdown page; the rest are display-only.
const dockerStrip = `
<div class="sys-section-head"><h2>Docker</h2></div>
<div class="sys-strip">
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
${this.stat('Images', String(dk.images ?? 0))}
${this.stat('Volumes', String(dk.volumes ?? 0))}
${this.stat('Mounts', String(disks.length))}
<button type="button" class="sys-stat sys-stat-link" data-sys-storage aria-label="Open storage breakdown">
<span class="sys-stat-label">Storage</span>
<strong class="sys-stat-value">Open breakdown </strong>
</button>
</div>`;
// Per-app table
const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : [];
const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {};
const appsBody = apps.length ? apps.map(a => {
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
const statusCls = a.status === 'running' ? 'ok' : 'none';
return `<tr>
return `<tr class="sys-app-row" data-sys-app="${this.escape(a.app)}" tabindex="0" role="button" aria-label="Open ${this.escape(a.app)} details">
<td class="sys-app-name"><span class="admin-status-dot ${statusCls}"></span>${this.escape(a.app)}
<span class="sys-app-sub">${a.running}/${a.containers} up</span></td>
<td>${C.bar(a.cpu_percent)}<span class="sys-cell-val">${(a.cpu_percent || 0).toFixed(1)}%</span></td>
<td>${C.bar(a.mem_percent)}<span class="sys-cell-val">${this.bytes(a.mem_bytes)}</span></td>
<td class="sys-net-cell">${this.bytes(a.net_rx)} ${this.bytes(a.net_tx)}</td>
<td class="sys-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
<td class="sys-app-arrow"></td>
</tr>`;
}).join('') : `<tr><td colspan="5" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
}).join('') : `<tr><td colspan="6" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
const appsTable = `
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">sorted by CPU</span></div>
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">click a row to open the deep-dive · sorted by CPU</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th></tr></thead>
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th><th></th></tr></thead>
<tbody>${appsBody}</tbody>
</table>
</div>`;
@ -297,4 +322,34 @@ class AdminSystem {
}
}
// Lightweight formatter helpers shared with sub-pages so they don't each
// reimplement bytes()/rate(). Attached as a global so a sub-page that mounts
// before AdminSystem still has them.
window.SystemFmt = window.SystemFmt || {
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); },
bytes(n) {
n = Number(n) || 0;
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
},
rate(n) { return `${this.bytes(n)}/s`; },
timeAgo(unixSec) {
if (!unixSec) return '';
const diff = Math.floor(Date.now() / 1000) - unixSec;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
},
timeAgoIso(iso) {
if (!iso) return '';
const t = Math.floor(new Date(iso).getTime() / 1000);
return Number.isFinite(t) && t > 0 ? this.timeAgo(t) : '';
},
rgbVar(name) {
return (getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
},
};
window.AdminSystem = AdminSystem;

View File

@ -0,0 +1,382 @@
// 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;

View File

@ -1,83 +1,157 @@
// Admin → System — fullscreen single-metric deep-dive overlay.
// Admin → System → Metric — single-metric deep-dive PAGE.
//
// Opens when the user hits "Expand" on a gauge, chart card, or any metric
// surface on the admin System page. Renders a large interactive chart with
// axes, gridlines, hover crosshair + tooltip, and a stat strip (now / peak /
// min / avg / Δ) — all driven from /api/system/history + the live SSE feed.
// Range picker (1h / 6h / 24h / 7d) re-fetches and animates between datasets.
// Replaces the previous fullscreen overlay (system-detail.js) with a real
// in-flow page mounted at /admin/config/system/metric/<key>. Bookmarkable,
// browser-back navigates, refresh keeps you here.
//
// Zero dependencies — everything is hand-rolled SVG and pointer events. The
// overlay is a singleton instance attached to <body>; opening with a fresh
// metric reuses the same DOM.
// Renders into #config-section as a full-width admin page. Owns its own
// DOM, range picker, live SSE subscription, and hover/scrub interactions.
//
// Mount lifecycle:
// const p = new SystemMetricPage('config-section');
// await p.mount('cpu');
// ...
// p.dispose(); // tears down SSE sub + global listeners
class SystemDetail {
constructor() {
this.el = null;
class SystemMetricPage {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.metricKey = null;
this.metric = null;
this.rangeMin = 360; // default to 6 h
this.rangeMin = 360; // default 6h
this.points = [];
this.unsubLive = null;
this.tier = '1m';
this.hoverIdx = -1;
this._rafHover = null;
this._unsubLive = null;
this._onKey = this._onKey.bind(this);
this._onResize = this._onResize.bind(this);
this._onClick = this._onClick.bind(this);
this._onPointerMove = this._onPointerMove.bind(this);
this._onPointerLeave = this._onPointerLeave.bind(this);
}
// Public: open the overlay for a given metric definition.
// m = { key, label, unit, color, max, fmt, sublabel?, accentRgb? }
open(m) {
this.metric = m;
if (!this.el) this._mount();
this.el.classList.add('open');
this.el.setAttribute('aria-hidden', 'false');
document.body.classList.add('sys-detail-active');
document.addEventListener('keydown', this._onKey);
window.addEventListener('resize', this._onResize);
root() { return document.getElementById(this.rootId); }
async mount(key) {
this.metricKey = key;
this.metric = this._metricDef(key);
if (!this.metric) {
this._renderUnknown();
return;
}
// Optional ?range= override on the URL.
const params = new URLSearchParams(window.location.search);
const r = parseInt(params.get('range'), 10);
if (Number.isFinite(r) && r > 0) this.rangeMin = Math.min(10080, Math.max(1, r));
this._renderShell();
this._loadRange();
this._bind();
await this._loadRange();
this._attachLive();
}
close() {
if (!this.el) return;
this.el.classList.remove('open');
this.el.setAttribute('aria-hidden', 'true');
document.body.classList.remove('sys-detail-active');
dispose() {
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
document.removeEventListener('keydown', this._onKey);
window.removeEventListener('resize', this._onResize);
this._detachLive();
this.points = [];
this.metric = null;
// Click handler is delegated on root and dies with the DOM swap.
if (this._rafHover) cancelAnimationFrame(this._rafHover);
}
_onKey(e) { if (e.key === 'Escape') this.close(); }
_onKey(e) {
if (e.key === 'Escape' && window.navigateToRoute) {
window.navigateToRoute('/admin/config/system');
}
}
_onResize() { this._renderChart(); }
_mount() {
const el = document.createElement('div');
el.className = 'sys-detail';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-hidden', 'true');
el.innerHTML = `
<div class="sys-detail-backdrop" data-sys-detail-close></div>
<div class="sys-detail-panel" role="document">
<header class="sys-detail-head">
<div class="sys-detail-title">
<div class="sys-detail-eyebrow">Admin · System · Live</div>
<h2 class="sys-detail-name"></h2>
<p class="sys-detail-sub"></p>
_bind() {
document.addEventListener('keydown', this._onKey);
window.addEventListener('resize', this._onResize);
const r = this.root();
if (r) r.addEventListener('click', this._onClick);
}
_onClick(e) {
const rb = e.target.closest('[data-rng]');
if (rb) {
const r = parseInt(rb.dataset.rng, 10) || 360;
this.rangeMin = r;
// Reflect in URL so refresh / share preserves the choice.
const url = new URL(window.location.href);
url.searchParams.set('range', String(r));
window.history.replaceState({}, '', url.toString());
this._loadRange();
return;
}
const back = e.target.closest('[data-back]');
if (back && window.navigateToRoute) {
window.navigateToRoute('/admin/config/system');
}
}
// Metric registry — keys map to label, unit, formatter, accent. Mirrors
// the same list the index view exposes; lives here so the metric page
// can mount cleanly without AdminSystem present (direct URL load).
_metricDef(key) {
const fmt = window.SystemFmt || { bytes: (n) => `${n}`, rate: (n) => `${n}/s`, rgbVar: () => '0, 212, 255' };
const pct = (v) => `${(v ?? 0).toFixed(1)}%`;
const rateFmt = (v) => fmt.rate(v);
const loadFmt = (v) => (v ?? 0).toFixed(2);
const all = {
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pct, accent: 'accent' },
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pct, accent: 'status-info' },
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
load1: { key: 'load1', label: 'Load average (1m)', fmt: loadFmt, accent: 'accent' },
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, accent: 'status-success' },
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, accent: 'accent' },
};
const m = all[key];
if (!m) return null;
m.accentRgb = fmt.rgbVar(m.accent);
return m;
}
_renderUnknown() {
const root = this.root();
if (!root) return;
root.innerHTML = `
<div class="admin-page sys-metric-page">
<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>Unknown metric</h1>
<p>No metric registered under "${(window.SystemFmt?.escape || String)(this.metricKey)}".</p>
</div>
<div class="sys-detail-actions">
</div>
</div>`;
}
_renderShell() {
const root = this.root();
if (!root) return;
const fmt = window.SystemFmt;
root.innerHTML = `
<div class="admin-page sys-metric-page" style="--metric-rgb:${this.metric.accentRgb}">
<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 class="sys-metric-name">${fmt.escape(this.metric.label)}</h1>
<p class="sys-metric-sub">${fmt.escape(this._subline())}</p>
</div>
<div class="sys-metric-actions">
<div class="sys-detail-range" role="tablist" aria-label="Time range">
${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']]
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn" data-rng="${m}">${l}</button>`).join('')}
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn ${this.rangeMin===m?'active':''}" data-rng="${m}">${l}</button>`).join('')}
</div>
<button type="button" class="sys-detail-close" data-sys-detail-close aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</header>
</div>
<section class="sys-detail-stats">
${['now','peak','avg','min'].map(k =>
`<div class="sys-detail-stat" data-stat="${k}">
@ -86,7 +160,7 @@ class SystemDetail {
<span class="sys-detail-stat-t"></span>
</div>`).join('')}
</section>
<section class="sys-detail-canvas">
<section class="sys-metric-canvas">
<div class="sys-detail-empty" hidden>No samples in this range yet check back in a minute.</div>
<div class="sys-detail-loading">Loading history</div>
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
@ -94,97 +168,67 @@ class SystemDetail {
</section>
<footer class="sys-detail-foot">
<span class="sys-detail-foot-meta"></span>
<span class="sys-detail-foot-hint">Esc to close · hover the chart to scrub · range tier auto-selects</span>
<span class="sys-detail-foot-hint">Esc returns to System · hover the chart to scrub · range tier auto-selects</span>
</footer>
</div>`;
document.body.appendChild(el);
this.el = el;
// Wire events once.
el.addEventListener('click', (e) => {
if (e.target.closest('[data-sys-detail-close]')) { this.close(); return; }
const rb = e.target.closest('[data-rng]');
if (rb) {
this.rangeMin = parseInt(rb.dataset.rng, 10) || 360;
this._loadRange();
}
});
const canvas = el.querySelector('.sys-detail-canvas');
canvas.addEventListener('pointermove', (e) => this._onHover(e));
canvas.addEventListener('pointerleave', () => this._setHover(-1));
const canvas = root.querySelector('.sys-metric-canvas');
if (canvas) {
canvas.addEventListener('pointermove', this._onPointerMove);
canvas.addEventListener('pointerleave', this._onPointerLeave);
}
}
_renderShell() {
const m = this.metric;
this.el.querySelector('.sys-detail-name').textContent = m.label;
this.el.querySelector('.sys-detail-sub').textContent = m.sublabel || '';
const panel = this.el.querySelector('.sys-detail-panel');
// Re-key accent so the panel borders + stat strip pick up the metric color.
const rgb = m.accentRgb || 'var(--accent-rgb)';
panel.style.setProperty('--metric-rgb', rgb);
// Range buttons reflect current selection.
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
}
_subline() {
// Stub — populated more fully once data lands (sample count + tier).
return 'Live · binary ring backed';
}
async _loadRange() {
const m = this.metric;
if (!m) return;
const loadingEl = this.el.querySelector('.sys-detail-loading');
const emptyEl = this.el.querySelector('.sys-detail-empty');
emptyEl.hidden = true;
loadingEl.hidden = false;
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
const root = this.root();
if (!root) return;
const loading = root.querySelector('.sys-detail-loading');
const empty = root.querySelector('.sys-detail-empty');
if (empty) empty.hidden = true;
if (loading) loading.hidden = false;
// Range button active states.
for (const b of root.querySelectorAll('.sys-detail-range-btn')) {
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
}
try {
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(m.key)}`, { credentials: 'same-origin' });
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(this.metric.key)}`, { credentials: 'same-origin' });
const j = await r.json().catch(() => ({}));
this.points = Array.isArray(j?.points) ? j.points : [];
this._tier = j?.tier || '1m';
this.tier = j?.tier || (this.rangeMin > 1440 ? '5m' : '1m');
} catch (_) {
this.points = [];
}
loadingEl.hidden = true;
if (this.points.length === 0) emptyEl.hidden = false;
if (loading) loading.hidden = true;
if (this.points.length === 0 && empty) empty.hidden = false;
this._renderChart();
this._renderStats();
this._renderFootMeta();
}
_attachLive() {
this._detachLive();
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
if (!window.LiveSystem) return;
this.unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
_detachLive() {
if (this.unsubLive) { try { this.unsubLive(); } catch (_) {} this.unsubLive = null; }
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
}
// Splice the live sample's current value into the dataset's tail and
// refresh the now-stat + the trailing edge of the chart only (cheaper
// than a full redraw at 1 Hz). The new point joins the displayed series
// but is not persisted to this.points so the next range load starts
// clean.
_applyLive(s) {
if (!s || !this.metric) return;
const v = this._extractLive(s);
if (v == null) return;
this._liveValue = v;
this._liveAt = s.t || Date.now();
// Append-or-replace: if the latest point in `this.points` is already
// tagged as live, replace; otherwise append. Either way we keep the
// series length stable so the geometry doesn't jiggle each second.
const t = Math.floor((s.t || Date.now()) / 1000);
if (this.points.length && this.points[this.points.length - 1].__live) {
this.points[this.points.length - 1] = { t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true };
this.points[this.points.length - 1] = { t, [this.metric.key]: v, __live: true };
} else {
this.points.push({ t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true });
this.points.push({ t, [this.metric.key]: v, __live: true });
}
this._renderChart();
this._renderStats(/*liveOnly=*/true);
this._renderStats(true);
}
// Pluck the metric's current value out of the SSE payload.
_extractLive(s) {
const k = this.metric.key;
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
@ -202,22 +246,23 @@ class SystemDetail {
}
_renderStats(liveOnly = false) {
const root = this.root();
if (!root) return;
const m = this.metric;
const key = m.key;
const vals = this.points.map(p => Number(p[key]) || 0);
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
const vals = this.points.map(p => Number(p[m.key]) || 0);
const fmt = m.fmt;
const stats = this._computeStats(vals);
const now = vals[vals.length - 1] ?? 0;
const nowAt = this.points.length ? this.points[this.points.length - 1].t : null;
const peakAt = stats.peakIdx >= 0 ? this.points[stats.peakIdx]?.t : null;
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
const set = (k, v, t) => {
const card = this.el.querySelector(`[data-stat="${k}"]`);
const card = root.querySelector(`[data-stat="${k}"]`);
if (!card) return;
card.querySelector('.sys-detail-stat-v').textContent = vals.length ? fmt(v) : '—';
card.querySelector('.sys-detail-stat-t').textContent = t ? this._timeAgo(t) : '';
card.querySelector('.sys-detail-stat-t').textContent = t ? window.SystemFmt.timeAgo(t) : '';
};
set('now', now, nowAt);
set('now', now, nowAt);
if (!liveOnly) {
set('peak', stats.peak, peakAt);
set('avg', stats.avg, null);
@ -226,13 +271,15 @@ class SystemDetail {
}
_renderFootMeta() {
const meta = this.el.querySelector('.sys-detail-foot-meta');
const tier = this._tier || (this.rangeMin > 1440 ? '5m' : '1m');
const root = this.root();
if (!root) return;
const meta = root.querySelector('.sys-detail-foot-meta');
if (!meta) return;
const n = this.points.length;
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
meta.textContent = n
? `${n} samples · tier ${tier} · spans ${this._formatDuration(span)}`
: `tier ${tier} · empty range`;
? `${n} samples · tier ${this.tier} · spans ${this._formatDuration(span)}`
: `tier ${this.tier} · empty range`;
}
_computeStats(vals) {
@ -250,8 +297,11 @@ class SystemDetail {
_renderChart() {
const m = this.metric;
if (!m) return;
const svg = this.el.querySelector('.sys-detail-svg');
const canvas = this.el.querySelector('.sys-detail-canvas');
const root = this.root();
if (!root) return;
const svg = root.querySelector('.sys-detail-svg');
const canvas = root.querySelector('.sys-metric-canvas');
if (!svg || !canvas) return;
const W = canvas.clientWidth || 1200;
const H = canvas.clientHeight || 480;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
@ -263,7 +313,6 @@ class SystemDetail {
const innerH = Math.max(1, H - padT - padB);
const vals = this.points.map(p => Number(p[m.key]) || 0);
const stats = this._computeStats(vals);
// Y-range: pin to opts.max when given (e.g. percentages); else 0..peak*1.15.
const yMin = (m.min !== undefined) ? m.min : 0;
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
const span = yMax - yMin || 1;
@ -272,14 +321,12 @@ class SystemDetail {
const xAt = (i) => padL + i * stepX;
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
// Line + area path.
let line = '';
for (let i = 0; i < n; i++) line += `${i ? 'L' : 'M'}${xAt(i).toFixed(1)},${yAt(vals[i]).toFixed(1)} `;
const area = `${line} L${xAt(n - 1).toFixed(1)},${padT + innerH} L${padL.toFixed(1)},${padT + innerH} Z`;
// Gridlines + Y labels (5 horizontal ticks, including top + bottom).
const yTicks = 5;
const fmtY = m.fmt || ((v) => `${Math.round(v)}${m.unit || ''}`);
const fmtY = m.fmt;
let grid = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = yMax - (i * span / yTicks);
@ -287,7 +334,6 @@ class SystemDetail {
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + innerW}" y2="${y.toFixed(1)}" class="sys-detail-grid"/>`;
yLabels += `<text x="${(padL - 10).toFixed(0)}" y="${(y + 4).toFixed(0)}" class="sys-detail-axis sys-detail-axis-y">${fmtY(v)}</text>`;
}
// X labels (~6 ticks across).
const xTicks = Math.min(6, n);
let xLabels = '';
for (let i = 0; i < xTicks; i++) {
@ -296,18 +342,14 @@ class SystemDetail {
const t = this.points[idx]?.t;
xLabels += `<text x="${x.toFixed(0)}" y="${(padT + innerH + 22).toFixed(0)}" class="sys-detail-axis sys-detail-axis-x">${this._fmtTime(t)}</text>`;
}
// Peak + min markers.
const peakDot = (stats.peakIdx >= 0)
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
const minDot = (stats.minIdx >= 0)
? `<circle cx="${xAt(stats.minIdx).toFixed(1)}" cy="${yAt(stats.min).toFixed(1)}" r="3" class="sys-detail-min"/>` : '';
// Now dot — pulses via CSS.
const nowIdx = n - 1;
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
// Hover crosshair (positioned in _setHover).
const crosshair = `<line class="sys-detail-cross" x1="0" y1="${padT}" x2="0" y2="${(padT + innerH).toFixed(1)}" visibility="hidden"/>
<circle class="sys-detail-cross-dot" cx="0" cy="0" r="5" visibility="hidden"/>`;
// Gradient id is stable so the fill keeps animating during live updates.
const gradId = 'sys-detail-grad';
svg.innerHTML = `
<defs>
@ -334,13 +376,12 @@ class SystemDetail {
${yLabels}
${xLabels}
`;
// Cache geometry for hover.
this._geo = { padL, padR, padT, padB, innerW, innerH, n, xAt, yAt, vals };
this._geo = { padL, padT, innerW, innerH, n, xAt, yAt, vals };
}
_onHover(e) {
_onPointerMove(e) {
if (!this._geo) return;
const canvas = this.el.querySelector('.sys-detail-canvas');
const canvas = e.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const { padL, innerW, n } = this._geo;
@ -351,11 +392,15 @@ class SystemDetail {
if (this._rafHover) cancelAnimationFrame(this._rafHover);
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
}
_onPointerLeave() { this._setHover(-1); }
_setHover(idx, yCursor) {
this.hoverIdx = idx;
const svg = this.el.querySelector('.sys-detail-svg');
const tooltip = this.el.querySelector('.sys-detail-tooltip');
const root = this.root();
if (!root) return;
const svg = root.querySelector('.sys-detail-svg');
const tooltip = root.querySelector('.sys-detail-tooltip');
if (!svg || !tooltip) return;
const cross = svg.querySelector('.sys-detail-cross');
const dot = svg.querySelector('.sys-detail-cross-dot');
if (!cross || !dot) return;
@ -373,19 +418,17 @@ class SystemDetail {
cross.setAttribute('visibility', 'visible');
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
dot.setAttribute('visibility', 'visible');
// Tooltip in canvas-pixel space (SVG fills the canvas at native size).
const m = this.metric;
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
const fmt = m.fmt;
const t = this.points[idx]?.t;
tooltip.innerHTML = `
<div class="sys-detail-tip-v">${fmt(v)}</div>
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
`;
tooltip.hidden = false;
const canvas = this.el.querySelector('.sys-detail-canvas');
const canvas = root.querySelector('.sys-metric-canvas');
const cw = canvas.clientWidth, ch = canvas.clientHeight;
const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40;
// Place to the right of the cursor unless near the right edge.
let tx = x + 14, ty = (yCursor !== undefined ? yCursor : y) - th - 8;
if (tx + tw > cw - 8) tx = x - tw - 14;
if (ty < 8) ty = 8;
@ -398,7 +441,6 @@ class SystemDetail {
const d = new Date(unixSec * 1000);
const sameDay = (new Date()).toDateString() === d.toDateString();
if (this.rangeMin > 1440) {
// 7-day view: dd/MM HH:mm
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
return `${dd}/${mm}`;
@ -410,17 +452,7 @@ class SystemDetail {
_fmtTimeFull(unixSec) {
if (!unixSec) return '';
const d = new Date(unixSec * 1000);
return d.toLocaleString();
}
_timeAgo(unixSec) {
if (!unixSec) return '';
const diff = Math.floor(Date.now() / 1000) - unixSec;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
return new Date(unixSec * 1000).toLocaleString();
}
_formatDuration(sec) {
@ -431,5 +463,4 @@ class SystemDetail {
}
}
window.SystemDetail = SystemDetail;
window.systemDetail = window.systemDetail || new SystemDetail();
window.SystemMetricPage = SystemMetricPage;

View File

@ -0,0 +1,233 @@
// Admin → System → Storage — Docker disk-usage breakdown.
//
// Mounted at /admin/config/system/storage. Visualises `docker system df`:
//
// - Headline total + reclaimable
// - Donut chart split by category (images / containers / volumes / build cache)
// - Per-category cards (count + size + reclaimable)
// - Top 10 images by size
// - Top 10 volumes by size
//
// One backend call (GET /api/system/storage), cached server-side for 5s.
// No actions yet — purely informational. Prune controls deferred to a v2.
class SystemStoragePage {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.data = null;
this._timer = null;
this._onClick = this._onClick.bind(this);
}
root() { return document.getElementById(this.rootId); }
async mount() {
this._renderShell();
await this._load();
this._render();
const r = this.root();
if (r) r.addEventListener('click', this._onClick);
// Refresh every 30s while mounted.
this._timer = setInterval(() => {
if (!document.querySelector('.sys-storage-page')) {
clearInterval(this._timer); this._timer = null;
return;
}
this._load().then(() => this._render());
}, 30000);
}
dispose() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
const r = this.root();
if (r) r.removeEventListener('click', this._onClick);
}
_onClick(e) {
const back = e.target.closest('[data-back]');
if (back && window.navigateToRoute) {
window.navigateToRoute('/admin/config/system');
}
}
async _load() {
try {
const r = await fetch('/api/system/storage');
const j = await r.json().catch(() => null);
this.data = j || null;
} catch (_) {
this.data = null;
}
}
_renderShell() {
const r = this.root();
if (!r) return;
r.innerHTML = `
<div class="admin-page sys-storage-page">
<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>Storage</h1>
<p class="sys-storage-sub">Docker disk usage images, containers, volumes, and build cache.</p>
</div>
</div>
<div class="sys-storage-body" data-storage-body>
<div class="sys-storage-loading">Reading Docker daemon</div>
</div>
</div>`;
}
_render() {
const r = this.root();
if (!r) return;
const body = r.querySelector('[data-storage-body]');
if (!body) return;
const d = this.data;
if (!d) {
body.innerHTML = `<div class="sys-storage-err">Couldn't read disk usage from the Docker daemon.</div>`;
return;
}
const fmt = window.SystemFmt;
const segments = [
{ key: 'images', label: 'Images', color: 'accent', data: d.images || {} },
{ key: 'volumes', label: 'Volumes', color: 'status-success', data: d.volumes || {} },
{ key: 'containers', label: 'Containers', color: 'status-info', data: d.containers || {} },
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: d.build_cache || {} },
];
const total = d.total || 1;
const recl = d.reclaimable || 0;
const reclPct = total ? (recl / total) * 100 : 0;
// Donut: cumulative segment arcs, full circle = total. Hand-rolled
// SVG. Stroke-dasharray + stroke-dashoffset for each slice.
const r0 = 90; // ring radius
const stroke = 28;
const C = 2 * Math.PI * r0;
let acc = 0;
const slices = segments.map(s => {
const v = s.data.size || 0;
const frac = total > 0 ? v / total : 0;
const len = C * frac;
const off = C * (1 - acc); // rotated offset start
acc += frac;
return { ...s, frac, len, off };
});
const donut = `
<svg viewBox="0 0 240 240" class="sys-storage-donut" role="img" aria-label="Storage breakdown">
<g transform="translate(120 120) rotate(-90)">
<circle cx="0" cy="0" r="${r0}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${stroke}"/>
${slices.map(s => s.len > 0
? `<circle cx="0" cy="0" r="${r0}" fill="none"
stroke="var(--${s.color})" stroke-width="${stroke}"
stroke-dasharray="${s.len.toFixed(1)} ${(C - s.len).toFixed(1)}"
stroke-dashoffset="${(-((acc - s.frac) * C - 0)).toFixed(1)}"
style="transition:stroke-dasharray .4s ease"/>`
: ''
).join('')}
</g>
<text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total)}</text>
<text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">total in use</text>
</svg>`;
const legend = `
<ul class="sys-storage-legend">
${segments.map(s => `
<li>
<span class="sys-storage-swatch" style="background: var(--${s.color})"></span>
<span class="sys-storage-leg-k">${s.label}</span>
<span class="sys-storage-leg-v">${fmt.bytes(s.data.size || 0)}</span>
${s.data.reclaimable ? `<span class="sys-storage-leg-r">${fmt.bytes(s.data.reclaimable)} reclaimable</span>` : ''}
</li>`).join('')}
</ul>`;
const headline = `
<div class="sys-storage-headline">
<div class="sys-storage-head-card">
${donut}
${legend}
</div>
<div class="sys-storage-head-stats">
<div class="sys-storage-stat">
<span class="sys-storage-stat-k">Total in use</span>
<strong class="sys-storage-stat-v">${fmt.bytes(total)}</strong>
</div>
<div class="sys-storage-stat sys-storage-stat-recl">
<span class="sys-storage-stat-k">Reclaimable</span>
<strong class="sys-storage-stat-v">${fmt.bytes(recl)}</strong>
<span class="sys-storage-stat-sub">${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes &amp; cache</span>
</div>
</div>
</div>`;
const catCards = `
<div class="sys-section-head"><h2>Categories</h2></div>
<div class="sys-storage-cards">
${segments.map(s => {
const v = s.data || {};
const pct = v.size && total ? (v.size / total) * 100 : 0;
const reclPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
return `
<div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
<div class="sys-storage-card-head">
<h3>${s.label}</h3>
<span class="sys-storage-card-count">${v.count ?? 0}</span>
</div>
<div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
<div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
<div class="sys-storage-card-meta">
<span>${pct.toFixed(1)}% of total</span>
${v.reclaimable
? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${reclPct.toFixed(0)}%)</span>`
: ''}
</div>
</div>`;
}).join('')}
</div>`;
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : [];
const imagesTable = topImages.length ? `
<div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Tag</th><th>Size</th><th>Shared</th><th>Containers</th><th>Created</th></tr></thead>
<tbody>
${topImages.map(im => `
<tr>
<td class="sys-app-name">${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}</td>
<td>${fmt.bytes(im.size)}</td>
<td>${fmt.bytes(im.shared_size || 0)}</td>
<td>${im.containers}${im.containers === 0 ? ' <span class="sys-storage-orphan">unused</span>' : ''}</td>
<td>${im.created ? fmt.timeAgo(im.created) : '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '';
const volumesTable = topVolumes.length ? `
<div class="sys-section-head"><h2>Largest volumes</h2><span class="sys-chart-meta">top ${topVolumes.length} by size</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Name</th><th>Driver</th><th>Size</th><th>Refs</th></tr></thead>
<tbody>
${topVolumes.map(v => `
<tr>
<td class="sys-app-name">${fmt.escape(v.name)}</td>
<td>${fmt.escape(v.driver || '—')}</td>
<td>${fmt.bytes(v.size)}</td>
<td>${v.ref_count}${v.ref_count === 0 ? ' <span class="sys-storage-orphan">orphan</span>' : ''}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '';
body.innerHTML = `${headline}${catCards}${imagesTable}${volumesTable}`;
}
}
window.SystemStoragePage = SystemStoragePage;

View File

@ -93,17 +93,18 @@ if (typeof window.ConfigManager === 'undefined') {
return;
}
// System is an admin tool page (live host + per-app statistics) with its
// own controller, like SSH Access above.
// System is an admin tool page that internally routes between index /
// metric / per-app / storage sub-views based on the URL sub-path.
// Load every sub-page module up-front so direct URL loads of any view
// get a synchronous dispatch.
if (category === 'system') {
try { this.sidebar.populateSidebar(); } catch (e) {}
// Detail overlay (fullscreen single-metric deep-dive) is its own
// file; load it alongside the page controller so "Expand" works
// from the first paint.
await Promise.all([
lazyLoad('/js/components/admin/charts.js'),
lazyLoad('/js/components/admin/admin-system.js'),
lazyLoad('/js/components/admin/system-detail.js')
lazyLoad('/js/components/admin/system-metric-page.js'),
lazyLoad('/js/components/admin/system-app-page.js'),
lazyLoad('/js/components/admin/system-storage-page.js')
]);
if (typeof AdminSystem !== 'undefined') {
window.adminSystem = new AdminSystem('config-section');