import express from 'express'; import Docker from 'dockerode'; import path from 'path'; import { fileURLToPath } from 'url'; import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const docker = new Docker({ socketPath: '/var/run/docker.sock' }); const PORT = process.env.PORT || 8080; const ENABLE_AUTH = !!(process.env.BASIC_AUTH_USER || process.env.BASIC_AUTH_PASS); const AUTH_USER = process.env.BASIC_AUTH_USER || ''; const AUTH_PASS = process.env.BASIC_AUTH_PASS || ''; const MEM_SUBTRACT_CACHE = String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false'; function constantTimeEqual(a, b) { const aa = Buffer.from(a || ''); const bb = Buffer.from(b || ''); if (aa.length !== bb.length) return false; return crypto.timingSafeEqual(aa, bb); } function basicAuth(req, res, next) { if (!ENABLE_AUTH) return next(); const hdr = req.headers['authorization'] || ''; const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(hdr); if (!m) return unauthorized(res); const dec = Buffer.from(m[1], 'base64').toString('utf8'); const idx = dec.indexOf(':'); if (idx < 0) return unauthorized(res); const u = dec.slice(0, idx); const p = dec.slice(idx + 1); if (constantTimeEqual(u, AUTH_USER) && constantTimeEqual(p, AUTH_PASS)) return next(); return unauthorized(res); } function unauthorized(res) { res.set('WWW-Authenticate', 'Basic realm="Stack Stats"'); return res.status(401).send('Authentication required'); } app.use(basicAuth); app.use(express.static(path.join(__dirname, 'public'))); function calcCpuPercentUnix(_, current) { try { if (!current || !current.cpu_stats || !current.precpu_stats) return 0; const cpuDelta = (current.cpu_stats.cpu_usage?.total_usage ?? 0) - (current.precpu_stats.cpu_usage?.total_usage ?? 0); const systemDelta = (current.cpu_stats.system_cpu_usage ?? 0) - (current.precpu_stats.system_cpu_usage ?? 0); const onlineCPUs = current.cpu_stats.online_cpus || (current.cpu_stats.cpu_usage?.percpu_usage?.length ?? 1) || 1; if (systemDelta <= 0 || cpuDelta <= 0) return 0; return (cpuDelta / systemDelta) * onlineCPUs * 100; } catch { return 0; } } function bytesToMiB(bytes) { return (bytes || 0) / (1024 * 1024); } function stackOrProject(labels = {}) { const stack = labels['com.docker.stack.namespace']; const proj = labels['com.docker.compose.project']; if (stack && stack !== '(unknown)') return stack; if (proj && proj !== '(unknown)') return proj; return '__no_stack__'; } function memoryMiBFromStats(stats) { try { if (!stats || !stats.memory_stats) return 0; let usage = stats.memory_stats.usage || 0; if (String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false') { const s = stats.memory_stats.stats || {}; const cacheLike = (typeof s.cache === 'number' ? s.cache : 0) || (typeof s.inactive_file === 'number' ? s.inactive_file : 0); if (cacheLike > 0 && usage > cacheLike) usage -= cacheLike; } return bytesToMiB(Math.max(0, usage)); } catch { return 0; } } async function dockerInfo() { try { const info = await docker.info(); return { serverVersion: info.ServerVersion, ncpu: info.NCPU, memTotalMiB: Math.round((info.MemTotal || 0) / (1024*1024)), os: info.OperatingSystem, kernel: info.KernelVersion, architecture: info.Architecture, containers: { total: info.Containers, running: info.ContainersRunning, paused: info.ContainersPaused, stopped: info.ContainersStopped } }; } catch (e) { return { error: String(e) }; } } async function fetchAllStats() { const containers = await docker.listContainers({ all: false }); const info = await dockerInfo(); const sysMemMiB = info.memTotalMiB || 0; const results = await Promise.allSettled(containers.map(async (c) => { const id = c.Id; const name = (c.Names?.[0] || '').replace(/^\//, '') || c.Names?.[0] || id.slice(0,12); const group = stackOrProject(c.Labels || {}); const container = docker.getContainer(id); let statsRaw; try { statsRaw = await container.stats({ stream: false }); } catch (e) { return { id, name, group, cpu: 0, memMiB: 0, memPctSys: 0 }; } const cpu = calcCpuPercentUnix(null, statsRaw); const memMiB = memoryMiBFromStats(statsRaw); const memPctSys = sysMemMiB > 0 ? (memMiB / sysMemMiB) * 100 : 0; return { id, name, group, cpu, memMiB, memPctSys }; })); const data = results.filter(r => r.status === 'fulfilled').map(r => r.value); const groups = {}; for (const row of data) { if (!groups[row.group]) groups[row.group] = { group: row.group, cpuSum: 0, memSumMiB: 0, memPctSys: 0, containers: [] }; groups[row.group].cpuSum += row.cpu; groups[row.group].memSumMiB += row.memMiB; groups[row.group].containers.push(row); } const infoMem = info.memTotalMiB || 0; if (infoMem > 0) { for (const g of Object.values(groups)) { g.memPctSys = (g.memSumMiB / infoMem) * 100; } } const groupList = Object.values(groups) .sort((a, b) => b.memSumMiB - a.memSumMiB) .map(g => ({ group: g.group, cpuSum: Number(g.cpuSum.toFixed(2)), memSumMiB: Number(g.memSumMiB.toFixed(2)), memPctSys: Number(g.memPctSys.toFixed(2)), count: g.containers.length })); const total = groupList.reduce((acc, g) => ({ cpuSum: Number((acc.cpuSum + g.cpuSum).toFixed(2)), memSumMiB: Number((acc.memSumMiB + g.memSumMiB).toFixed(2)), memPctSys: Number((acc.memPctSys + g.memPctSys).toFixed(2)) }), { cpuSum: 0, memSumMiB: 0, memPctSys: 0 }); for (const g of Object.values(groups)) { g.containers.sort((a, b) => b.cpu - a.cpu); g.containers = g.containers.map(c => ({ id: c.id, name: c.name, group: c.group, cpu: Number(c.cpu.toFixed(2)), memMiB: Number(c.memMiB.toFixed(2)), memPctSys: Number((c.memPctSys || 0).toFixed(2)) })); } return { total, groups, groupList, sys: info, containers: data }; } // endpoints app.get('/api/summary', async (req, res) => { try { const { total, groupList, sys } = await fetchAllStats(); res.json({ total, groups: groupList, sys }); } catch (e) { res.status(500).json({ error: e.message || String(e) }); } }); app.get('/api/group/:name', async (req, res) => { try { const { groups } = await fetchAllStats(); const g = groups[req.params.name]; if (!g) return res.json({ group: req.params.name, containers: [], cpuSum: 0, memSumMiB: 0, memPctSys: 0 }); res.json({ group: g.group, containers: g.containers, cpuSum: g.cpuSum, memSumMiB: g.memSumMiB, memPctSys: g.memPctSys }); } catch (e) { res.status(500).json({ error: e.message || String(e) }); } }); app.get('/api/containers', async (req, res) => { try { const { containers, sys } = await fetchAllStats(); res.json({ containers, sys }); } catch (e) { res.status(500).json({ error: e.message || String(e) }); } }); app.get('/health', async (req, res) => { try { await docker.ping(); const cont = await docker.listContainers({ all: false }); res.json({ ok: true, containers: cont.length }); } catch (e) { res.status(500).json({ ok: false, error: String(e) }); } }); app.listen(PORT, () => { console.log(`docker-stack-stats-ui listening on :${PORT}`); });