Files
2026-05-31 16:07:30 +02:00

329 lines
8.9 KiB
Plaintext

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Node Balancer</title>
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
}
body { font-family: sans-serif; margin: 20px; }
svg { border:1px solid #ccc; width:100%; background:#f9f9f9; }
.node { fill:#4CAF50; stroke:#333; stroke-width:2; }
.pod { fill:#2196F3; stroke:#000; stroke-width:1; }
.label { font-size:14px; text-anchor:middle; dominant-baseline:middle; }
.pod-label { font-size:14px; text-anchor:middle; fill:#fff; }
header {
display: flex;
align-items: center;
margin-bottom: 20px;
background-color: #f0f0f0;
padding: 10px 15px;
border-radius: 8px;
}
header .logo {
font-weight: bold;
font-size: 18px;
color: #2196F3;
margin-right: 15px;
padding: 5px 10px;
background-color: #333;
color: #fff;
border-radius: 4px;
font-family: monospace;
}
header .title {
font-size: 18px;
}
header .title span {
margin-left: 10px;
color: #555;
font-weight: normal;
}
header .cluster-label {
margin-left: 20px;
color: #333;
font-weight: normal;
}
</style>
</head>
<body>
<header>
<div class="logo">allarddcs</div>
<div class="title">
Node Balancer <span id="version">v?</span>
<span class="cluster-label">Cluster: <span id="cluster-name">?</span></span>
</div>
</header>
<svg id="canvas"></svg>
<script>
const svg = document.getElementById("canvas");
const nodePositions = {};
const nodeY = 50;
const nodeWidth = 120;
const nodeHeight = 55;
const podHeight = 36;
const podSpacing = 6;
const textPaddingY = 6;
let animationDuration = 2000;
const podsPerNode = {};
// Truncate pod names (remove 1-2 trailing hashes)
function truncatePodName(podName){
const parts = podName.split('-');
const hashRegex = /^[a-z0-9]+$/i;
// Remove last segment if it looks like a hash
if(parts.length > 1 && parts[parts.length - 1].match(hashRegex) && parts[parts.length - 1].length >= 5){
parts.pop();
}
// Remove second last segment if it also looks like a hash
if(parts.length > 1 && parts[parts.length - 1].match(hashRegex) && parts[parts.length - 1].length >= 5){
parts.pop();
}
return parts.join('-');
}
// Fetch config
fetch("/api/config")
.then(res => res.json())
.then(cfg => {
animationDuration = cfg.animationDurationMs;
document.getElementById("version").textContent = "v" + cfg.version;
document.getElementById("cluster-name").textContent = cfg.clusterName;
});
// Add a node to DOM
function addNode(name, i){
const x = 50 + i*220;
nodePositions[name] = {x: x + nodeWidth/2, y: nodeY + nodeHeight/2};
// Node box
const rect = document.createElementNS("http://www.w3.org/2000/svg","rect");
rect.setAttribute("x", x);
rect.setAttribute("y", nodeY);
rect.setAttribute("width", nodeWidth);
rect.setAttribute("height", nodeHeight);
rect.setAttribute("class","node");
svg.appendChild(rect);
// Node label
const label = document.createElementNS("http://www.w3.org/2000/svg","text");
label.setAttribute("x", x + nodeWidth/2);
label.setAttribute("y", nodeY + nodeHeight/2);
label.setAttribute("class","label");
label.setAttribute("text-anchor","middle");
label.setAttribute("dominant-baseline","middle");
label.textContent = name;
svg.appendChild(label);
podsPerNode[name] = [];
}
// Remove a node from DOM
function removeNode(name){
delete nodePositions[name];
// Remove pods
if(podsPerNode[name]){
podsPerNode[name].forEach(g => g.remove());
delete podsPerNode[name];
}
// Remove node rect and label
const rects = svg.querySelectorAll("rect.node");
rects.forEach(r => {
if(r.nextSibling && r.nextSibling.textContent === name){
r.remove();
}
});
const labels = svg.querySelectorAll("text.label");
labels.forEach(t => {
if(t.textContent === name) t.remove();
});
}
function refreshNodes(){
fetch("/api/nodes")
.then(res => res.json())
.then(nodeNames => {
// Remove existing node visuals
svg.querySelectorAll("rect.node").forEach(n => n.remove());
svg.querySelectorAll("text.label").forEach(n => n.remove());
// Reset node state
for(const k in nodePositions){
delete nodePositions[k];
}
// Rebuild nodes from cluster state
nodeNames.forEach((name, i) => {
addNode(name, i);
});
});
}
// Initial node fetch
refreshNodes();
// Refresh every 5s
setInterval(refreshNodes, 5000);
// Draw pods
fetch("/api/pods")
.then(res => res.json())
.then(podsByNode => {
for(const nodeName in podsByNode){
podsByNode[nodeName].forEach(podName => addPod(podName, nodeName));
}
});
// Add a pod
function addPod(podName, nodeName){
if(!nodePositions[nodeName]) return;
const stack = podsPerNode[nodeName] || [];
const index = stack.length;
const x = nodePositions[nodeName].x - nodeWidth/2;
const y = nodeY + nodeHeight + index*(podHeight + podSpacing);
const g = document.createElementNS("http://www.w3.org/2000/svg","g");
g.dataset.podName = podName;
const rect = document.createElementNS("http://www.w3.org/2000/svg","rect");
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", nodeWidth);
rect.setAttribute("height", podHeight);
rect.setAttribute("class","pod");
g.appendChild(rect);
// Pod text
const text = document.createElementNS("http://www.w3.org/2000/svg","text");
text.setAttribute("class","pod-label");
text.setAttribute("text-anchor","middle");
text.setAttribute("x", x + nodeWidth/2);
text.setAttribute("y", y);
g.appendChild(text);
const truncatedName = truncatePodName(podName);
const maxCharsPerLine = 16;
for(let i = 0; i < truncatedName.length; i += maxCharsPerLine){
const tspan = document.createElementNS("http://www.w3.org/2000/svg","tspan");
tspan.setAttribute("x", x + nodeWidth/2);
tspan.setAttribute("dy", i===0 ? textPaddingY + 10 : 14);
tspan.textContent = truncatedName.substr(i, maxCharsPerLine);
text.appendChild(tspan);
}
svg.appendChild(g);
stack.push(g);
podsPerNode[nodeName] = stack;
updateSVGHeight();
}
// Update SVG height
function updateSVGHeight(){
let maxY = nodeY + nodeHeight;
for(const nodeName in podsPerNode){
const stack = podsPerNode[nodeName];
if(!stack || stack.length === 0) continue;
const bottomY = nodeY + nodeHeight + stack.length*(podHeight + podSpacing);
if(bottomY > maxY) maxY = bottomY;
}
svg.setAttribute("height", maxY + 20);
}
// Animate pod move
function flyPod(podName, fromNode, toNode){
if(!fromNode || !toNode) return;
const fromStack = podsPerNode[fromNode];
const idx = fromStack.findIndex(g => g.dataset.podName === podName);
if(idx === -1) return;
const g = fromStack[idx];
fromStack.splice(idx,1);
redrawPods(fromNode);
svg.appendChild(g);
const rect = g.querySelector("rect");
const text = g.querySelector("text");
const startX = parseFloat(rect.getAttribute("x"));
const startY = parseFloat(rect.getAttribute("y"));
const endX = nodePositions[toNode].x - nodeWidth/2;
const endY = nodeY + nodeHeight + podsPerNode[toNode].length*(podHeight+podSpacing);
let progress = 0;
const steps = 60;
const dx = (endX - startX)/steps;
const dy = (endY - startY)/steps;
const anim = setInterval(()=>{
progress++;
const nx = startX + dx*progress;
const ny = startY + dy*progress;
rect.setAttribute("x", nx);
rect.setAttribute("y", ny);
text.setAttribute("x", nx + nodeWidth/2);
if(progress>=steps){
clearInterval(anim);
podsPerNode[toNode].push(g);
redrawPods(toNode);
}
}, animationDuration/steps);
}
// Redraw pods stack on node
function redrawPods(nodeName){
if(!podsPerNode[nodeName]) return;
podsPerNode[nodeName].forEach((g,idx)=>{
const rect = g.querySelector("rect");
const text = g.querySelector("text");
const x = nodePositions[nodeName].x - nodeWidth/2;
const y = nodeY + nodeHeight + idx*(podHeight+podSpacing);
rect.setAttribute("x", x);
rect.setAttribute("y", y);
text.setAttribute("x", x + nodeWidth/2);
text.setAttribute("y", y);
});
updateSVGHeight();
}
// SSE stream
const evtSource = new EventSource("/api/stream");
evtSource.onmessage = e => {
const evt = JSON.parse(e.data);
if(evt.fromNode === evt.toNode){
redrawPods(evt.toNode);
return;
}
if(!evt.fromNode){
addPod(evt.pod, evt.toNode);
redrawPods(evt.toNode);
} else {
flyPod(evt.pod, evt.fromNode, evt.toNode);
}
};
</script>
</body>
</html>