Files
colony/colony/core/roles/webview/static/app.js
T
2026-09-19 22:22:22 +02:00

409 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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.on_demand) {
state = holders.length ? "held" : "off";
} else 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 onDemandHint(payload) {
const off = roleStates(payload).filter((role) => role.state === "off").map((role) => role.name);
if (!off.length) return "";
return "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0];
}
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" && role.state !== "off");
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)));
document.getElementById("ondemand").textContent = onDemandHint(payload);
};
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, onDemandHint };
}