first commit
This commit is contained in:
208
server.js
Normal file
208
server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user