// 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 for the engine overhead worth // acting on (images, build cache). Cached for STORAGE_TTL_MS because this is // one of the more expensive calls on a busy daemon. Named volumes and // container writable layers are omitted: LibrePortal apps keep data in bind // mounts, so both read ~empty here — per-app on-disk usage is generated // separately (see webuiSystemAppStorage / /data/system/app_storage.json). // // 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. We // surface only the engine overhead worth acting on — images and build // cache — and skip container writable layers: for LibrePortal that's a // near-zero scratch number (app data lives in bind mounts, shown per-app // elsewhere) that just confuses the picture. // "Reclaimable" reflects exactly what the Reclaim button frees: dangling // images + the whole build cache. Tagged-but-unused images are // deliberately NOT counted — the safe prune leaves them alone — so the // headline matches the button's effect instead of overstating it. const isDangling = (im) => { const tags = im.RepoTags || []; return tags.length === 0 || tags.every(t => t.includes('')); }; const sumImages = (df.Images || []).reduce( (a, im) => { a.count++; a.size += im.Size || 0; a.shared += im.SharedSize || 0; if (isDangling(im)) a.reclaimable += im.Size || 0; return a; }, { count: 0, size: 0, shared: 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 } ); // Every image, largest first — the Storage page lists them all so the // user can remove specific ones (deletion runs through the task/CLI, // not from here). Dangling images are included on purpose. const images = (df.Images || []) .slice() .sort((a, b) => (b.Size || 0) - (a.Size || 0)) .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, })); const total = sumImages.size + sumBuild.size; const reclaimable = sumImages.reclaimable + sumBuild.reclaimable; res.set('Cache-Control', 'no-store'); res.json({ total, reclaimable, images: sumImages, build_cache: sumBuild, image_list: images, updated: new Date().toISOString(), }); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router;