- Introduced a new `monitor.html` file for the Cluster Status Dashboard featuring real-time cluster health and resource allocation visualization using Chart.js and Tailwind CSS. - Removed the outdated `other.html` file which contained the previous Application Manager interface.
515 lines
27 KiB
HTML
515 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Cluster Status Dashboard - Dynamic</title>
|
|
<!-- Tailwind CSS for styling -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- Chart.js for beautiful charts -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<!-- Lucide Icons for a modern look -->
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
/* Custom styles for a better dark mode and overall look */
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 50%, #4a5568 100%);
|
|
background-size: 400% 400%;
|
|
animation: gradientBG 15s ease infinite;
|
|
}
|
|
|
|
@keyframes gradientBG {
|
|
0% { background-position: 0% 50%; }
|
|
50% { background-position: 100% 50%; }
|
|
100% { background-position: 0% 50%; }
|
|
}
|
|
|
|
.chart-container {
|
|
position: relative;
|
|
height: 120px;
|
|
width: 120px;
|
|
}
|
|
.chart-label {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
}
|
|
/* Glassmorphism Card Style */
|
|
.card {
|
|
background: rgba(31, 41, 55, 0.5); /* gray-800 with transparency */
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border-radius: 1rem; /* 16px */
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
transition: all 0.3s ease;
|
|
}
|
|
.card:hover {
|
|
transform: translateY(-5px) scale(1.01);
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Custom scrollbar for pod lists */
|
|
.pod-list::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
.pod-list::-webkit-scrollbar-track {
|
|
background: rgba(45, 55, 72, 0.5); /* gray-700 with transparency */
|
|
}
|
|
.pod-list::-webkit-scrollbar-thumb {
|
|
background: #90cdf4; /* blue-300 */
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* Tooltip for pressure status */
|
|
.tooltip {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
.tooltip .tooltiptext {
|
|
visibility: hidden;
|
|
width: 140px;
|
|
background-color: #111827;
|
|
color: #fff;
|
|
text-align: center;
|
|
border-radius: 6px;
|
|
padding: 5px 0;
|
|
position: absolute;
|
|
z-index: 1;
|
|
bottom: 125%;
|
|
left: 50%;
|
|
margin-left: -70px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.tooltip:hover .tooltiptext {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
</style>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
</head>
|
|
<body class="bg-gray-900 text-gray-200">
|
|
|
|
<div class="container mx-auto p-4 md:p-8">
|
|
<!-- API URL Input Section -->
|
|
<div class="card p-4 mb-8">
|
|
<div class="flex flex-col sm:flex-row items-center gap-4">
|
|
<label for="apiUrl" class="font-semibold text-white flex-shrink-0">API URL:</label>
|
|
<input type="text" id="apiUrl" placeholder="http://127.0.0.1:8000/api/v1/orchestration/cluster/cluster-status" class="w-full bg-gray-900/50 text-white border border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition">
|
|
<button id="fetchDataBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded-lg flex items-center gap-2 transition w-full sm:w-auto">
|
|
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
|
<span>Get Data</span>
|
|
</button>
|
|
</div>
|
|
<div id="status-message" class="mt-3 text-center min-h-[20px]"></div>
|
|
</div>
|
|
|
|
<!-- Header -->
|
|
<header class="mb-8 flex items-center gap-4">
|
|
<i data-lucide="layout-dashboard" class="w-10 h-10 text-blue-400"></i>
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white">Orchestration Cluster Status</h1>
|
|
<p class="text-gray-400">Real-time overview of cluster health and resource allocation.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="dashboard-content" class="hidden">
|
|
<!-- Cluster Summary Section -->
|
|
<div id="summary-section" class="mb-8">
|
|
<h2 class="text-2xl font-semibold text-white mb-4 flex items-center gap-2"><i data-lucide="server" class="w-6 h-6"></i>Cluster Summary</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div id="health-card" class="card p-6 flex flex-col justify-center items-center"></div>
|
|
<div class="card p-6 col-span-1 md:col-span-2 lg:col-span-3">
|
|
<h3 class="text-lg font-semibold mb-4 text-white flex items-center gap-2"><i data-lucide="pie-chart" class="w-5 h-5"></i>Core Resource Usage</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
|
|
<div class="flex flex-col items-center">
|
|
<div class="chart-container"><canvas id="cpuChart"></canvas><div id="cpuChartLabel" class="chart-label text-white"></div></div>
|
|
<p class="mt-2 text-gray-300 font-medium">CPU Usage</p><p id="cpu-usage-text" class="text-sm text-gray-400"></p>
|
|
</div>
|
|
<div class="flex flex-col items-center">
|
|
<div class="chart-container"><canvas id="memoryChart"></canvas><div id="memoryChartLabel" class="chart-label text-white"></div></div>
|
|
<p class="mt-2 text-gray-300 font-medium">Memory Usage</p><p id="memory-usage-text" class="text-sm text-gray-400"></p>
|
|
</div>
|
|
<div class="flex flex-col items-center">
|
|
<div class="chart-container"><canvas id="storageChart"></canvas><div id="storageChartLabel" class="chart-label text-white"></div></div>
|
|
<p class="mt-2 text-gray-300 font-medium">Ephemeral Storage</p><p id="storage-usage-text" class="text-sm text-gray-400"></p>
|
|
</div>
|
|
<div class="flex flex-col items-center">
|
|
<div class="chart-container"><canvas id="podsChart"></canvas><div id="podsChartLabel" class="chart-label text-white"></div></div>
|
|
<p class="mt-2 text-gray-300 font-medium">Pod Allocation</p><p id="pods-usage-text" class="text-sm text-gray-400"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
|
<div class="card p-6">
|
|
<h3 class="text-lg font-semibold text-white mb-3 flex items-center gap-2"><i data-lucide="lightbulb" class="w-5 h-5 text-yellow-300"></i>Scheduling Hints</h3>
|
|
<div id="scheduling-hints" class="space-y-3 text-gray-300"></div>
|
|
</div>
|
|
<div class="card p-6 md:col-span-2">
|
|
<h3 class="text-lg font-semibold text-white mb-3 flex items-center gap-2"><i data-lucide="gpu-chip" class="w-5 h-5 text-green-400"></i>GPU Availability</h3>
|
|
<div id="gpu-availability" class="space-y-2 text-gray-300"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nodes Section -->
|
|
<div>
|
|
<h2 class="text-2xl font-semibold text-white mb-4 flex items-center gap-2"><i data-lucide="hard-drive" class="w-6 h-6"></i>Node Details</h2>
|
|
<div id="nodes-grid" class="space-y-6"></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const apiUrlInput = document.getElementById('apiUrl');
|
|
const fetchDataBtn = document.getElementById('fetchDataBtn');
|
|
const statusMessage = document.getElementById('status-message');
|
|
const dashboardContent = document.getElementById('dashboard-content');
|
|
|
|
let refreshInterval = null;
|
|
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
|
|
|
// --- CORE LOGIC ---
|
|
|
|
async function fetchAndRenderDashboard() {
|
|
const url = apiUrlInput.value.trim();
|
|
if (!url) {
|
|
showStatus('Please enter a valid API URL.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
|
|
showStatus('Fetching data...', 'loading');
|
|
fetchDataBtn.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
const data = await response.json();
|
|
|
|
renderDashboard(data);
|
|
dashboardContent.classList.remove('hidden');
|
|
showStatus(`Data updated successfully. Next refresh in ${REFRESH_INTERVAL_MS / 1000}s.`, 'success');
|
|
localStorage.setItem('clusterApiUrl', url);
|
|
|
|
refreshInterval = setInterval(fetchAndRenderDashboard, REFRESH_INTERVAL_MS);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch or render dashboard:', error);
|
|
let errorMessage = `Failed to load data: ${error.message}`;
|
|
if (error instanceof TypeError && error.message === 'Failed to fetch') {
|
|
errorMessage = 'Network Error: Failed to fetch. This is likely a CORS issue. Please ensure your API server at the specified URL is running and has CORS enabled (e.g., with the "Access-Control-Allow-Origin: *" header).';
|
|
}
|
|
showStatus(errorMessage, 'error');
|
|
dashboardContent.classList.add('hidden');
|
|
} finally {
|
|
fetchDataBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// --- UTILITY FUNCTIONS ---
|
|
function parseResourceValue(valueStr) {
|
|
if (typeof valueStr !== 'string') return parseFloat(valueStr) || 0;
|
|
const value = parseFloat(valueStr);
|
|
if (valueStr.toLowerCase().includes('gi')) return value;
|
|
if (valueStr.toLowerCase().includes('mi')) return value / 1024;
|
|
if (valueStr.toLowerCase().includes('ki')) return value / 1024 / 1024;
|
|
if (valueStr.toLowerCase().includes('m')) return value / 1000;
|
|
return value;
|
|
}
|
|
|
|
function showStatus(message, type) {
|
|
statusMessage.textContent = message;
|
|
statusMessage.className = 'mt-3 text-center min-h-[20px] ';
|
|
switch (type) {
|
|
case 'success': statusMessage.classList.add('text-green-400'); break;
|
|
case 'error': statusMessage.classList.add('text-red-400'); break;
|
|
case 'loading': statusMessage.classList.add('text-blue-400'); break;
|
|
default: statusMessage.classList.add('text-gray-400');
|
|
}
|
|
}
|
|
|
|
function createDonutChart(canvasId, labelId, used, total, color) {
|
|
const free = total - used;
|
|
const percentage = total > 0 ? ((used / total) * 100).toFixed(1) : 0;
|
|
const ctx = document.getElementById(canvasId).getContext('2d');
|
|
|
|
if (window.chartInstances && window.chartInstances[canvasId]) {
|
|
window.chartInstances[canvasId].destroy();
|
|
}
|
|
|
|
const chart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
datasets: [{
|
|
data: [used, free > 0 ? free : 0],
|
|
backgroundColor: [color, 'rgba(74, 85, 104, 0.5)'],
|
|
borderWidth: 0,
|
|
hoverBackgroundColor: [color, 'rgba(74, 85, 104, 0.7)']
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false, cutout: '75%',
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
animation: { duration: 500 }
|
|
}
|
|
});
|
|
|
|
if (!window.chartInstances) window.chartInstances = {};
|
|
window.chartInstances[canvasId] = chart;
|
|
|
|
document.getElementById(labelId).innerText = `${percentage}%`;
|
|
}
|
|
|
|
function getNodePressureStatus(conditions) {
|
|
if (!Array.isArray(conditions)) {
|
|
return { hasPressure: false, reason: 'Unknown' };
|
|
}
|
|
const pressureCondition = conditions.find(c =>
|
|
(c.type === 'MemoryPressure' || c.type === 'DiskPressure' || c.type === 'PIDPressure') && c.status === 'True'
|
|
);
|
|
return {
|
|
hasPressure: !!pressureCondition,
|
|
reason: pressureCondition ? pressureCondition.type : 'No Pressure'
|
|
};
|
|
}
|
|
|
|
// --- RENDER FUNCTIONS ---
|
|
function renderDashboard(data) {
|
|
renderSummary(data); // Pass full data object
|
|
renderNodes(data.nodes);
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderSummary(data) {
|
|
const summary = data.summary;
|
|
const healthCard = document.getElementById('health-card');
|
|
const isHealthy = summary.health.unhealthy_nodes === 0;
|
|
healthCard.innerHTML = `
|
|
<div class="text-center">
|
|
<p class="text-lg font-semibold ${isHealthy ? 'text-green-300' : 'text-red-400'} mb-2 flex items-center gap-2">
|
|
<i data-lucide="${isHealthy ? 'shield-check' : 'shield-alert'}" class="w-6 h-6"></i>
|
|
${isHealthy ? 'Cluster Healthy' : 'Cluster Unhealthy'}
|
|
</p>
|
|
<p class="text-4xl font-bold text-white">${summary.health.ready_nodes} <span class="text-2xl font-normal">/ ${summary.health.total_nodes}</span></p>
|
|
<p class="text-gray-400">Nodes Ready</p>
|
|
</div>
|
|
`;
|
|
|
|
const { cluster_total_cpu, cluster_total_memory, cluster_total_pods, cluster_total_ephemeral_storage, best_node_for_gpu_app } = summary.resources;
|
|
|
|
const cpuUsed = parseFloat(cluster_total_cpu.used);
|
|
const cpuTotal = parseFloat(cluster_total_cpu.total);
|
|
createDonutChart('cpuChart', 'cpuChartLabel', cpuUsed, cpuTotal, '#6ee7b7');
|
|
document.getElementById('cpu-usage-text').innerText = `${cpuUsed.toFixed(2)} / ${cpuTotal.toFixed(2)} Cores`;
|
|
|
|
const memUsed = parseResourceValue(cluster_total_memory.used);
|
|
const memTotal = parseResourceValue(cluster_total_memory.total);
|
|
createDonutChart('memoryChart', 'memoryChartLabel', memUsed, memTotal, '#93c5fd');
|
|
document.getElementById('memory-usage-text').innerText = `${memUsed.toFixed(2)} / ${memTotal.toFixed(2)} GiB`;
|
|
|
|
createDonutChart('podsChart', 'podsChartLabel', cluster_total_pods.used, cluster_total_pods.total, '#fca5a5');
|
|
document.getElementById('pods-usage-text').innerText = `${cluster_total_pods.used} / ${cluster_total_pods.total} Pods`;
|
|
|
|
const storageUsed = parseResourceValue(cluster_total_ephemeral_storage.used);
|
|
const storageTotal = parseResourceValue(cluster_total_ephemeral_storage.total);
|
|
createDonutChart('storageChart', 'storageChartLabel', storageUsed, storageTotal, '#fde047');
|
|
document.getElementById('storage-usage-text').innerText = `${storageUsed.toFixed(2)} / ${storageTotal.toFixed(2)} GiB`;
|
|
|
|
const hintsContainer = document.getElementById('scheduling-hints');
|
|
const bestGpuNode = data.nodes.find(n => n.name === best_node_for_gpu_app.node_name);
|
|
const gpuProduct = bestGpuNode?.gpu_info?.types[0]?.product || 'N/A';
|
|
const gpuMemoryGB = (best_node_for_gpu_app.memory_per_gpu_mb / 1024).toFixed(1);
|
|
|
|
hintsContainer.innerHTML = `
|
|
<p><strong class="font-semibold text-blue-300">For CPU:</strong> ${summary.resources.best_node_for_cpu.node_name} (${summary.resources.best_node_for_cpu.free_amount} free)</p>
|
|
<p><strong class="font-semibold text-emerald-300">For Memory:</strong> ${summary.resources.best_node_for_memory.node_name} (${summary.resources.best_node_for_memory.free_amount} free)</p>
|
|
<p>
|
|
<strong class="font-semibold text-purple-300">For GPU App:</strong> ${best_node_for_gpu_app.node_name}
|
|
<span class="block text-sm text-gray-400 pl-4">
|
|
- Product: ${gpuProduct} <br>
|
|
- Free: ${best_node_for_gpu_app.free_gpu_count} cards (${gpuMemoryGB} GB/card) <br>
|
|
- Total Available: ${best_node_for_gpu_app.total_potential_memory_gb.toFixed(1)} GB
|
|
</span>
|
|
</p>
|
|
`;
|
|
|
|
const gpuContainer = document.getElementById('gpu-availability');
|
|
gpuContainer.innerHTML = summary.resources.distributed_gpu_availability
|
|
.filter(gpu => gpu.product !== 'Unknown' && gpu.total_free_count > 0)
|
|
.map(gpu => {
|
|
const totalMemoryGB = (gpu.total_free_count * gpu.memory_per_gpu_mb) / 1024;
|
|
return `
|
|
<div class="flex justify-between items-center bg-gray-900/40 p-3 rounded-lg">
|
|
<div>
|
|
<p class="font-semibold text-white">${gpu.product}</p>
|
|
<p class="text-sm text-gray-400">${gpu.total_free_count} cards × ${(gpu.memory_per_gpu_mb / 1024).toFixed(1)} GB/card</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-2xl font-bold text-green-300">${totalMemoryGB.toFixed(1)} GB</p>
|
|
<p class="text-sm text-gray-400">Total Available</p>
|
|
</div>
|
|
</div>
|
|
`}).join('') || `<p class="text-gray-400">No dedicated GPUs available in the cluster.</p>`;
|
|
}
|
|
|
|
function renderNodes(nodes) {
|
|
const nodesGrid = document.getElementById('nodes-grid');
|
|
nodesGrid.innerHTML = nodes.map((node, index) => {
|
|
const cpuUsed = parseResourceValue(node.cpu.used);
|
|
const cpuTotal = parseResourceValue(node.cpu.total);
|
|
const cpuPercentage = cpuTotal > 0 ? (cpuUsed / cpuTotal) * 100 : 0;
|
|
|
|
const memUsed = parseResourceValue(node.memory.used);
|
|
const memTotal = parseResourceValue(node.memory.total);
|
|
const memPercentage = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
|
|
|
|
const diskTotalBytes = parseFloat(node.ephemeral_storage.total);
|
|
const diskTotal = diskTotalBytes > 0 ? diskTotalBytes / (1024 * 1024 * 1024) : 0; // Convert bytes to GiB
|
|
const diskUsed = parseResourceValue(node.ephemeral_storage.used);
|
|
const diskPercentage = diskTotal > 0 ? (diskUsed / diskTotal) * 100 : 0;
|
|
|
|
const podsPercentage = node.pods.total > 0 ? (node.pods.used / node.pods.total) * 100 : 0;
|
|
|
|
const isReady = node.health.overall_status === 'Ready';
|
|
const pressureStatus = getNodePressureStatus(node.health.conditions);
|
|
|
|
const gpuSectionHtml = createGpuSection(node.gpu_info);
|
|
|
|
return `
|
|
<div class="card overflow-hidden">
|
|
<div class="p-4 bg-gray-900/30 flex flex-col sm:flex-row justify-between items-start sm:items-center">
|
|
<div>
|
|
<h3 class="text-xl font-bold text-white">${node.name}</h3>
|
|
<div class="flex flex-wrap gap-2 mt-2">
|
|
${node.roles.map(role => `<span class="bg-blue-500/50 text-blue-200 text-xs font-semibold px-2.5 py-1 rounded-full">${role}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 sm:mt-0 flex items-center gap-4">
|
|
<div class="tooltip">
|
|
<i data-lucide="${pressureStatus.hasPressure ? 'shield-alert' : 'shield-check'}" class="w-6 h-6 ${pressureStatus.hasPressure ? 'text-orange-400' : 'text-green-400'}"></i>
|
|
<span class="tooltiptext">${pressureStatus.reason}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-lg font-semibold ${isReady ? 'text-green-300' : 'text-red-400'}">
|
|
<span class="w-3 h-3 rounded-full ${isReady ? 'bg-green-400' : 'bg-red-400'}"></span>
|
|
${node.health.overall_status}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
${createResourceBar('CPU', cpuUsed.toFixed(2), cpuTotal, 'Cores', cpuPercentage, 'bg-emerald-400')}
|
|
${createResourceBar('Memory', memUsed.toFixed(2), memTotal.toFixed(2), 'GiB', memPercentage, 'bg-blue-400')}
|
|
${createResourceBar('Ephemeral Storage', diskUsed.toFixed(2), diskTotal.toFixed(2), 'GiB', diskPercentage, 'bg-yellow-400')}
|
|
${createResourceBar('Pods', node.pods.used, node.pods.total, '', podsPercentage, 'bg-red-400')}
|
|
</div>
|
|
${gpuSectionHtml}
|
|
<h4 class="font-semibold text-gray-300 mt-4 mb-2">Running Pods (${node.running_pods.length})</h4>
|
|
<div class="overflow-x-auto pod-list max-h-60 bg-gray-900/50 rounded-lg">
|
|
<table class="w-full text-sm text-left text-gray-300">
|
|
<thead class="text-xs text-gray-400 uppercase bg-gray-900/70 sticky top-0">
|
|
<tr>
|
|
<th scope="col" class="px-4 py-2">Namespace</th><th scope="col" class="px-4 py-2">Name</th>
|
|
<th scope="col" class="px-4 py-2">CPU Req.</th><th scope="col" class="px-4 py-2">Memory Req.</th>
|
|
<th scope="col" class="px-4 py-2">Age</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${node.running_pods.map(pod => `
|
|
<tr class="border-b border-gray-700/50 hover:bg-gray-700/50">
|
|
<td class="px-4 py-2">${pod.namespace}</td>
|
|
<td class="px-4 py-2 font-medium text-white whitespace-nowrap">${pod.name}</td>
|
|
<td class="px-4 py-2">${pod.cpu_requests}</td>
|
|
<td class="px-4 py-2">${pod.memory_requests}</td>
|
|
<td class="px-4 py-2">${pod.age}</td>
|
|
</tr>`).join('')}
|
|
${node.running_pods.length === 0 ? `<tr><td colspan="5" class="text-center py-4 text-gray-500">No running pods</td></tr>` : ''}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="text-right mt-2">
|
|
<button onclick="toggleDetails(${index})" class="text-sm text-blue-300 hover:underline">Show Labels & Conditions</button>
|
|
</div>
|
|
<div id="details-${index}" class="hidden mt-4 p-4 bg-black/30 rounded-lg">
|
|
<h5 class="font-semibold text-white mb-2">Labels</h5>
|
|
<pre class="text-xs bg-black/50 p-3 rounded-md max-h-48 overflow-auto"><code>${JSON.stringify(node.labels, null, 2)}</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function createGpuSection(gpu_info) {
|
|
if (!gpu_info || !gpu_info.usage || gpu_info.usage.total === 0) {
|
|
return '';
|
|
}
|
|
|
|
const { used, total } = gpu_info.usage;
|
|
const percentage = total > 0 ? (used / total) * 100 : 0;
|
|
|
|
const gpuType = gpu_info.types[0] || {};
|
|
const product = gpuType.product || 'N/A';
|
|
const memoryPerCard = gpuType.memory_mb ? (gpuType.memory_mb / 1024) : 0;
|
|
const totalMemoryOnNode = total * memoryPerCard;
|
|
|
|
const detailsHtml = `
|
|
<div class="text-sm mt-2 text-gray-400">
|
|
<span>${product}</span>
|
|
<span class="float-right font-medium text-white">${totalMemoryOnNode.toFixed(1)} GB Total
|
|
<span class="text-gray-500">(${total} × ${memoryPerCard.toFixed(1)} GB)</span>
|
|
</span>
|
|
</div>
|
|
`;
|
|
|
|
return `
|
|
<div class="mt-4 pt-4 border-t border-gray-700/50">
|
|
${createResourceBar('GPU', used, total, 'Cards', percentage, 'bg-purple-400')}
|
|
${detailsHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function createResourceBar(label, used, total, unit, percentage, colorClass) {
|
|
// Hide bar if total is 0, except for pods
|
|
if (total === 0 && label !== 'Pods') return '';
|
|
return `
|
|
<div>
|
|
<div class="flex justify-between mb-1">
|
|
<span class="text-sm font-medium text-gray-300">${label}</span>
|
|
<span class="text-sm font-medium text-gray-400">${used} / ${total} ${unit}</span>
|
|
</div>
|
|
<div class="w-full bg-gray-700/50 rounded-full h-2.5">
|
|
<div class="${colorClass} h-2.5 rounded-full" style="width: ${percentage}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function toggleDetails(index) {
|
|
const detailsDiv = document.getElementById(`details-${index}`);
|
|
detailsDiv.classList.toggle('hidden');
|
|
}
|
|
|
|
// --- INITIALIZATION ---
|
|
window.onload = () => {
|
|
lucide.createIcons();
|
|
fetchDataBtn.addEventListener('click', fetchAndRenderDashboard);
|
|
|
|
const savedUrl = localStorage.getItem('clusterApiUrl');
|
|
if (savedUrl) {
|
|
apiUrlInput.value = savedUrl;
|
|
fetchAndRenderDashboard();
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</ht |