Replaces the read-only "Largest images" top-10 table with a Tasks-style list of ALL Docker images, with select-one / select-multiple / clear-all removal that mirrors the Tasks page UX (row checkboxes, master select-all, a button that morphs Clear All ↔ Delete Selected (N), an eo confirm modal). Deletion routes through the task system, NOT a new web API: a new `libreportal system image rm [--force] <ids>` CLI subcommand (validates each ref, loops runFileOp docker image rm, reports a tally) is invoked via the system_image_rm task action — same pattern as Reclaim. The web backend change is read-only (uncap the existing /storage image list). In-use images are skipped by default with an opt-in "force-remove" toggle (warned). The page stays put, toasts, and refreshes on the task's completion event. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
401 lines
17 KiB
JavaScript
401 lines
17 KiB
JavaScript
// Read-only Docker inspection routes for the Admin → System deep-dive pages.
|
|
//
|
|
// GET /api/system/containers
|
|
// List every container with a per-app summary (compose-project grouping
|
|
// mirrors what metrics_apps.json does, but with extra per-container
|
|
// fields the deep-dive pages need: state, status, image, ports, mounts,
|
|
// restart count). Cached for STAT_TTL_MS.
|
|
//
|
|
// GET /api/system/containers/:id
|
|
// Full container detail straight from `docker inspect`. Includes
|
|
// resource limits, mounts, networks, env, health-check state, restart
|
|
// policy. Cached briefly so the page can poll without thrashing the
|
|
// daemon.
|
|
//
|
|
// GET /api/system/containers/:id/stats
|
|
// One-shot live stats sample (one frame of /containers/<id>/stats).
|
|
// Returns the same shape Docker emits, plus a derived `cpu_percent`
|
|
// and `mem_percent` so the frontend doesn't have to recompute.
|
|
//
|
|
// GET /api/system/containers/:id/logs?tail=N
|
|
// Last N lines of combined stdout/stderr, multiplex-decoded.
|
|
//
|
|
// GET /api/system/storage
|
|
// `docker system df` — total + reclaimable 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('<none>'));
|
|
};
|
|
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 <none> 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;
|