From dbcab8614ff5bddad912111289b8baf36a29487a Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 27 May 2026 21:53:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(system):=20route-based=20sub-pages=20?= =?UTF-8?q?=E2=80=94=20metric=20/=20per-container=20/=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ single-metric deep-dive /admin/config/system/app/ 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 --- .../backend/routes/docker-info-routes.js | 420 ++++++++++++ .../libreportal/backend/routes/routes.js | 2 + .../libreportal/backend/utils/docker.js | 126 ++++ containers/libreportal/frontend/css/admin.css | 627 +++++++++++++++--- .../js/components/admin/admin-system.js | 195 ++++-- .../js/components/admin/system-app-page.js | 382 +++++++++++ ...system-detail.js => system-metric-page.js} | 345 +++++----- .../components/admin/system-storage-page.js | 233 +++++++ .../js/components/config/config-manager.js | 13 +- 9 files changed, 2030 insertions(+), 313 deletions(-) create mode 100644 containers/libreportal/backend/routes/docker-info-routes.js create mode 100644 containers/libreportal/backend/utils/docker.js create mode 100644 containers/libreportal/frontend/js/components/admin/system-app-page.js rename containers/libreportal/frontend/js/components/admin/{system-detail.js => system-metric-page.js} (58%) create mode 100644 containers/libreportal/frontend/js/components/admin/system-storage-page.js diff --git a/containers/libreportal/backend/routes/docker-info-routes.js b/containers/libreportal/backend/routes/docker-info-routes.js new file mode 100644 index 0000000..8e4791b --- /dev/null +++ b/containers/libreportal/backend/routes/docker-info-routes.js @@ -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//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; diff --git a/containers/libreportal/backend/routes/routes.js b/containers/libreportal/backend/routes/routes.js index fed6605..1ac1eaf 100755 --- a/containers/libreportal/backend/routes/routes.js +++ b/containers/libreportal/backend/routes/routes.js @@ -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) => { diff --git a/containers/libreportal/backend/utils/docker.js b/containers/libreportal/backend/utils/docker.js new file mode 100644 index 0000000..3d8202c --- /dev/null +++ b/containers/libreportal/backend/utils/docker.js @@ -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//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, +}; diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index 2588a19..0a2d96c 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -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; } diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js index cc0a509..e73f88c 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-system.js +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -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/ → single-metric deep-dive page +// /admin/config/system/app/ → 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 = '
Loading system stats…
'; + 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 = '
Loading system stats…
'; 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 { `; } - // 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=) 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 = `
${this._gaugesHtml()}
`; - // 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') : ''} `; - // Host info + swap + docker summary const infoStrip = `
${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')}
`; + // Docker strip — now a navigational tile too. "Storage" leads to the + // dedicated breakdown page; the rest are display-only. const dockerStrip = `

Docker

${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))} +
`; - // 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 ` + return ` ${this.escape(a.app)} ${a.running}/${a.containers} up ${C.bar(a.cpu_percent)}${(a.cpu_percent || 0).toFixed(1)}% ${C.bar(a.mem_percent)}${this.bytes(a.mem_bytes)} ↓${this.bytes(a.net_rx)} ↑${this.bytes(a.net_tx)} ${C.sparkline(spark, { color: 'accent' })} + › `; - }).join('') : `No running containers — install an app to see per-app stats.`; + }).join('') : `No running containers — install an app to see per-app stats.`; const appsTable = ` -

Per-app usage

sorted by CPU
+

Per-app usage

click a row to open the deep-dive · sorted by CPU
- + ${appsBody}
AppCPUMemoryNetworkCPU trend
AppCPUMemoryNetworkCPU trend
`; @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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; diff --git a/containers/libreportal/frontend/js/components/admin/system-app-page.js b/containers/libreportal/frontend/js/components/admin/system-app-page.js new file mode 100644 index 0000000..8e6dfcc --- /dev/null +++ b/containers/libreportal/frontend/js/components/admin/system-app-page.js @@ -0,0 +1,382 @@ +// Admin → System → App — per-container deep-dive page. +// +// Mounted at /admin/config/system/app/. 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//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 = '
Loading…
'; + 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 = `
${(window.SystemFmt?.escape || ((s)=>s))(clean) || 'empty'}
`; + const pre = bodyEl.querySelector('pre'); + if (pre) pre.scrollTop = pre.scrollHeight; + } catch (err) { + bodyEl.innerHTML = `
Failed to load logs: ${(err && err.message) || err}
`; + } + } + + _renderShell() { + const r = this.root(); + if (!r) return; + const fmt = window.SystemFmt; + const totalRunning = this.members.filter(m => m.state === 'running').length; + const header = ` + `; + if (!this.members.length) { + r.innerHTML = ` +
+ ${header} +
+ No containers found for "${fmt.escape(this.appName)}". + It may not be installed, or its compose project label differs from the app name. +
+
`; + return; + } + r.innerHTML = ` +
+ ${header} +
+ ${this.members.map(m => this._cardSkeleton(m)).join('')} +
+
`; + } + + _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 ? `${fmt.escape(member.service)}` : ''; + return ` +
+
+
+
+ + ${fmt.escape(member.state || 'unknown')} + ${service} +
+

${fmt.escape(member.name)}

+
+ ${fmt.escape(member.image || '—')} + · + ${fmt.escape(member.short || '')} +
+
+
${fmt.escape(member.status || '')}
+
+ +
+
CPU
+
Memory
+
↓ rx
+
↑ tx
+
Block r/w
+
PIDs
+
+ +
+
Loading container detail…
+
+ +
+
+ + +
+ +
+
`; + } + + _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 = `
Couldn't load container detail.
`; + 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 = ` +
+
+ Memory limit + ${memLimit ? fmt.bytes(memLimit) : 'unlimited'} +
+
+ CPU limit + ${fmt.escape(cpuLimit)} +
+
+ PIDs limit + ${lim.pids ? lim.pids : 'unlimited'} +
+
+ Restart policy + ${fmt.escape(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''} +
+
+ Restart count + ${d.state?.restart_count ?? 0} +
+
+ Started + ${d.state?.started_at ? fmt.timeAgoIso(d.state.started_at) : '—'} +
+
`; + + const health = d.state?.health + ? `
+

Healthcheck

+
+ ${fmt.escape(d.state.health.status || 'unknown')} + ${d.state.health.failing_streak ? `${d.state.health.failing_streak} failing in a row` : ''} +
+ ${(d.state.health.log || []).slice(-3).reverse().map(l => + `
${fmt.escape(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}
${fmt.escape(l.output || '')}
` + ).join('')} +
` : ''; + + const nets = (d.networks || []).length + ? `
+

Networks

+ + + ${d.networks.map(n => ` + + `).join('')} +
NameIPGatewayMAC
${fmt.escape(n.name)}${fmt.escape(n.ip || '—')}${fmt.escape(n.gateway || '—')}${fmt.escape(n.mac || '—')}
+
` : ''; + + const ports = (d.ports || []).filter(p => p.host).length + ? `
+

Published ports

+
    ${(d.ports || []).filter(p => p.host).map(p => + `
  • ${p.host} → ${p.container}/${fmt.escape(p.proto || '')}
  • ` + ).join('')}
+
` : ''; + + const mounts = (d.mounts || []).length + ? `
+

Mounts

+ + + ${d.mounts.map(m => ` + + + + + `).join('')} +
TypeSourceTargetMode
${fmt.escape(m.type || '')}${fmt.escape(m.source || '')}${fmt.escape(m.target || '')}${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''}
+
` : ''; + + 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; diff --git a/containers/libreportal/frontend/js/components/admin/system-detail.js b/containers/libreportal/frontend/js/components/admin/system-metric-page.js similarity index 58% rename from containers/libreportal/frontend/js/components/admin/system-detail.js rename to containers/libreportal/frontend/js/components/admin/system-metric-page.js index 5e5568c..165d016 100644 --- a/containers/libreportal/frontend/js/components/admin/system-detail.js +++ b/containers/libreportal/frontend/js/components/admin/system-metric-page.js @@ -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/. 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 ; 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 = ` -
-
-
-
-
Admin · System · Live
-

-

+ _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 = ` +
+ `; + } + + _renderShell() { + const root = this.root(); + if (!root) return; + const fmt = window.SystemFmt; + root.innerHTML = ` +
+
+
${['now','peak','avg','min'].map(k => `
@@ -86,7 +160,7 @@ class SystemDetail {
`).join('')}
-
+
Loading history…
@@ -94,97 +168,67 @@ class SystemDetail {
- Esc to close · hover the chart to scrub · range tier auto-selects + Esc returns to System · hover the chart to scrub · range tier auto-selects
`; - 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 += ``; yLabels += `${fmtY(v)}`; } - // 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 += `${this._fmtTime(t)}`; } - // Peak + min markers. const peakDot = (stats.peakIdx >= 0) ? `` : ''; const minDot = (stats.minIdx >= 0) ? `` : ''; - // Now dot — pulses via CSS. const nowIdx = n - 1; const nowDot = ``; - // Hover crosshair (positioned in _setHover). const crosshair = ` `; - // Gradient id is stable so the fill keeps animating during live updates. const gradId = 'sys-detail-grad'; svg.innerHTML = ` @@ -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 = `
${fmt(v)}
${this._fmtTimeFull(t)}
`; 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; diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js new file mode 100644 index 0000000..a86193c --- /dev/null +++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js @@ -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 = ` +
+ +
+
Reading Docker daemon…
+
+
`; + } + + _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 = `
Couldn't read disk usage from the Docker daemon.
`; + 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 = ` + + + + ${slices.map(s => s.len > 0 + ? `` + : '' + ).join('')} + + ${fmt.bytes(total)} + total in use + `; + + const legend = ` +
    + ${segments.map(s => ` +
  • + + ${s.label} + ${fmt.bytes(s.data.size || 0)} + ${s.data.reclaimable ? `${fmt.bytes(s.data.reclaimable)} reclaimable` : ''} +
  • `).join('')} +
`; + + const headline = ` +
+
+ ${donut} + ${legend} +
+
+
+ Total in use + ${fmt.bytes(total)} +
+
+ Reclaimable + ${fmt.bytes(recl)} + ${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes & cache +
+
+
`; + + const catCards = ` +

Categories

+
+ ${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 ` +
+
+

${s.label}

+ ${v.count ?? 0} +
+
${fmt.bytes(v.size || 0)}
+
+
+ ${pct.toFixed(1)}% of total + ${v.reclaimable + ? `${fmt.bytes(v.reclaimable)} reclaimable (${reclPct.toFixed(0)}%)` + : ''} +
+
`; + }).join('')} +
`; + + const topImages = Array.isArray(d.top_images) ? d.top_images : []; + const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : []; + + const imagesTable = topImages.length ? ` +

Largest images

top ${topImages.length} by size
+
+ + + + ${topImages.map(im => ` + + + + + + + `).join('')} + +
TagSizeSharedContainersCreated
${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}${fmt.bytes(im.size)}${fmt.bytes(im.shared_size || 0)}${im.containers}${im.containers === 0 ? ' unused' : ''}${im.created ? fmt.timeAgo(im.created) : '—'}
+
` : ''; + + const volumesTable = topVolumes.length ? ` +

Largest volumes

top ${topVolumes.length} by size
+
+ + + + ${topVolumes.map(v => ` + + + + + + `).join('')} + +
NameDriverSizeRefs
${fmt.escape(v.name)}${fmt.escape(v.driver || '—')}${fmt.bytes(v.size)}${v.ref_count}${v.ref_count === 0 ? ' orphan' : ''}
+
` : ''; + + body.innerHTML = `${headline}${catCards}${imagesTable}${volumesTable}`; + } +} + +window.SystemStoragePage = SystemStoragePage; diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js index ce2c120..2a528d4 100755 --- a/containers/libreportal/frontend/js/components/config/config-manager.js +++ b/containers/libreportal/frontend/js/components/config/config-manager.js @@ -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');