first commit

This commit is contained in:
2025-11-11 10:36:31 +01:00
commit 80eb037b56
25 changed files with 4509 additions and 0 deletions

208
server.js Normal file
View File

@@ -0,0 +1,208 @@
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}`);
});