0.5 - Now in 3D
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m10s
Details
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m10s
Details
This commit is contained in:
parent
c421e84eb1
commit
9e0d7d0726
586
static/app.js
586
static/app.js
|
|
@ -1,8 +1,21 @@
|
||||||
let currentMetric = 'cpu';
|
let currentMetric = 'cpu';
|
||||||
let clusterData = null;
|
let clusterData = null;
|
||||||
|
|
||||||
|
// 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
|
||||||
const formatBytes = (bytes) => {
|
const formatBytes = (bytes) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
@ -10,123 +23,437 @@ const formatBytes = (bytes) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCpu = (millicores) => {
|
const formatCpu = (millicores) => {
|
||||||
if (millicores === 0) return '0 m';
|
if (!millicores || millicores === 0) return '0 m';
|
||||||
return `${millicores} m`;
|
return `${millicores} m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('btn-cpu').addEventListener('click', (e) => {
|
// UI Controls Setup
|
||||||
|
document.getElementById('btn-cpu').addEventListener('click', () => {
|
||||||
document.getElementById('btn-cpu').classList.add('active');
|
document.getElementById('btn-cpu').classList.add('active');
|
||||||
document.getElementById('btn-mem').classList.remove('active');
|
document.getElementById('btn-mem').classList.remove('active');
|
||||||
currentMetric = 'cpu';
|
currentMetric = 'cpu';
|
||||||
if (clusterData) renderChart(clusterData);
|
updatePodHeights();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-mem').addEventListener('click', (e) => {
|
document.getElementById('btn-mem').addEventListener('click', () => {
|
||||||
document.getElementById('btn-mem').classList.add('active');
|
document.getElementById('btn-mem').classList.add('active');
|
||||||
document.getElementById('btn-cpu').classList.remove('active');
|
document.getElementById('btn-cpu').classList.remove('active');
|
||||||
currentMetric = 'memory';
|
currentMetric = 'memory';
|
||||||
if (clusterData) renderChart(clusterData);
|
updatePodHeights();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchData() {
|
document.getElementById('btn-reset-cam').addEventListener('click', () => {
|
||||||
try {
|
resetCameraView();
|
||||||
const response = await fetch('/api/cluster-data');
|
});
|
||||||
if (!response.ok) throw new Error('Failed to fetch data');
|
|
||||||
clusterData = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
document.getElementById('btn-auto-rotate').addEventListener('click', () => {
|
||||||
renderChart(clusterData);
|
autoRotateEnabled = !autoRotateEnabled;
|
||||||
} catch (error) {
|
controls.autoRotate = autoRotateEnabled;
|
||||||
console.error(error);
|
const btn = document.getElementById('btn-auto-rotate');
|
||||||
document.getElementById('loading').innerHTML = `<p style="color: #ef4444;">Error loading data: ${error.message}</p>`;
|
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';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function renderChart(data) {
|
// Initialize 3D Engine
|
||||||
|
function init3D() {
|
||||||
const container = document.getElementById('chart-container');
|
const container = document.getElementById('chart-container');
|
||||||
const width = container.clientWidth;
|
const width = container.clientWidth;
|
||||||
const height = container.clientHeight;
|
const height = container.clientHeight;
|
||||||
|
|
||||||
// Clear previous
|
// Scene
|
||||||
d3.select('#chart-container').selectAll('*').remove();
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x090d16);
|
||||||
|
scene.fog = new THREE.FogExp2(0x090d16, 0.0012);
|
||||||
|
|
||||||
const svg = d3.select('#chart-container')
|
// Camera
|
||||||
.append('svg')
|
camera = new THREE.PerspectiveCamera(45, width / height, 1, 2000);
|
||||||
.attr('width', width)
|
camera.position.set(0, 140, 210);
|
||||||
.attr('height', height)
|
camera.lookAt(0, 0, 0);
|
||||||
.style('font-family', 'Inter, sans-serif');
|
|
||||||
|
// 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
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/cluster-data');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch cluster data');
|
||||||
|
clusterData = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
|
||||||
|
if (!scene) {
|
||||||
|
init3D();
|
||||||
|
}
|
||||||
|
build3DCluster(clusterData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
document.getElementById('loading').innerHTML = `<p style="color: #ef4444; font-weight: 600;">Error loading cluster data: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create 3D Canvas Text Sprite for Node Labels
|
||||||
|
function createTextSprite(text, fontSize = 28, textColor = '#f8fafc', bgColor = 'rgba(15, 23, 42, 0.85)') {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 128;
|
||||||
|
|
||||||
|
// Pill background
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.roundRect(16, 16, canvas.width - 32, canvas.height - 32, 24);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.6)';
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Text
|
||||||
|
ctx.font = `700 ${fontSize}px Inter, sans-serif`;
|
||||||
|
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);
|
||||||
|
sprite.scale.set(45, 11.25, 1);
|
||||||
|
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
|
||||||
|
|
||||||
// Create hierarchy
|
|
||||||
const root = d3.hierarchy(data)
|
const root = d3.hierarchy(data)
|
||||||
.sum(d => {
|
.sum(d => {
|
||||||
if (d.type === 'node') return 0;
|
if (d.type === 'node') return 0;
|
||||||
return d[currentMetric] || 0;
|
// Ensure minimum footprint for every pod block
|
||||||
|
return Math.max(d.cpu_raw || 0, d.memory_raw ? d.memory_raw / 1000000 : 0, 15);
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
// Create Treemap
|
|
||||||
d3.treemap()
|
d3.treemap()
|
||||||
.size([width, height])
|
.size([BOUNDS_SIZE, BOUNDS_SIZE])
|
||||||
.paddingTop(28)
|
.paddingTop(36)
|
||||||
.paddingRight(8)
|
.paddingRight(10)
|
||||||
.paddingInner(4)
|
.paddingInner(6)
|
||||||
.paddingBottom(8)
|
.paddingBottom(10)
|
||||||
.paddingLeft(8)
|
.paddingLeft(10)(root);
|
||||||
.round(true)(root);
|
|
||||||
|
|
||||||
// Get nodes and pods
|
// 3. Render Nodes and Pods
|
||||||
const nodes = root.descendants().filter(d => d.depth === 1); // Nodes
|
const nodes = root.descendants().filter(d => d.depth === 1);
|
||||||
const pods = root.leaves(); // Pods and Unused
|
const pods = root.leaves();
|
||||||
|
|
||||||
// Color scale for pods
|
// Render Node Platforms
|
||||||
const getColor = (d) => {
|
nodes.forEach(n => {
|
||||||
if (d.data.type === 'unused') return 'var(--color-unused)';
|
const nw = Math.max(10, n.x1 - n.x0);
|
||||||
if (d.data.status === 'Running') return 'var(--color-running)';
|
const nh = Math.max(10, n.y1 - n.y0);
|
||||||
if (d.data.status === 'Pending') return 'var(--color-pending)';
|
const centerX = (n.x0 + n.x1) / 2 - BOUNDS_SIZE / 2;
|
||||||
return 'var(--color-failed)';
|
const centerZ = (n.y0 + n.y1) / 2 - BOUNDS_SIZE / 2;
|
||||||
|
|
||||||
|
const platformGeo = new THREE.BoxGeometry(nw, 1.5, nh);
|
||||||
|
const platformMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x1e293b,
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0.3,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.95
|
||||||
|
});
|
||||||
|
const platformMesh = new THREE.Mesh(platformGeo, platformMat);
|
||||||
|
platformMesh.position.set(centerX, 0.75, centerZ);
|
||||||
|
platformMesh.receiveShadow = true;
|
||||||
|
|
||||||
|
// Platform Border Frame
|
||||||
|
const edges = new THREE.EdgesGeometry(platformGeo);
|
||||||
|
const lineMat = new THREE.LineBasicMaterial({ color: 0x3b82f6, linewidth: 2 });
|
||||||
|
const wireframe = new THREE.LineSegments(edges, lineMat);
|
||||||
|
platformMesh.add(wireframe);
|
||||||
|
|
||||||
|
scene.add(platformMesh);
|
||||||
|
nodeMeshes.push(platformMesh);
|
||||||
|
|
||||||
|
// Node Label Sprite
|
||||||
|
const sprite = createTextSprite(n.data.name, 26, '#f8fafc');
|
||||||
|
sprite.position.set(centerX, 12, centerZ - nh / 2 - 4);
|
||||||
|
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;
|
||||||
|
} 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
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
// Draw Node Groups
|
scene.add(podMesh);
|
||||||
const nodeGroups = svg.selectAll('.node-group')
|
podMeshes.push(podMesh);
|
||||||
.data(nodes)
|
});
|
||||||
.enter()
|
}
|
||||||
.append('g')
|
|
||||||
.attr('class', 'node-group')
|
|
||||||
.attr('transform', d => `translate(${d.x0},${d.y0})`);
|
|
||||||
|
|
||||||
// Node Background
|
// Calculate target 3D tower height based on current metric
|
||||||
nodeGroups.append('rect')
|
function getTargetHeight(data) {
|
||||||
.attr('class', 'node-rect')
|
if (data.type === 'unused') return 0.5; // low plate for available capacity
|
||||||
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
||||||
.attr('height', d => Math.max(0, d.y1 - d.y0));
|
|
||||||
|
|
||||||
// Node Label
|
const MAX_HEIGHT = 50; // Maximum tower height units
|
||||||
nodeGroups.append('text')
|
const MIN_HEIGHT = 1.5; // Base minimum height for visibility
|
||||||
.attr('class', 'node-label')
|
|
||||||
.attr('x', 8)
|
|
||||||
.attr('y', 20)
|
|
||||||
.text(d => d.data.name);
|
|
||||||
|
|
||||||
// Draw Pods
|
if (currentMetric === 'cpu') {
|
||||||
const podGroups = svg.selectAll('.pod-group')
|
const val = data.cpu_raw || 0;
|
||||||
.data(pods)
|
return MIN_HEIGHT + (val / (maxCpuInCluster || 1)) * MAX_HEIGHT;
|
||||||
.enter()
|
} else {
|
||||||
.append('g')
|
const val = data.memory_raw || 0;
|
||||||
.attr('class', 'pod-group')
|
return MIN_HEIGHT + (val / (maxMemInCluster || 1)) * MAX_HEIGHT;
|
||||||
.attr('transform', d => `translate(${d.x0},${d.y0})`);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const intersects = raycaster.intersectObjects(podMeshes);
|
||||||
|
|
||||||
// Pod Rect
|
|
||||||
podGroups.append('rect')
|
|
||||||
.attr('class', 'pod-rect')
|
|
||||||
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
||||||
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
||||||
.attr('fill', d => getColor(d))
|
|
||||||
.on('mousemove', function(event, d) {
|
|
||||||
const tooltip = document.getElementById('tooltip');
|
const tooltip = document.getElementById('tooltip');
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hitMesh = intersects[0].object;
|
||||||
|
|
||||||
|
if (hoveredPod !== hitMesh) {
|
||||||
|
// Reset previous hovered pod
|
||||||
|
if (hoveredPod) {
|
||||||
|
hoveredPod.material.emissive.setHex(hoveredPod.originalEmissive);
|
||||||
|
hoveredPod.material.emissiveIntensity = hoveredPod.originalEmissiveIntensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredPod = hitMesh;
|
||||||
|
// Highlight hovered pod tower
|
||||||
|
hoveredPod.material.emissive.setHex(0x60a5fa);
|
||||||
|
hoveredPod.material.emissiveIntensity = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display Tooltip
|
||||||
tooltip.classList.remove('hidden');
|
tooltip.classList.remove('hidden');
|
||||||
|
|
||||||
// Boundary detection for tooltip
|
// Boundary handling
|
||||||
const tWidth = 240;
|
const tWidth = 260;
|
||||||
let left = event.pageX + 15;
|
let left = event.pageX + 15;
|
||||||
let top = event.pageY + 15;
|
let top = event.pageY + 15;
|
||||||
|
|
||||||
|
|
@ -137,66 +464,93 @@ function renderChart(data) {
|
||||||
tooltip.style.left = left + 'px';
|
tooltip.style.left = left + 'px';
|
||||||
tooltip.style.top = top + 'px';
|
tooltip.style.top = top + 'px';
|
||||||
|
|
||||||
let val = currentMetric === 'cpu' ? formatCpu(d.data[`${currentMetric}_raw`]) : formatBytes(d.data[`${currentMetric}_raw`]);
|
const data = hitMesh.userData;
|
||||||
|
const valCpu = formatCpu(data.cpu_raw);
|
||||||
|
const valMem = formatBytes(data.memory_raw);
|
||||||
|
|
||||||
if (d.data.type === 'unused') {
|
if (data.type === 'unused') {
|
||||||
tooltip.innerHTML = `
|
tooltip.innerHTML = `
|
||||||
<div class="tooltip-title">Available Capacity</div>
|
<div class="tooltip-title">
|
||||||
|
<span>Available Capacity</span>
|
||||||
|
<span class="legend-dot unused"></span>
|
||||||
|
</div>
|
||||||
<div class="tooltip-row">
|
<div class="tooltip-row">
|
||||||
<span class="tooltip-label">${currentMetric.toUpperCase()}:</span>
|
<span class="tooltip-label">Node:</span>
|
||||||
<span class="tooltip-value">${val}</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
const statusColor = data.status === 'Running' ? '#10b981' : (data.status === 'Pending' ? '#f59e0b' : '#ef4444');
|
||||||
tooltip.innerHTML = `
|
tooltip.innerHTML = `
|
||||||
<div class="tooltip-title">${d.data.name}</div>
|
<div class="tooltip-title">
|
||||||
|
<span>${data.name}</span>
|
||||||
|
</div>
|
||||||
<div class="tooltip-row">
|
<div class="tooltip-row">
|
||||||
<span class="tooltip-label">Namespace:</span>
|
<span class="tooltip-label">Namespace:</span>
|
||||||
<span class="tooltip-value">${d.data.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>
|
||||||
<div class="tooltip-row">
|
<div class="tooltip-row">
|
||||||
<span class="tooltip-label">Status:</span>
|
<span class="tooltip-label">Status:</span>
|
||||||
<span class="tooltip-value">${d.data.status}</span>
|
<span class="tooltip-value" style="color: ${statusColor}; font-weight: 700;">${data.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tooltip-row">
|
<div class="tooltip-row">
|
||||||
<span class="tooltip-label">${currentMetric.toUpperCase()}:</span>
|
<span class="tooltip-label">CPU Usage:</span>
|
||||||
<span class="tooltip-value">${val}</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.on('mouseout', function() {
|
|
||||||
document.getElementById('tooltip').classList.add('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pod Label (only if there is space)
|
} else {
|
||||||
podGroups.append('text')
|
if (hoveredPod) {
|
||||||
.attr('class', 'pod-label')
|
hoveredPod.material.emissive.setHex(hoveredPod.originalEmissive);
|
||||||
.attr('x', 4)
|
hoveredPod.material.emissiveIntensity = hoveredPod.originalEmissiveIntensity;
|
||||||
.attr('y', 14)
|
hoveredPod = null;
|
||||||
.text(d => {
|
|
||||||
if (d.data.type === 'unused') return '';
|
|
||||||
const width = d.x1 - d.x0;
|
|
||||||
const height = d.y1 - d.y0;
|
|
||||||
if (width > 60 && height > 20) {
|
|
||||||
let txt = d.data.name;
|
|
||||||
if (txt.length * 5 > width) {
|
|
||||||
return txt.substring(0, Math.floor(width/5) - 2) + '..';
|
|
||||||
}
|
}
|
||||||
return txt;
|
tooltip.classList.add('hidden');
|
||||||
}
|
}
|
||||||
return '';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resize
|
// Click to focus camera on selected Pod
|
||||||
window.addEventListener('resize', () => {
|
function onPointerClick(event) {
|
||||||
if (clusterData) {
|
if (!hoveredPod) return;
|
||||||
renderChart(clusterData);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial fetch
|
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
|
||||||
fetchData();
|
fetchData();
|
||||||
// Poll every 30 seconds
|
|
||||||
setInterval(fetchData, 30000);
|
setInterval(fetchData, 30000);
|
||||||
|
|
||||||
|
|
|
||||||
182
static/style.css
182
static/style.css
|
|
@ -4,14 +4,14 @@
|
||||||
--text-muted: #94a3b8;
|
--text-muted: #94a3b8;
|
||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--accent-hover: #2563eb;
|
--accent-hover: #2563eb;
|
||||||
--glass-bg: rgba(30, 41, 59, 0.7);
|
--glass-bg: rgba(30, 41, 59, 0.75);
|
||||||
--glass-border: rgba(255, 255, 255, 0.1);
|
--glass-border: rgba(255, 255, 255, 0.12);
|
||||||
|
|
||||||
/* Chart Colors */
|
/* Chart Colors */
|
||||||
--color-running: rgba(16, 185, 129, 0.8);
|
--color-running: #10b981;
|
||||||
--color-pending: rgba(245, 158, 11, 0.8);
|
--color-pending: #f59e0b;
|
||||||
--color-failed: rgba(239, 68, 68, 0.8);
|
--color-failed: #ef4444;
|
||||||
--color-unused: rgba(148, 163, 184, 0.15);
|
--color-unused: rgba(148, 163, 184, 0.2);
|
||||||
--color-node: rgba(30, 41, 59, 0.9);
|
--color-node: rgba(30, 41, 59, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,14 +36,14 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 1.5rem;
|
padding: 1.25rem;
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
|
@ -53,7 +53,8 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 2rem;
|
padding: 0.85rem 1.75rem;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|
@ -68,6 +69,26 @@ body {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-3d {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-container {
|
.toggle-container {
|
||||||
|
|
@ -93,7 +114,39 @@ body {
|
||||||
.toggle-btn.active {
|
.toggle-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.9);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.active {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
|
|
@ -114,7 +167,7 @@ body {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
z-index: 10;
|
z-index: 30;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,46 +192,76 @@ body {
|
||||||
.chart-container {
|
.chart-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-group {
|
.chart-container:active {
|
||||||
transition: all 0.3s ease;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-rect {
|
.navigation-hint {
|
||||||
fill: var(--color-node);
|
position: absolute;
|
||||||
stroke: var(--glass-border);
|
top: 1rem;
|
||||||
stroke-width: 1px;
|
left: 1rem;
|
||||||
rx: 8;
|
display: flex;
|
||||||
ry: 8;
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod-rect {
|
.navigation-hint kbd {
|
||||||
stroke: rgba(0,0,0,0.3);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
stroke-width: 1px;
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
rx: 4;
|
border-radius: 0.25rem;
|
||||||
ry: 4;
|
padding: 0.1rem 0.35rem;
|
||||||
transition: fill 0.3s ease, opacity 0.3s ease, stroke 0.3s ease;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pod-rect:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
stroke: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-label {
|
|
||||||
fill: var(--text-main);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pod-label {
|
.legend-item {
|
||||||
fill: white;
|
display: flex;
|
||||||
font-size: 0.7rem;
|
align-items: center;
|
||||||
pointer-events: none;
|
gap: 0.5rem;
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.running { background-color: var(--color-running); box-shadow: 0 0 6px var(--color-running); }
|
||||||
|
.legend-dot.pending { background-color: var(--color-pending); box-shadow: 0 0 6px var(--color-pending); }
|
||||||
|
.legend-dot.failed { background-color: var(--color-failed); box-shadow: 0 0 6px var(--color-failed); }
|
||||||
|
.legend-dot.unused { background-color: #64748b; opacity: 0.5; }
|
||||||
|
|
||||||
|
.legend-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--glass-border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-note {
|
||||||
|
font-size: 0.725rem;
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
|
|
@ -186,9 +269,10 @@ body {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
font-size: 0.875rem;
|
font-size: 0.85rem;
|
||||||
transition: opacity 0.2s ease, transform 0.1s ease;
|
transition: opacity 0.2s ease, transform 0.1s ease;
|
||||||
min-width: 240px;
|
min-width: 250px;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.hidden {
|
.tooltip.hidden {
|
||||||
|
|
@ -201,12 +285,16 @@ body {
|
||||||
border-bottom: 1px solid var(--glass-border);
|
border-bottom: 1px solid var(--glass-border);
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-row {
|
.tooltip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.3rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,4 +304,6 @@ body {
|
||||||
|
|
||||||
.tooltip-value {
|
.tooltip-value {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Kubernetes Cluster Visualization</title>
|
<title>Kubernetes Cluster Visualization - K8s Pulse 3D</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<!-- D3.js for 2D floor layout computation -->
|
||||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<!-- Three.js & OrbitControls for 3D rendering and mouse navigation -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
|
@ -19,12 +23,27 @@
|
||||||
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>K8s Pulse</h1>
|
<h1>K8s Pulse <span class="badge-3d">3D</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="toggle-container">
|
<div class="toggle-container">
|
||||||
<button class="toggle-btn active" id="btn-cpu">CPU</button>
|
<button class="toggle-btn active" id="btn-cpu">CPU Consumption</button>
|
||||||
<button class="toggle-btn" id="btn-mem">Memory</button>
|
<button class="toggle-btn" id="btn-mem">Memory Consumption</button>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="icon-btn" id="btn-reset-cam" title="Reset Camera View">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
|
<path d="M3 3v5h5"/>
|
||||||
|
</svg>
|
||||||
|
<span>Reset View</span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" id="btn-auto-rotate" title="Toggle 3D Auto-Rotation">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
|
||||||
|
</svg>
|
||||||
|
<span id="auto-rotate-text">Auto Rotate</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -32,9 +51,27 @@
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div id="loading" class="loading glass-panel">
|
<div id="loading" class="loading glass-panel">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Analyzing Cluster...</p>
|
<p>Building 3D Pod Topology...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chart-container" class="chart-container"></div>
|
<div id="chart-container" class="chart-container"></div>
|
||||||
|
|
||||||
|
<!-- 3D Navigation Hint -->
|
||||||
|
<div class="navigation-hint glass-panel">
|
||||||
|
<span><kbd>Left Drag</kbd> Rotate</span>
|
||||||
|
<span><kbd>Right Drag</kbd> Pan</span>
|
||||||
|
<span><kbd>Scroll</kbd> Zoom</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend Panel -->
|
||||||
|
<div class="legend-panel glass-panel">
|
||||||
|
<div class="legend-item"><span class="legend-dot running"></span> Running Pod</div>
|
||||||
|
<div class="legend-item"><span class="legend-dot pending"></span> Pending Pod</div>
|
||||||
|
<div class="legend-item"><span class="legend-dot failed"></span> Failed Pod</div>
|
||||||
|
<div class="legend-item"><span class="legend-dot unused"></span> Available Capacity</div>
|
||||||
|
<div class="legend-divider"></div>
|
||||||
|
<div class="legend-note">📐 <strong>Tower Height</strong> = Resource Usage</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -43,3 +80,4 @@
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
[metadata]
|
[metadata]
|
||||||
version = 0.4
|
version = 0.5
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue