2026-07-28 12:05:24 +00:00
|
|
|
// Patch Three.js r128 Sprite raycast camera space vs world space bug
|
|
|
|
|
(function() {
|
|
|
|
|
const _worldScale = new THREE.Vector3();
|
|
|
|
|
const _mvPosition = new THREE.Vector3();
|
|
|
|
|
const _vA = new THREE.Vector3();
|
|
|
|
|
const _vB = new THREE.Vector3();
|
|
|
|
|
const _vC = new THREE.Vector3();
|
|
|
|
|
const _uvC = new THREE.Vector2();
|
|
|
|
|
const _intersectPoint = new THREE.Vector3();
|
|
|
|
|
const _modelViewMatrix = new THREE.Matrix4();
|
|
|
|
|
|
|
|
|
|
THREE.Sprite.prototype.raycast = function (raycaster, intersects) {
|
|
|
|
|
if (!raycaster.camera) return;
|
|
|
|
|
|
|
|
|
|
_worldScale.setFromMatrixScale(this.matrixWorld);
|
|
|
|
|
_modelViewMatrix.multiplyMatrices(raycaster.camera.matrixWorldInverse, this.matrixWorld);
|
|
|
|
|
_mvPosition.setFromMatrixPosition(_modelViewMatrix);
|
|
|
|
|
|
|
|
|
|
if (raycaster.camera.isPerspectiveCamera && this.material.sizeAttenuation === false) {
|
|
|
|
|
_worldScale.multiplyScalar(-_mvPosition.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rotation = this.material.rotation;
|
|
|
|
|
let sin = 0, cos = 1;
|
|
|
|
|
if (rotation !== 0) {
|
|
|
|
|
cos = Math.cos(rotation);
|
|
|
|
|
sin = Math.sin(rotation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const center = this.center;
|
|
|
|
|
|
|
|
|
|
const transformVertex = (v, mvPosition, center, scale, sin, cos) => {
|
|
|
|
|
v.x -= center.x;
|
|
|
|
|
v.y -= center.y;
|
|
|
|
|
|
|
|
|
|
let rx = v.x * scale.x;
|
|
|
|
|
let ry = v.y * scale.y;
|
|
|
|
|
|
|
|
|
|
if (sin !== 0) {
|
|
|
|
|
const x = rx;
|
|
|
|
|
const y = ry;
|
|
|
|
|
rx = cos * x - sin * y;
|
|
|
|
|
ry = sin * x + cos * y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v.x = rx + mvPosition.x;
|
|
|
|
|
v.y = ry + mvPosition.y;
|
|
|
|
|
v.z = mvPosition.z;
|
|
|
|
|
|
|
|
|
|
// Transform from Camera space to World space
|
|
|
|
|
v.applyMatrix4(raycaster.camera.matrixWorld);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
transformVertex(_vA.set(-0.5, -0.5, 0), _mvPosition, center, _worldScale, sin, cos);
|
|
|
|
|
transformVertex(_vB.set(0.5, -0.5, 0), _mvPosition, center, _worldScale, sin, cos);
|
|
|
|
|
transformVertex(_vC.set(0.5, 0.5, 0), _mvPosition, center, _worldScale, sin, cos);
|
|
|
|
|
|
|
|
|
|
let intersect = raycaster.ray.intersectTriangle(_vA, _vB, _vC, false, _intersectPoint);
|
|
|
|
|
|
|
|
|
|
if (intersect === null) {
|
|
|
|
|
transformVertex(_vB.set(-0.5, 0.5, 0), _mvPosition, center, _worldScale, sin, cos);
|
|
|
|
|
intersect = raycaster.ray.intersectTriangle(_vA, _vC, _vB, false, _intersectPoint);
|
|
|
|
|
if (intersect === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const distance = raycaster.ray.origin.distanceTo(_intersectPoint);
|
|
|
|
|
if (distance < raycaster.near || distance > raycaster.far) return;
|
|
|
|
|
|
|
|
|
|
intersects.push({
|
|
|
|
|
distance: distance,
|
|
|
|
|
point: _intersectPoint.clone(),
|
|
|
|
|
uv: _uvC.clone(),
|
|
|
|
|
face: null,
|
|
|
|
|
object: this
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
let currentMetric = 'cpu';
|
|
|
|
|
let clusterData = null;
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
// Three.js Core Variables
|
|
|
|
|
let scene, camera, renderer, controls;
|
|
|
|
|
let podMeshes = [];
|
|
|
|
|
let nodeMeshes = [];
|
|
|
|
|
let raycaster = new THREE.Raycaster();
|
|
|
|
|
let mouse = new THREE.Vector2();
|
|
|
|
|
let hoveredPod = null;
|
|
|
|
|
|
|
|
|
|
let maxCpuInCluster = 1;
|
|
|
|
|
let maxMemInCluster = 1;
|
|
|
|
|
let autoRotateEnabled = false;
|
|
|
|
|
|
|
|
|
|
// Formatters
|
2026-07-24 00:15:19 +00:00
|
|
|
const formatBytes = (bytes) => {
|
2026-07-24 12:57:12 +00:00
|
|
|
if (!bytes || bytes === 0) return '0 B';
|
2026-07-24 00:15:19 +00:00
|
|
|
const k = 1024;
|
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatCpu = (millicores) => {
|
2026-07-24 12:57:12 +00:00
|
|
|
if (!millicores || millicores === 0) return '0 m';
|
2026-07-24 00:15:19 +00:00
|
|
|
return `${millicores} m`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
// UI Controls Setup
|
|
|
|
|
document.getElementById('btn-cpu').addEventListener('click', () => {
|
2026-07-24 00:15:19 +00:00
|
|
|
document.getElementById('btn-cpu').classList.add('active');
|
|
|
|
|
document.getElementById('btn-mem').classList.remove('active');
|
|
|
|
|
currentMetric = 'cpu';
|
2026-07-24 12:57:12 +00:00
|
|
|
updatePodHeights();
|
2026-07-24 00:15:19 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
document.getElementById('btn-mem').addEventListener('click', () => {
|
2026-07-24 00:15:19 +00:00
|
|
|
document.getElementById('btn-mem').classList.add('active');
|
|
|
|
|
document.getElementById('btn-cpu').classList.remove('active');
|
|
|
|
|
currentMetric = 'memory';
|
2026-07-24 12:57:12 +00:00
|
|
|
updatePodHeights();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('btn-reset-cam').addEventListener('click', () => {
|
|
|
|
|
resetCameraView();
|
2026-07-24 00:15:19 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
document.getElementById('btn-auto-rotate').addEventListener('click', () => {
|
|
|
|
|
autoRotateEnabled = !autoRotateEnabled;
|
|
|
|
|
controls.autoRotate = autoRotateEnabled;
|
|
|
|
|
const btn = document.getElementById('btn-auto-rotate');
|
|
|
|
|
const textSpan = document.getElementById('auto-rotate-text');
|
|
|
|
|
if (autoRotateEnabled) {
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
textSpan.innerText = 'Rotating...';
|
|
|
|
|
} else {
|
|
|
|
|
btn.classList.remove('active');
|
|
|
|
|
textSpan.innerText = 'Auto Rotate';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Initialize 3D Engine
|
|
|
|
|
function init3D() {
|
|
|
|
|
const container = document.getElementById('chart-container');
|
|
|
|
|
const width = container.clientWidth;
|
|
|
|
|
const height = container.clientHeight;
|
|
|
|
|
|
|
|
|
|
// Scene
|
|
|
|
|
scene = new THREE.Scene();
|
|
|
|
|
scene.background = new THREE.Color(0x090d16);
|
|
|
|
|
scene.fog = new THREE.FogExp2(0x090d16, 0.0012);
|
|
|
|
|
|
|
|
|
|
// Camera
|
|
|
|
|
camera = new THREE.PerspectiveCamera(45, width / height, 1, 2000);
|
|
|
|
|
camera.position.set(0, 140, 210);
|
|
|
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
|
|
|
|
|
|
// Renderer
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
|
|
|
renderer.setSize(width, height);
|
|
|
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
|
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
|
|
|
|
|
|
|
|
// Clear previous elements if any
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
container.appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
// Lights
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.65);
|
|
|
|
|
scene.add(ambientLight);
|
|
|
|
|
|
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.85);
|
|
|
|
|
dirLight.position.set(120, 220, 120);
|
|
|
|
|
dirLight.castShadow = true;
|
|
|
|
|
dirLight.shadow.mapSize.width = 2048;
|
|
|
|
|
dirLight.shadow.mapSize.height = 2048;
|
|
|
|
|
scene.add(dirLight);
|
|
|
|
|
|
|
|
|
|
const bluePointLight = new THREE.PointLight(0x3b82f6, 1.2, 600);
|
|
|
|
|
bluePointLight.position.set(-150, 150, -150);
|
|
|
|
|
scene.add(bluePointLight);
|
|
|
|
|
|
|
|
|
|
const purplePointLight = new THREE.PointLight(0x8b5cf6, 1.0, 600);
|
|
|
|
|
purplePointLight.position.set(150, 150, 150);
|
|
|
|
|
scene.add(purplePointLight);
|
|
|
|
|
|
|
|
|
|
// Grid Floor
|
|
|
|
|
const gridHelper = new THREE.GridHelper(600, 60, 0x3b82f6, 0x1e293b);
|
|
|
|
|
gridHelper.position.y = -0.5;
|
|
|
|
|
scene.add(gridHelper);
|
|
|
|
|
|
|
|
|
|
// Orbit Controls
|
|
|
|
|
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
|
|
|
controls.enableDamping = true;
|
|
|
|
|
controls.dampingFactor = 0.05;
|
|
|
|
|
controls.maxPolarAngle = Math.PI / 2 - 0.02; // Don't go below ground
|
|
|
|
|
controls.minDistance = 20;
|
|
|
|
|
controls.maxDistance = 800;
|
|
|
|
|
controls.autoRotate = autoRotateEnabled;
|
|
|
|
|
controls.autoRotateSpeed = 1.0;
|
|
|
|
|
|
|
|
|
|
// Events
|
|
|
|
|
window.addEventListener('resize', onWindowResize);
|
|
|
|
|
container.addEventListener('mousemove', onPointerMove);
|
|
|
|
|
container.addEventListener('click', onPointerClick);
|
|
|
|
|
|
|
|
|
|
// Start animation loop
|
|
|
|
|
animate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetCameraView() {
|
|
|
|
|
let startPos = camera.position.clone();
|
|
|
|
|
let targetPos = new THREE.Vector3(0, 140, 210);
|
|
|
|
|
let startLookAt = controls.target.clone();
|
|
|
|
|
let targetLookAt = new THREE.Vector3(0, 0, 0);
|
|
|
|
|
|
|
|
|
|
let duration = 600;
|
|
|
|
|
let startTime = performance.now();
|
|
|
|
|
|
|
|
|
|
function step(now) {
|
|
|
|
|
let progress = Math.min((now - startTime) / duration, 1);
|
|
|
|
|
// Ease out cubic
|
|
|
|
|
let ease = 1 - Math.pow(1 - progress, 3);
|
|
|
|
|
|
|
|
|
|
camera.position.lerpVectors(startPos, targetPos, ease);
|
|
|
|
|
controls.target.lerpVectors(startLookAt, targetLookAt, ease);
|
|
|
|
|
controls.update();
|
|
|
|
|
|
|
|
|
|
if (progress < 1) {
|
|
|
|
|
requestAnimationFrame(step);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(step);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onWindowResize() {
|
|
|
|
|
const container = document.getElementById('chart-container');
|
|
|
|
|
if (!container || !renderer || !camera) return;
|
|
|
|
|
|
|
|
|
|
const width = container.clientWidth;
|
|
|
|
|
const height = container.clientHeight;
|
|
|
|
|
|
|
|
|
|
camera.aspect = width / height;
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
|
|
|
|
|
renderer.setSize(width, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch Cluster Data
|
2026-07-24 00:15:19 +00:00
|
|
|
async function fetchData() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/cluster-data');
|
2026-07-24 12:57:12 +00:00
|
|
|
if (!response.ok) throw new Error('Failed to fetch cluster data');
|
2026-07-24 00:15:19 +00:00
|
|
|
clusterData = await response.json();
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
document.getElementById('loading').classList.add('hidden');
|
2026-07-24 12:57:12 +00:00
|
|
|
|
|
|
|
|
if (!scene) {
|
|
|
|
|
init3D();
|
|
|
|
|
}
|
|
|
|
|
build3DCluster(clusterData);
|
2026-07-24 00:15:19 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
2026-07-24 12:57:12 +00:00
|
|
|
document.getElementById('loading').innerHTML = `<p style="color: #ef4444; font-weight: 600;">Error loading cluster data: ${error.message}</p>`;
|
2026-07-24 00:15:19 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
// Helper to create 3D Canvas Text Sprite for Node Labels
|
2026-07-28 12:05:24 +00:00
|
|
|
function createTextSprite(text, fontSize = 28, textColor = '#f8fafc', bgColor = 'rgba(15, 23, 42, 0.85)', isBold = false, isControlPlane = false) {
|
2026-07-24 12:57:12 +00:00
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
2026-07-28 12:05:24 +00:00
|
|
|
|
|
|
|
|
// Allocate higher resolution canvas for crisp text
|
|
|
|
|
canvas.width = isControlPlane ? 640 : 512;
|
|
|
|
|
canvas.height = isControlPlane ? 160 : 128;
|
|
|
|
|
|
|
|
|
|
const pad = 16;
|
|
|
|
|
const w = canvas.width - pad * 2;
|
|
|
|
|
const h = canvas.height - pad * 2;
|
2026-07-24 12:57:12 +00:00
|
|
|
|
|
|
|
|
// Pill background
|
|
|
|
|
ctx.fillStyle = bgColor;
|
2026-07-28 12:05:24 +00:00
|
|
|
ctx.roundRect(pad, pad, w, h, isControlPlane ? 32 : 24);
|
2026-07-24 12:57:12 +00:00
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
// Border
|
2026-07-28 12:05:24 +00:00
|
|
|
ctx.strokeStyle = isControlPlane ? 'rgba(168, 85, 247, 0.95)' : 'rgba(59, 130, 246, 0.6)';
|
|
|
|
|
ctx.lineWidth = isControlPlane ? 8 : 6;
|
2026-07-24 12:57:12 +00:00
|
|
|
ctx.stroke();
|
|
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
// Text - use 900 weight for control plane / master, 700 for regular
|
|
|
|
|
const fontWeight = isControlPlane ? '900' : (isBold ? '800' : '700');
|
|
|
|
|
ctx.font = `${fontWeight} ${fontSize}px Inter, sans-serif`;
|
2026-07-24 12:57:12 +00:00
|
|
|
ctx.fillStyle = textColor;
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
ctx.textBaseline = 'middle';
|
|
|
|
|
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
|
|
|
|
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
|
|
|
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
|
|
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
2026-07-28 12:05:24 +00:00
|
|
|
|
|
|
|
|
// Differentiate scale: control plane sprite is larger
|
|
|
|
|
if (isControlPlane) {
|
|
|
|
|
sprite.scale.set(58, 14.5, 1);
|
|
|
|
|
} else {
|
|
|
|
|
sprite.scale.set(45, 11.25, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add invisible Mesh plane for 100% reliable raycasting
|
|
|
|
|
const hitGeo = new THREE.PlaneGeometry(1, 1);
|
|
|
|
|
const hitMat = new THREE.MeshBasicMaterial({ visible: false, depthWrite: false });
|
|
|
|
|
const hitMesh = new THREE.Mesh(hitGeo, hitMat);
|
|
|
|
|
hitMesh.name = 'labelHitMesh';
|
|
|
|
|
sprite.add(hitMesh);
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
return sprite;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build 3D Topology from Data
|
|
|
|
|
function build3DCluster(data) {
|
|
|
|
|
// Clear existing node & pod meshes
|
|
|
|
|
podMeshes.forEach(mesh => {
|
|
|
|
|
scene.remove(mesh);
|
|
|
|
|
mesh.geometry.dispose();
|
|
|
|
|
if (mesh.material) mesh.material.dispose();
|
|
|
|
|
});
|
|
|
|
|
podMeshes = [];
|
|
|
|
|
|
|
|
|
|
nodeMeshes.forEach(obj => {
|
|
|
|
|
scene.remove(obj);
|
|
|
|
|
if (obj.geometry) obj.geometry.dispose();
|
|
|
|
|
if (obj.material) obj.material.dispose();
|
|
|
|
|
});
|
|
|
|
|
nodeMeshes = [];
|
|
|
|
|
|
|
|
|
|
if (!data || !data.children || data.children.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// 1. Compute cluster max metrics for scaling height
|
|
|
|
|
maxCpuInCluster = 1;
|
|
|
|
|
maxMemInCluster = 1;
|
|
|
|
|
|
|
|
|
|
data.children.forEach(node => {
|
|
|
|
|
if (node.children) {
|
|
|
|
|
node.children.forEach(pod => {
|
|
|
|
|
if (pod.type === 'pod') {
|
|
|
|
|
if (pod.cpu_raw > maxCpuInCluster) maxCpuInCluster = pod.cpu_raw;
|
|
|
|
|
if (pod.memory_raw > maxMemInCluster) maxMemInCluster = pod.memory_raw;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Layout on X-Z floor using D3 Treemap
|
|
|
|
|
const BOUNDS_SIZE = 220; // 3D world dimension for ground layout
|
2026-07-24 00:15:19 +00:00
|
|
|
|
|
|
|
|
const root = d3.hierarchy(data)
|
|
|
|
|
.sum(d => {
|
|
|
|
|
if (d.type === 'node') return 0;
|
2026-07-24 12:57:12 +00:00
|
|
|
// Ensure minimum footprint for every pod block
|
|
|
|
|
return Math.max(d.cpu_raw || 0, d.memory_raw ? d.memory_raw / 1000000 : 0, 15);
|
2026-07-24 00:15:19 +00:00
|
|
|
})
|
|
|
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
|
|
|
|
|
|
d3.treemap()
|
2026-07-24 12:57:12 +00:00
|
|
|
.size([BOUNDS_SIZE, BOUNDS_SIZE])
|
|
|
|
|
.paddingTop(36)
|
|
|
|
|
.paddingRight(10)
|
|
|
|
|
.paddingInner(6)
|
|
|
|
|
.paddingBottom(10)
|
|
|
|
|
.paddingLeft(10)(root);
|
|
|
|
|
|
|
|
|
|
// 3. Render Nodes and Pods
|
|
|
|
|
const nodes = root.descendants().filter(d => d.depth === 1);
|
|
|
|
|
const pods = root.leaves();
|
|
|
|
|
|
|
|
|
|
// Render Node Platforms
|
|
|
|
|
nodes.forEach(n => {
|
|
|
|
|
const nw = Math.max(10, n.x1 - n.x0);
|
|
|
|
|
const nh = Math.max(10, n.y1 - n.y0);
|
|
|
|
|
const centerX = (n.x0 + n.x1) / 2 - BOUNDS_SIZE / 2;
|
|
|
|
|
const centerZ = (n.y0 + n.y1) / 2 - BOUNDS_SIZE / 2;
|
|
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
const isControlPlane = n.data.isControlPlane || n.data.is_control_plane ||
|
|
|
|
|
(n.data.roles && (n.data.roles.includes('control-plane') || n.data.roles.includes('master'))) ||
|
|
|
|
|
n.data.name.includes('control-plane') || n.data.name.includes('master');
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
const platformGeo = new THREE.BoxGeometry(nw, 1.5, nh);
|
|
|
|
|
const platformMat = new THREE.MeshStandardMaterial({
|
2026-07-28 12:05:24 +00:00
|
|
|
color: isControlPlane ? 0x1e1b4b : 0x1e293b,
|
2026-07-24 12:57:12 +00:00
|
|
|
roughness: 0.5,
|
|
|
|
|
metalness: 0.3,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.95
|
2026-07-24 00:15:19 +00:00
|
|
|
});
|
2026-07-24 12:57:12 +00:00
|
|
|
const platformMesh = new THREE.Mesh(platformGeo, platformMat);
|
|
|
|
|
platformMesh.position.set(centerX, 0.75, centerZ);
|
|
|
|
|
platformMesh.receiveShadow = true;
|
2026-07-24 00:15:19 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
const nodeUserData = {
|
|
|
|
|
type: 'node',
|
|
|
|
|
name: n.data.name,
|
|
|
|
|
isControlPlane: isControlPlane,
|
|
|
|
|
roles: n.data.roles || (isControlPlane ? ['control-plane'] : ['worker']),
|
|
|
|
|
internalIp: n.data.internalIp || n.data.internal_ip || 'N/A',
|
2026-07-29 10:40:29 +00:00
|
|
|
osImage: n.data.osImage || n.data.os_image || 'N/A',
|
|
|
|
|
nonTerminatedPods: n.data.nonTerminatedPods ?? n.data.non_terminated_pods ?? n.data.podCount ?? n.data.pod_count ?? (n.data.children ? n.data.children.filter(c => c.type === 'pod' && c.status !== 'Succeeded' && c.status !== 'Failed').length : 0)
|
2026-07-28 12:05:24 +00:00
|
|
|
};
|
|
|
|
|
platformMesh.userData = nodeUserData;
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
// Platform Border Frame
|
|
|
|
|
const edges = new THREE.EdgesGeometry(platformGeo);
|
2026-07-28 12:05:24 +00:00
|
|
|
const frameColor = isControlPlane ? 0xa855f7 : 0x3b82f6;
|
|
|
|
|
const lineMat = new THREE.LineBasicMaterial({ color: frameColor, linewidth: isControlPlane ? 3 : 2 });
|
2026-07-24 12:57:12 +00:00
|
|
|
const wireframe = new THREE.LineSegments(edges, lineMat);
|
|
|
|
|
platformMesh.add(wireframe);
|
|
|
|
|
|
|
|
|
|
scene.add(platformMesh);
|
|
|
|
|
nodeMeshes.push(platformMesh);
|
|
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
// Node Label Sprite (Larger, bold font for control-plane/master nodes)
|
|
|
|
|
const fontSize = isControlPlane ? 34 : 24;
|
|
|
|
|
const textColor = isControlPlane ? '#f3e8ff' : '#f8fafc';
|
|
|
|
|
const bgColor = isControlPlane ? 'rgba(46, 16, 101, 0.92)' : 'rgba(15, 23, 42, 0.85)';
|
|
|
|
|
|
|
|
|
|
const sprite = createTextSprite(n.data.name, fontSize, textColor, bgColor, true, isControlPlane);
|
|
|
|
|
sprite.position.set(centerX, isControlPlane ? 16 : 14, centerZ - nh / 2 - 2);
|
|
|
|
|
sprite.userData = nodeUserData;
|
|
|
|
|
const labelHit = sprite.getObjectByName('labelHitMesh');
|
|
|
|
|
if (labelHit) {
|
|
|
|
|
labelHit.userData = nodeUserData;
|
|
|
|
|
}
|
2026-07-24 12:57:12 +00:00
|
|
|
scene.add(sprite);
|
|
|
|
|
nodeMeshes.push(sprite);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Render Pod Towers
|
|
|
|
|
pods.forEach(p => {
|
|
|
|
|
const pw = Math.max(2, (p.x1 - p.x0) * 0.88);
|
|
|
|
|
const pd = Math.max(2, (p.y1 - p.y0) * 0.88);
|
|
|
|
|
const centerX = (p.x0 + p.x1) / 2 - BOUNDS_SIZE / 2;
|
|
|
|
|
const centerZ = (p.y0 + p.y1) / 2 - BOUNDS_SIZE / 2;
|
|
|
|
|
|
|
|
|
|
// Calculate height
|
|
|
|
|
const targetHeight = getTargetHeight(p.data);
|
|
|
|
|
|
|
|
|
|
// Box geometry: 1 unit height base, scaled in animation loop
|
|
|
|
|
const podGeo = new THREE.BoxGeometry(pw, 1, pd);
|
|
|
|
|
|
|
|
|
|
// Color & Material based on Pod Status
|
|
|
|
|
let color = 0x10b981; // Running emerald
|
|
|
|
|
let emissive = 0x059669;
|
|
|
|
|
let opacity = 0.9;
|
|
|
|
|
let transparent = false;
|
|
|
|
|
|
|
|
|
|
if (p.data.type === 'unused') {
|
|
|
|
|
color = 0x64748b;
|
|
|
|
|
emissive = 0x334155;
|
|
|
|
|
opacity = 0.25;
|
|
|
|
|
transparent = true;
|
|
|
|
|
} else if (p.data.status === 'Pending') {
|
|
|
|
|
color = 0xf59e0b;
|
|
|
|
|
emissive = 0xd97706;
|
2026-07-28 11:44:04 +00:00
|
|
|
} else if (p.data.status === 'Succeeded') {
|
|
|
|
|
color = 0x94a3b8;
|
|
|
|
|
emissive = 0x475569;
|
2026-07-24 12:57:12 +00:00
|
|
|
} else if (p.data.status !== 'Running') {
|
|
|
|
|
color = 0xef4444;
|
|
|
|
|
emissive = 0xdc2626;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const podMat = new THREE.MeshStandardMaterial({
|
|
|
|
|
color: color,
|
|
|
|
|
emissive: emissive,
|
|
|
|
|
emissiveIntensity: p.data.type === 'unused' ? 0.05 : 0.25,
|
|
|
|
|
roughness: 0.3,
|
|
|
|
|
metalness: 0.4,
|
|
|
|
|
transparent: transparent,
|
|
|
|
|
opacity: opacity
|
2026-07-24 00:15:19 +00:00
|
|
|
});
|
2026-07-24 12:57:12 +00:00
|
|
|
|
|
|
|
|
const podMesh = new THREE.Mesh(podGeo, podMat);
|
|
|
|
|
podMesh.castShadow = true;
|
|
|
|
|
podMesh.receiveShadow = true;
|
|
|
|
|
|
|
|
|
|
// Initial positions & properties
|
|
|
|
|
podMesh.position.set(centerX, 1.5 + 0.1 / 2, centerZ);
|
|
|
|
|
podMesh.scale.set(1, 0.1, 1);
|
|
|
|
|
|
|
|
|
|
podMesh.userData = {
|
|
|
|
|
...p.data,
|
|
|
|
|
nodeName: p.parent ? p.parent.data.name : 'Unknown Node'
|
|
|
|
|
};
|
|
|
|
|
podMesh.targetHeight = targetHeight;
|
|
|
|
|
podMesh.currentHeight = 0.1;
|
|
|
|
|
podMesh.baseWidth = pw;
|
|
|
|
|
podMesh.baseDepth = pd;
|
|
|
|
|
podMesh.baseCenterX = centerX;
|
|
|
|
|
podMesh.baseCenterZ = centerZ;
|
|
|
|
|
podMesh.originalColor = color;
|
|
|
|
|
podMesh.originalEmissive = emissive;
|
|
|
|
|
podMesh.originalEmissiveIntensity = p.data.type === 'unused' ? 0.05 : 0.25;
|
|
|
|
|
|
|
|
|
|
scene.add(podMesh);
|
|
|
|
|
podMeshes.push(podMesh);
|
|
|
|
|
});
|
2026-07-24 00:15:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
// Calculate target 3D tower height based on current metric
|
|
|
|
|
function getTargetHeight(data) {
|
|
|
|
|
if (data.type === 'unused') return 0.5; // low plate for available capacity
|
|
|
|
|
|
|
|
|
|
const MAX_HEIGHT = 50; // Maximum tower height units
|
|
|
|
|
const MIN_HEIGHT = 1.5; // Base minimum height for visibility
|
|
|
|
|
|
|
|
|
|
if (currentMetric === 'cpu') {
|
|
|
|
|
const val = data.cpu_raw || 0;
|
|
|
|
|
return MIN_HEIGHT + (val / (maxCpuInCluster || 1)) * MAX_HEIGHT;
|
|
|
|
|
} else {
|
|
|
|
|
const val = data.memory_raw || 0;
|
|
|
|
|
return MIN_HEIGHT + (val / (maxMemInCluster || 1)) * MAX_HEIGHT;
|
2026-07-24 00:15:19 +00:00
|
|
|
}
|
2026-07-24 12:57:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update heights smoothly when toggling metric
|
|
|
|
|
function updatePodHeights() {
|
|
|
|
|
podMeshes.forEach(mesh => {
|
|
|
|
|
mesh.targetHeight = getTargetHeight(mesh.userData);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main Render & Animation Loop
|
|
|
|
|
function animate() {
|
|
|
|
|
requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
|
|
// Smoothly interpolate pod tower heights (lerp)
|
|
|
|
|
podMeshes.forEach(mesh => {
|
|
|
|
|
if (Math.abs(mesh.currentHeight - mesh.targetHeight) > 0.01) {
|
|
|
|
|
mesh.currentHeight += (mesh.targetHeight - mesh.currentHeight) * 0.1;
|
|
|
|
|
mesh.scale.y = mesh.currentHeight;
|
|
|
|
|
// Keep tower base resting on the node platform at y = 1.5
|
|
|
|
|
mesh.position.y = 1.5 + mesh.currentHeight / 2;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-24 00:15:19 +00:00
|
|
|
|
2026-07-24 12:57:12 +00:00
|
|
|
controls.update();
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mouse Pointer Raycasting & Hover Tooltip
|
|
|
|
|
function onPointerMove(event) {
|
|
|
|
|
const container = document.getElementById('chart-container');
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
mouse.x = ((event.clientX - rect.left) / container.clientWidth) * 2 - 1;
|
|
|
|
|
mouse.y = -((event.clientY - rect.top) / container.clientHeight) * 2 + 1;
|
|
|
|
|
|
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
2026-07-28 12:05:24 +00:00
|
|
|
const intersects = raycaster.intersectObjects([...podMeshes, ...nodeMeshes], true);
|
2026-07-24 12:57:12 +00:00
|
|
|
|
|
|
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
|
|
|
|
|
|
if (intersects.length > 0) {
|
|
|
|
|
const hitMesh = intersects[0].object;
|
2026-07-28 12:05:24 +00:00
|
|
|
const data = (hitMesh.userData && hitMesh.userData.type) ? hitMesh.userData : (hitMesh.parent && hitMesh.parent.userData ? hitMesh.parent.userData : {});
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
if (data && data.type) {
|
|
|
|
|
container.style.cursor = 'pointer';
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
if (hoveredPod !== hitMesh) {
|
|
|
|
|
// Reset previous hovered pod
|
|
|
|
|
if (hoveredPod && hoveredPod.userData && hoveredPod.userData.type !== 'node' && hoveredPod.material && hoveredPod.material.emissive) {
|
|
|
|
|
hoveredPod.material.emissive.setHex(hoveredPod.originalEmissive);
|
|
|
|
|
hoveredPod.material.emissiveIntensity = hoveredPod.originalEmissiveIntensity;
|
|
|
|
|
}
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
hoveredPod = hitMesh;
|
|
|
|
|
// Highlight hovered pod tower
|
|
|
|
|
if (hoveredPod.userData && hoveredPod.userData.type !== 'node' && hoveredPod.material && hoveredPod.material.emissive) {
|
|
|
|
|
hoveredPod.material.emissive.setHex(0x60a5fa);
|
|
|
|
|
hoveredPod.material.emissiveIntensity = 0.6;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
// Display Tooltip
|
|
|
|
|
tooltip.classList.remove('hidden');
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
// Boundary handling
|
|
|
|
|
const tWidth = 260;
|
|
|
|
|
let left = event.pageX + 15;
|
|
|
|
|
let top = event.pageY + 15;
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
if (left + tWidth > window.innerWidth) {
|
|
|
|
|
left = event.pageX - tWidth - 15;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tooltip.style.left = left + 'px';
|
|
|
|
|
tooltip.style.top = top + 'px';
|
|
|
|
|
|
|
|
|
|
if (data.type === 'node') {
|
|
|
|
|
const isCP = data.isControlPlane || (data.roles && (data.roles.includes('control-plane') || data.roles.includes('master')));
|
|
|
|
|
const badgeLabel = data.roles && data.roles.length ? data.roles.join(', ') : (isCP ? 'control-plane' : 'Node');
|
2026-07-29 10:40:29 +00:00
|
|
|
const podCount = data.nonTerminatedPods ?? data.non_terminated_pods ?? data.podCount ?? data.pod_count ?? 0;
|
2026-07-28 12:05:24 +00:00
|
|
|
tooltip.innerHTML = `
|
|
|
|
|
<div class="tooltip-title">
|
|
|
|
|
<span>${data.name}</span>
|
|
|
|
|
<span style="font-size: 0.7rem; background: ${isCP ? 'rgba(168, 85, 247, 0.35)' : 'rgba(59, 130, 246, 0.3)'}; color: ${isCP ? '#e9d5ff' : '#60a5fa'}; padding: 2px 6px; border-radius: 4px; font-weight: 700;">${badgeLabel}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Internal IP:</span>
|
|
|
|
|
<span class="tooltip-value">${data.internalIp || 'N/A'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">OS Image:</span>
|
|
|
|
|
<span class="tooltip-value">${data.osImage || 'N/A'}</span>
|
|
|
|
|
</div>
|
2026-07-29 10:40:29 +00:00
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Non-terminated Pods:</span>
|
|
|
|
|
<span class="tooltip-value">${podCount}</span>
|
|
|
|
|
</div>
|
2026-07-28 12:05:24 +00:00
|
|
|
`;
|
|
|
|
|
} else if (data.type === 'unused') {
|
|
|
|
|
const valCpu = formatCpu(data.cpu_raw);
|
|
|
|
|
const valMem = formatBytes(data.memory_raw);
|
|
|
|
|
tooltip.innerHTML = `
|
|
|
|
|
<div class="tooltip-title">
|
|
|
|
|
<span>Available Capacity</span>
|
|
|
|
|
<span class="legend-dot unused"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Node:</span>
|
|
|
|
|
<span class="tooltip-value">${data.nodeName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Unused CPU:</span>
|
|
|
|
|
<span class="tooltip-value">${valCpu}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Unused Memory:</span>
|
|
|
|
|
<span class="tooltip-value">${valMem}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
} else {
|
|
|
|
|
const valCpu = formatCpu(data.cpu_raw);
|
|
|
|
|
const valMem = formatBytes(data.memory_raw);
|
|
|
|
|
const statusColor = data.status === 'Running' ? '#10b981' : (data.status === 'Pending' ? '#f59e0b' : (data.status === 'Succeeded' ? '#94a3b8' : '#ef4444'));
|
|
|
|
|
tooltip.innerHTML = `
|
|
|
|
|
<div class="tooltip-title">
|
|
|
|
|
<span>${data.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Namespace:</span>
|
|
|
|
|
<span class="tooltip-value">${data.namespace}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Node:</span>
|
|
|
|
|
<span class="tooltip-value">${data.nodeName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Status:</span>
|
|
|
|
|
<span class="tooltip-value" style="color: ${statusColor}; font-weight: 700;">${data.status}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">CPU Usage:</span>
|
|
|
|
|
<span class="tooltip-value ${currentMetric === 'cpu' ? 'active-metric' : ''}">${valCpu}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row">
|
|
|
|
|
<span class="tooltip-label">Memory Usage:</span>
|
|
|
|
|
<span class="tooltip-value ${currentMetric === 'memory' ? 'active-metric' : ''}">${valMem}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
return;
|
2026-07-24 12:57:12 +00:00
|
|
|
}
|
2026-07-28 12:05:24 +00:00
|
|
|
}
|
2026-07-24 12:57:12 +00:00
|
|
|
|
2026-07-28 12:05:24 +00:00
|
|
|
container.style.cursor = 'grab';
|
|
|
|
|
if (hoveredPod) {
|
|
|
|
|
if (hoveredPod.userData && hoveredPod.userData.type !== 'node' && hoveredPod.material && hoveredPod.material.emissive) {
|
2026-07-24 12:57:12 +00:00
|
|
|
hoveredPod.material.emissive.setHex(hoveredPod.originalEmissive);
|
|
|
|
|
hoveredPod.material.emissiveIntensity = hoveredPod.originalEmissiveIntensity;
|
|
|
|
|
}
|
2026-07-28 12:05:24 +00:00
|
|
|
hoveredPod = null;
|
2026-07-24 12:57:12 +00:00
|
|
|
}
|
2026-07-28 12:05:24 +00:00
|
|
|
tooltip.classList.add('hidden');
|
2026-07-24 12:57:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Click to focus camera on selected Pod
|
|
|
|
|
function onPointerClick(event) {
|
2026-07-28 12:05:24 +00:00
|
|
|
if (!hoveredPod || hoveredPod.userData.type === 'node') return;
|
2026-07-24 12:57:12 +00:00
|
|
|
|
|
|
|
|
let targetPos = hoveredPod.position.clone();
|
|
|
|
|
|
|
|
|
|
let startLookAt = controls.target.clone();
|
|
|
|
|
let duration = 500;
|
|
|
|
|
let startTime = performance.now();
|
|
|
|
|
|
|
|
|
|
function step(now) {
|
|
|
|
|
let progress = Math.min((now - startTime) / duration, 1);
|
|
|
|
|
let ease = 1 - Math.pow(1 - progress, 3);
|
|
|
|
|
|
|
|
|
|
controls.target.lerpVectors(startLookAt, targetPos, ease);
|
|
|
|
|
controls.update();
|
|
|
|
|
|
|
|
|
|
if (progress < 1) {
|
|
|
|
|
requestAnimationFrame(step);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(step);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initial fetch and auto-polling every 30s
|
2026-07-24 00:15:19 +00:00
|
|
|
fetchData();
|
|
|
|
|
setInterval(fetchData, 30000);
|
2026-07-24 12:57:12 +00:00
|
|
|
|