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

351 lines
9.5 KiB
Plaintext
Raw Permalink 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.
<!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");
// Node/pod layout constants
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 = {};
// 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;
});
// Remove trailing k8s-style hash if present
function truncatePodName(podName) {
const parts = podName.split("-");
function isPodId(seg) {
// Pod suffix like "9tl49"
return /^[a-z0-9]{5}$/.test(seg);
}
function isReplicaSetHash(seg) {
// ReplicaSet hash typically 810 hex chars
return /^[a-f0-9]{8,10}$/.test(seg);
}
if (parts.length > 1 && isPodId(parts[parts.length - 1])) {
parts.pop();
}
if (parts.length > 1 && isReplicaSetHash(parts[parts.length - 1])) {
parts.pop();
}
return parts.join("-");
}
// Split pod name over multiple lines without breaking words
function splitPodNameLines(podName, maxCharsPerLine=12){
const words = podName.split("-");
const lines = [];
let currentLine = "";
words.forEach(word => {
if(currentLine.length === 0){
currentLine = word;
} else if((currentLine.length + 1 + word.length) <= maxCharsPerLine){
currentLine += "-" + word;
} else {
lines.push(currentLine);
currentLine = word;
}
});
if(currentLine) lines.push(currentLine);
return lines;
}
// Draw a node
function addNode(name, index){
const x = 50 + index*220;
nodePositions[name] = {x: x + nodeWidth/2, y: nodeY + nodeHeight/2};
// Node rectangle
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] = [];
}
// 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 label
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);
const truncated = truncatePodName(podName);
if(truncated.length <= 12){
text.setAttribute("y", y + podHeight/2 + textPaddingY/2);
text.setAttribute("dominant-baseline","middle");
text.textContent = truncated;
} else {
// split into two lines
const line1 = truncated.substring(0,12);
const line2 = truncated.substring(12);
text.setAttribute("y", y + textPaddingY + 6);
const t1 = document.createElementNS("http://www.w3.org/2000/svg","tspan");
t1.setAttribute("x", x + nodeWidth/2);
t1.textContent = line1;
const t2 = document.createElementNS("http://www.w3.org/2000/svg","tspan");
t2.setAttribute("x", x + nodeWidth/2);
t2.setAttribute("dy", "12");
t2.textContent = line2;
text.appendChild(t1);
text.appendChild(t2);
}
g.appendChild(text);
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);
text.setAttribute("y", ny + podHeight/2 + textPaddingY/2);
if(progress>=steps){
clearInterval(anim);
podsPerNode[toNode].push(g);
redrawPods(toNode);
}
}, animationDuration/steps);
}
// Redraw pods on a 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 + podHeight/2 + textPaddingY/2);
});
updateSVGHeight();
}
// Fetch authoritative cluster state and redraw
function refreshCluster(){
Promise.all([
fetch("/api/nodes").then(r=>r.json()),
fetch("/api/pods").then(r=>r.json())
]).then(([nodeNames, podsByNode])=>{
// clear SVG
while(svg.firstChild) svg.removeChild(svg.firstChild);
// reset state
for(const k in nodePositions) delete nodePositions[k];
for(const k in podsPerNode) delete podsPerNode[k];
// draw nodes
nodeNames.forEach((name,i)=>addNode(name,i));
// draw pods
for(const nodeName in podsByNode){
podsByNode[nodeName].forEach(podName=>{
addPod(podName,nodeName);
});
}
updateSVGHeight();
});
}
// SSE events
const evtSource = new EventSource("/api/stream");
evtSource.onmessage = e=>{
const evt = JSON.parse(e.data);
if(evt.fromNode && evt.toNode && evt.fromNode !== evt.toNode){
// animate first
flyPod(evt.pod, evt.fromNode, evt.toNode);
// then authoritative redraw after animation
setTimeout(refreshCluster, animationDuration);
} else {
// no animation needed
refreshCluster();
}
};
// Periodic refresh every 5s
setInterval(refreshCluster, 5000);
// Initial load
refreshCluster();
</script>
</body>
</html>