351 lines
9.5 KiB
Plaintext
351 lines
9.5 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");
|
||
|
||
// 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 8–10 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>
|