Three fixes from testing the storage page: - Placement: the "Reclaim space" button moves into the page header, top-right (matching the metric page), instead of sitting in the body. - It now actually reclaims: build cache needs -a to drop (docker reports 0 B "reclaimable" without it, but it's pure cache — safe to clear), so the CLI uses `docker builder prune -af`. Previously the safe scope freed ~nothing on a box whose reclaimable was mostly cache. - Honest "Reclaimable" number: /api/system/storage was counting the whole build cache AND unused tagged images, overstating what the safe prune frees (e.g. 340 MB shown, ~96 MB per docker, button cleared 0). Reclaimable now = dangling images + build cache only; stopped containers and volumes are never counted (the safe prune never touches them). Headline now matches the button's effect. Also simplify the CLI output (drop the jargony scope notice and the reclaimed-total greps) and re-enable the now-persistent header button after the post-reclaim refreshes. Signed-off-by: librelad <librelad@digitalangels.vip>
426 lines
18 KiB
JavaScript
426 lines
18 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 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.
|
|
// "Reclaimable" across this page reflects exactly what the Reclaim
|
|
// button frees: dangling images + the whole build cache. Tagged-but-
|
|
// unused images, stopped containers and volumes are deliberately NOT
|
|
// counted — the safe prune leaves them alone — so the headline number
|
|
// 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 sumContainers = (df.Containers || []).reduce(
|
|
(a, c) => {
|
|
a.count++;
|
|
a.size += c.SizeRw || 0;
|
|
return a;
|
|
},
|
|
{ count: 0, size: 0, reclaimable: 0 }
|
|
);
|
|
const sumVolumes = (df.Volumes || []).reduce(
|
|
(a, v) => {
|
|
a.count++;
|
|
a.size += (v.UsageData && v.UsageData.Size) || 0;
|
|
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;
|