web view
Build and Push Docker Image / build-and-push (push) Successful in 20s

This commit is contained in:
ciomek committed 2026-09-19 22:08:09 +02:00
1 parent 48349569e7
commit 4bfaed6184
19 files changed
+761 -47

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
"use strict";
const REFRESH_MS = 1000;
const SVG_NS = "http://www.w3.org/2000/svg";
const PAD = 24;
const CARD_W = 280;
const CARD_H = 168;
const CARD_GAP = 16;
const BUS_GUTTER = 14;
const NARROW_WIDTH = 360;
const NARROW_BELOW = 640;
const CHIP_HEIGHT = 17;
const CHIP_GAP = 5;
const CHIP_LINES = 2;
const CENTER_W = 170;
const CENTER_MAX_ROWS = 2;
const CENTER_ROW = 24;
function address(member) {
return member.ip + ":" + member.port;
}
function roleStates(payload) {
const alive = payload.members.filter((member) => member.alive);
return payload.roles.map((role) => {
const holders = alive.filter((member) => member.roles.includes(role.name)).map(address).sort();
let state;
if (role.required === null) {
state = "general";
} else if (holders.length < role.required) {
state = "unmet";
} else if (holders.length > role.required) {
state = "duplicate";
} else {
state = "held";
}
return Object.assign({}, role, { holders: holders, state: state });
});
}
function shorten(text, limit) {
return text.length > limit ? text.slice(0, limit - 1) + "…" : text;
}
function formatAge(seconds) {
seconds = Math.floor(seconds);
if (seconds < 60) return seconds + "s";
if (seconds < 3600) return Math.floor(seconds / 60) + "m" + String(seconds % 60).padStart(2, "0") + "s";
return Math.floor(seconds / 3600) + "h" + String(Math.floor((seconds % 3600) / 60)).padStart(2, "0") + "m";
}
function formatSize(gb) {
if (gb >= 1000) return (gb / 1024).toFixed(1) + " TB";
if (gb >= 100) return Math.round(gb) + " GB";
return gb.toFixed(1) + " GB";
}
function formatUptime(hours) {
if (hours < 1) return Math.round(hours * 60) + "m";
if (hours < 48) return Math.round(hours) + "h";
return Math.floor(hours / 24) + "d " + Math.round(hours % 24) + "h";
}
function percent(used, total) {
return total > 0 ? Math.max(0, Math.min(100, Math.round((100 * used) / total))) : null;
}
function barLevel(pct) {
return pct >= 90 ? "crit" : pct >= 75 ? "warn" : "ok";
}
function describeStats(stats) {
stats = stats || {};
const num = (key) => (typeof stats[key] === "number" ? stats[key] : null);
const cpuPercent = num("cpu_percent");
const cores = num("cpu_cores");
const threads = num("cpu_threads");
let cpuText = null;
if (cpuPercent !== null) {
cpuText = Math.round(cpuPercent) + "%";
if (cores && threads) cpuText += " · " + cores + "C/" + threads + "T";
else if (threads) cpuText += " · " + threads + "T";
}
const usage = (usedKey, totalKey) => {
const used = num(usedKey);
const total = num(totalKey);
if (used === null || total === null) return null;
return { pct: percent(used, total), text: formatSize(used) + " / " + formatSize(total) };
};
const gpuCount = num("gpu_count");
const gpuUsage = usage("gpu_mem_used_gb", "gpu_mem_total_gb");
const uptime = num("uptime_hours");
const cpu = cpuText === null ? null : { pct: Math.round(cpuPercent), text: cpuText };
const ram = usage("mem_used_gb", "mem_total_gb");
const disk = usage("disk_used_gb", "disk_total_gb");
const gpu = gpuCount && gpuUsage ? (gpuCount > 1 ? gpuCount + "× " : "") + "GPU " + gpuUsage.text : null;
const footer = [uptime === null ? null : "up " + formatUptime(uptime), gpu].filter(Boolean).join(" · ");
return { cpu: cpu, ram: ram, disk: disk, gpu: gpu, uptime: uptime, footer: cpu || ram || disk ? footer : "no stats reported (older version?)" };
}
function alerts(payload) {
const states = roleStates(payload);
const out = [];
const unmet = states.filter((role) => role.state === "unmet").map((role) => role.name);
const twice = states.filter((role) => role.state === "duplicate").map((role) => role.name);
const down = payload.members.filter((member) => !member.alive).map((member) => member.hostname);
if (unmet.length) out.push({ level: "bad", text: "Unmet: " + unmet.join(", ") });
if (twice.length) out.push({ level: "warn", text: "Held twice: " + twice.join(", ") });
if (down.length) out.push({ level: "info", text: "Down: " + down.join(", ") });
return out;
}
function summarize(payload) {
const states = roleStates(payload);
const alive = payload.members.filter((member) => member.alive).length;
const down = payload.members.length - alive;
const assigned = states.filter((role) => role.state !== "general");
const held = assigned.filter((role) => role.state !== "unmet");
let text = alive + " node" + (alive === 1 ? "" : "s") + " alive";
if (down) text += " · " + down + " down";
text += " · " + held.length + "/" + assigned.length + " roles held";
return text;
}
function nodeRoles(payload, member) {
const states = new Map(roleStates(payload).map((role) => [role.name, role]));
const known = payload.roles.map((role) => role.name).filter((name) => member.roles.includes(name));
const unknown = member.roles.filter((name) => !states.has(name)).sort();
const kind = (name) => (states.has(name) ? states.get(name).state : "unknown");
const rank = (name) => (kind(name) === "general" ? 1 : 0);
return known
.concat(unknown)
.map((name, index) => ({ text: name, kind: kind(name), order: index }))
.sort((a, b) => rank(a.text) - rank(b.text) || a.order - b.order)
.map((entry) => ({ text: entry.text, kind: entry.kind }));
}
function chipWidth(text) {
return Math.round(text.length * 6.3) + 14;
}
function chipLayout(labels, maxWidth, maxLines) {
const chips = [];
let line = 0;
let x = 0;
let shown = 0;
for (const label of labels) {
const width = chipWidth(label.text);
const wraps = x > 0 && x + width > maxWidth;
if ((wraps ? line + 1 : line) >= maxLines) break;
if (wraps) {
line += 1;
x = 0;
}
chips.push({ text: label.text, kind: label.kind, x: x, line: line, w: width });
x += width + CHIP_GAP;
shown += 1;
}
let hidden = labels.length - shown;
if (hidden > 0) {
for (;;) {
const text = "+" + hidden;
const width = chipWidth(text);
const last = chips[chips.length - 1];
const at = last ? last.x + last.w + CHIP_GAP : 0;
if (!last || at + width <= maxWidth) {
chips.push({ text: text, kind: "more", x: at, line: maxLines - 1, w: width });
break;
}
chips.pop();
hidden += 1;
}
}
return chips;
}
function layout(payload, narrow) {
const members = payload.members;
const count = members.length;
const unmet = roleStates(payload).filter((role) => role.state === "unmet").map((role) => role.name);
const cards = [];
const links = [];
let width;
let height;
let center = null;
if (narrow) {
width = NARROW_WIDTH;
const cardW = width - 2 * PAD - BUS_GUTTER;
members.forEach((member, index) => {
const y = PAD + index * (CARD_H + CARD_GAP);
cards.push({ member: member, key: address(member), x: PAD + BUS_GUTTER, y: y, w: cardW, h: CARD_H, cx: PAD + BUS_GUTTER + cardW / 2, cy: y + CARD_H / 2 });
});
height = PAD * 2 + count * CARD_H + (count - 1) * CARD_GAP;
const busX = PAD / 2;
if (count > 1) {
links.push({ x1: busX, y1: cards[0].cy, x2: busX, y2: cards[count - 1].cy, kind: "link" });
}
cards.forEach((card) => {
links.push({ x1: busX, y1: card.cy, x2: card.x, y2: card.cy, kind: card.member.alive ? "link" : "down" });
});
} else {
const rx = count <= 1 ? 0 : Math.max(280, count * 70);
const ry = count <= 2 ? 0 : Math.max(200, count * 50);
const start = count === 2 ? Math.PI : count % 2 === 0 ? -Math.PI / 2 + Math.PI / count : -Math.PI / 2;
members.forEach((member, index) => {
const angle = start + (2 * Math.PI * index) / count;
const centerX = rx * Math.cos(angle);
const centerY = ry * Math.sin(angle);
cards.push({ member: member, key: address(member), x: centerX - CARD_W / 2, y: centerY - CARD_H / 2, w: CARD_W, h: CARD_H, cx: centerX, cy: centerY });
});
const left = Math.min.apply(null, cards.map((card) => card.x)) - PAD;
const top = Math.min.apply(null, cards.map((card) => card.y)) - PAD;
width = Math.max.apply(null, cards.map((card) => card.x + card.w)) + PAD - left;
height = Math.max.apply(null, cards.map((card) => card.y + card.h)) + PAD - top;
cards.forEach((card) => {
card.x -= left;
card.y -= top;
card.cx -= left;
card.cy -= top;
});
const cx = -left;
const cy = -top;
const alive = cards.filter((card) => card.member.alive);
for (let i = 0; i < alive.length; i++) {
for (let j = i + 1; j < alive.length; j++) {
links.push({ x1: alive[i].cx, y1: alive[i].cy, x2: alive[j].cx, y2: alive[j].cy, kind: "link" });
}
}
const viewer = cards.find((card) => card.member.self);
cards.filter((card) => !card.member.alive).forEach((card) => {
if (viewer) links.push({ x1: viewer.cx, y1: viewer.cy, x2: card.cx, y2: card.cy, kind: "down" });
});
if (count >= 3) {
const rows = unmet.length > CENTER_MAX_ROWS ? [unmet[0], "+" + (unmet.length - 1) + " more"] : unmet;
const h = 34 + Math.max(rows.length, 1) * CENTER_ROW;
center = { x: cx - CENTER_W / 2, y: cy - h / 2, w: CENTER_W, h: h, unmet: unmet, rows: rows };
}
}
return { width: width, height: height, narrow: narrow, cards: cards, links: links, center: center };
}
if (typeof document !== "undefined") {
const svg = (name, attributes, text) => {
const element = document.createElementNS(SVG_NS, name);
Object.keys(attributes || {}).forEach((key) => element.setAttribute(key, attributes[key]));
if (text !== undefined) element.textContent = text;
return element;
};
const html = (name, text, className) => {
const element = document.createElement(name);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
};
const drawChip = (parent, chip, x, y) => {
const group = svg("g", { class: "chip " + chip.kind, transform: "translate(" + x + "," + y + ")" });
group.appendChild(svg("rect", { width: chip.w, height: CHIP_HEIGHT, rx: 8.5 }));
group.appendChild(svg("text", { x: chip.w / 2, y: 12 }, chip.text));
parent.appendChild(group);
};
const drawBar = (group, y, cardW, label, usage) => {
const trackX = 44;
const trackW = cardW - trackX - 14 - 118;
group.appendChild(svg("text", { class: "t-label", x: 14, y: y + 8 }, label));
group.appendChild(svg("rect", { class: "track", x: trackX, y: y, width: trackW, height: 8, rx: 4 }));
if (usage) {
const width = usage.pct ? Math.max((trackW * usage.pct) / 100, 4) : 0;
if (width) {
group.appendChild(svg("rect", { class: "fill " + barLevel(usage.pct), x: trackX, y: y, width: width, height: 8, rx: 4 }));
}
group.appendChild(svg("text", { class: "t-value", x: cardW - 14, y: y + 8 }, usage.text));
} else {
group.appendChild(svg("text", { class: "t-value na", x: cardW - 14, y: y + 8 }, "n/a"));
}
};
const drawCard = (parent, card, payload) => {
const member = card.member;
const stats = describeStats(member.stats);
const classes = ["node", member.alive ? "alive" : "down"];
if (member.self) classes.push("self");
const group = svg("g", { class: classes.join(" "), transform: "translate(" + card.x + "," + card.y + ")" });
group.appendChild(svg("title", {}, member.hostname + " " + card.key + "\nroles: " + (member.roles.join(", ") || "none") +
(member.declined.length ? "\ndeclined: " + member.declined.join(", ") : "")));
group.appendChild(svg("rect", { class: "card", width: card.w, height: card.h, rx: 12 }));
group.appendChild(svg("circle", { class: "dot " + (member.alive ? "alive" : "down"), cx: 20, cy: 22, r: 5 }));
group.appendChild(svg("text", { class: "t-title", x: 32, y: 26 }, shorten(member.hostname, 22)));
const tag = member.self ? "this node" : member.alive ? "" : "down " + formatAge(member.seen_ago);
if (tag) group.appendChild(svg("text", { class: "t-tag", x: card.w - 14, y: 26 }, tag));
group.appendChild(svg("text", { class: "t-sub", x: 32, y: 43 }, card.key));
chipLayout(nodeRoles(payload, member), card.w - 28, CHIP_LINES).forEach((chip) => {
drawChip(group, chip, 14 + chip.x, 54 + chip.line * (CHIP_HEIGHT + 4));
});
drawBar(group, 104, card.w, "CPU", stats.cpu);
drawBar(group, 120, card.w, "RAM", stats.ram);
drawBar(group, 136, card.w, "DISK", stats.disk);
group.appendChild(svg("text", { class: "t-foot", x: 14, y: card.h - 8 }, shorten(stats.footer, 56)));
parent.appendChild(group);
};
const drawCenter = (parent, center) => {
const group = svg("g", { class: "center", transform: "translate(" + center.x + "," + center.y + ")" });
group.appendChild(svg("rect", { class: "center-box" + (center.unmet.length ? " bad" : ""), width: center.w, height: center.h, rx: 12 }));
group.appendChild(svg("text", { class: "t-label center-title", x: center.w / 2, y: 20 }, center.unmet.length ? "UNMET ROLES" : "ALL ROLES HELD"));
center.rows.forEach((name, index) => {
const chip = { text: shorten(name, 18), kind: name.startsWith("+") ? "more" : "unmet", w: center.w - 28 };
drawChip(group, chip, 14, 30 + index * CENTER_ROW);
});
parent.appendChild(group);
};
const drawGraph = (payload, narrow) => {
const graph = document.getElementById("graph");
const geometry = layout(payload, narrow);
graph.setAttribute("viewBox", "0 0 " + geometry.width + " " + geometry.height);
graph.style.maxWidth = Math.round(geometry.width) + "px";
graph.replaceChildren();
geometry.links.forEach((link) => {
graph.appendChild(svg("line", { class: "link " + link.kind, x1: link.x1, y1: link.y1, x2: link.x2, y2: link.y2 }));
});
if (geometry.center) drawCenter(graph, geometry.center);
geometry.cards.forEach((card) => drawCard(graph, card, payload));
};
const drawHeader = (payload) => {
document.getElementById("summary").textContent = summarize(payload);
const box = document.getElementById("alerts");
box.replaceChildren();
alerts(payload).forEach((alert) => box.appendChild(html("span", alert.text, "alert " + alert.level)));
};
let latest = null;
let narrow = window.innerWidth < NARROW_BELOW;
const render = () => {
if (!latest) return;
drawHeader(latest);
drawGraph(latest, narrow);
document.getElementById("error").hidden = true;
document.getElementById("updated").textContent = "Updated " + new Date().toLocaleTimeString() + " · refreshing every " + REFRESH_MS / 1000 + "s";
};
window.addEventListener("resize", () => {
const now = window.innerWidth < NARROW_BELOW;
if (now !== narrow) {
narrow = now;
render();
}
});
let busy = false;
const refresh = async () => {
if (busy) return;
busy = true;
try {
const response = await fetch("/api/status", { cache: "no-store" });
if (!response.ok) throw new Error("the node answered with HTTP " + response.status);
latest = await response.json();
render();
} catch (error) {
const banner = document.getElementById("error");
banner.textContent = "Cannot reach the node: " + error.message + ". Retrying...";
banner.hidden = false;
} finally {
busy = false;
}
};
refresh();
setInterval(refresh, REFRESH_MS);
}
if (typeof module !== "undefined") {
module.exports = { address, roleStates, layout, nodeRoles, chipLayout, chipWidth, shorten, formatAge, formatSize, formatUptime, describeStats, barLevel, alerts, summarize };
}