feat: Add orchestration models and services for Kubernetes cluster management
- Implemented Pydantic models for Kubernetes cluster state representation in `cluster.py`. - Created a `Resource` class for converting JSON/dict to Python objects in `resource.py`. - Established user models and services for user management, including password hashing and JWT token generation. - Developed application orchestration services for managing Kubernetes applications, including installation and uninstallation. - Added cluster service for retrieving cluster status and health reports. - Introduced node service for fetching node resource details and health status. - Implemented user service for handling user authentication and management.
This commit is contained in:
515
frontend/frontend.html
Normal file
515
frontend/frontend.html
Normal file
@ -0,0 +1,515 @@
|
||||
<!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
|
||||
406
frontend/other.html
Normal file
406
frontend/other.html
Normal file
@ -0,0 +1,406 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OCDP Application Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
/* Custom styles for glassmorphism and animations */
|
||||
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%; }
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 1rem;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Tabs styling */
|
||||
.tab-button.active {
|
||||
background: rgba(55, 65, 81, 0.5);
|
||||
color: #93c5fd;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
</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">
|
||||
<header class="mb-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div class="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">Application Manager</h1>
|
||||
<p class="text-gray-400">Manage and deploy applications on your cluster.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto mt-4 sm:mt-0">
|
||||
<input type="text" id="tokenInput" placeholder="Enter JWT Token" class="w-full sm:w-80 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">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="flex border-b border-gray-700/50 mb-6">
|
||||
<button id="tabAvailable" class="tab-button px-6 py-3 font-semibold text-gray-400 hover:text-white transition active">Available Applications</button>
|
||||
<button id="tabInstalled" class="tab-button px-6 py-3 font-semibold text-gray-400 hover:text-white transition">Installed Applications</button>
|
||||
</div>
|
||||
|
||||
<div id="contentAvailable" class="tab-content">
|
||||
<div id="availableAppsList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="contentInstalled" class="tab-content hidden">
|
||||
<div id="installedAppsList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="installModal" class="fixed inset-0 bg-gray-900/50 backdrop-blur-md hidden justify-center items-center z-50">
|
||||
<div class="card p-8 w-full max-w-lg">
|
||||
<h3 class="text-2xl font-bold text-white mb-4">Install Application</h3>
|
||||
<p id="modalAppName" class="text-gray-400 mb-4"></p>
|
||||
<form id="installForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="installMode" class="block text-sm font-medium text-gray-300">Deployment Mode</label>
|
||||
<select id="installMode" class="mt-1 block w-full bg-gray-900/50 border border-gray-600 rounded-md shadow-sm p-2 text-white"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="userOverrides" class="block text-sm font-medium text-gray-300">User Overrides (JSON)</label>
|
||||
<textarea id="userOverrides" rows="5" class="mt-1 block w-full bg-gray-900/50 border border-gray-600 rounded-md shadow-sm p-2 text-white"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" id="cancelInstallBtn" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg transition">Cancel</button>
|
||||
<button type="submit" id="submitInstallBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition">Install</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statusModal" class="fixed inset-0 bg-gray-900/50 backdrop-blur-md hidden justify-center items-center z-50">
|
||||
<div class="card p-8 w-full max-w-2xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-2xl font-bold text-white">Application Status</h3>
|
||||
<button id="closeStatusModal" class="text-gray-400 hover:text-white"><i data-lucide="x" class="w-6 h-6"></i></button>
|
||||
</div>
|
||||
<div id="statusContent" class="space-y-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_PREFIX = "http://localhost:8000/api/v1/orchestration";
|
||||
let TOKEN = "";
|
||||
|
||||
// --- DOM Elements ---
|
||||
const tokenInput = document.getElementById('tokenInput');
|
||||
const tabAvailable = document.getElementById('tabAvailable');
|
||||
const tabInstalled = document.getElementById('tabInstalled');
|
||||
const contentAvailable = document.getElementById('contentAvailable');
|
||||
const contentInstalled = document.getElementById('contentInstalled');
|
||||
const availableAppsList = document.getElementById('availableAppsList');
|
||||
const installedAppsList = document.getElementById('installedAppsList');
|
||||
const installModal = document.getElementById('installModal');
|
||||
const statusModal = document.getElementById('statusModal');
|
||||
const closeStatusModal = document.getElementById('closeStatusModal');
|
||||
const installForm = document.getElementById('installForm');
|
||||
|
||||
let activeRefreshInterval = null;
|
||||
|
||||
// --- Core Logic ---
|
||||
async function fetchAvailableApps() {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-templates`);
|
||||
if (!response.ok) throw new Error("Failed to fetch available apps.");
|
||||
const apps = await response.json();
|
||||
renderAvailableApps(apps);
|
||||
} catch (error) {
|
||||
console.error("Error fetching available apps:", error);
|
||||
availableAppsList.innerHTML = `<p class="text-center text-red-400">Failed to load available applications. Please check your API server.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInstalledApps() {
|
||||
if (!TOKEN) {
|
||||
installedAppsList.innerHTML = `<p class="text-center text-yellow-400 col-span-full">Please enter a valid JWT token to view installed applications.</p>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-instances`, {
|
||||
headers: { "Authorization": `Bearer ${TOKEN}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch installed apps.");
|
||||
const apps = await response.json();
|
||||
renderInstalledApps(apps);
|
||||
} catch (error) {
|
||||
console.error("Error fetching installed apps:", error);
|
||||
installedAppsList.innerHTML = `<p class="text-center text-red-400">Failed to load installed applications. Token might be invalid or expired.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndRenderStatus(namespace, app_template_name, mode) {
|
||||
if (!TOKEN) return;
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-instances/${namespace}/${app_template_name}/status?mode=${mode}`, {
|
||||
headers: { "Authorization": `Bearer ${TOKEN}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch status.");
|
||||
const statusData = await response.json();
|
||||
renderStatusDetails(statusData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching status:", error);
|
||||
document.getElementById('statusContent').innerHTML = `<p class="text-red-400">Error: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallRelease(namespace, app_template_name, mode) {
|
||||
if (!confirm(`Are you sure you want to uninstall the Helm release for ${app_template_name}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-instances/${namespace}/${app_template_name}?mode=${mode}`, {
|
||||
method: 'DELETE',
|
||||
headers: { "Authorization": `Bearer ${TOKEN}` }
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`Uninstall Release Result:\n${result.message}`);
|
||||
fetchInstalledApps(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Error uninstalling release:", error);
|
||||
alert(`Failed to uninstall release: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNamespace(namespace) {
|
||||
if (!confirm(`WARNING: This will permanently delete the entire namespace '${namespace}' and all its resources.`)) return;
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-instances/${namespace}`, {
|
||||
method: 'DELETE',
|
||||
headers: { "Authorization": `Bearer ${TOKEN}` }
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`Delete Namespace Result:\n${result.message}`);
|
||||
fetchInstalledApps(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Error deleting namespace:", error);
|
||||
alert(`Failed to delete namespace: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render Functions ---
|
||||
function renderAvailableApps(apps) {
|
||||
availableAppsList.innerHTML = apps.map(app => `
|
||||
<div class="card p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white mb-2">${app.name}</h3>
|
||||
<p class="text-sm text-gray-400">Business Name: ${app.metadata.application_name}</p>
|
||||
<p class="text-sm text-gray-400">Chart: ${app.metadata.distributed.chart}</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button onclick="openInstallModal('${app.name}', ${JSON.stringify(app.metadata)})" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderInstalledApps(apps) {
|
||||
installedAppsList.innerHTML = apps.map(app => `
|
||||
<div class="card p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white mb-2">${app.application_name}</h3>
|
||||
<p class="text-sm text-gray-400">Namespace: <span class="text-blue-300 font-semibold">${app.namespace}</span></p>
|
||||
<p class="text-sm text-gray-400">Release: ${app.release_name}</p>
|
||||
<p class="text-sm text-gray-400">Status: <span class="font-semibold ${app.status === 'deployed' ? 'text-green-300' : 'text-yellow-300'}">${app.status}</span></p>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button onclick="viewStatusDetails('${app.namespace}', '${app.application_name}', 'distributed')" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-2 rounded-lg transition text-sm">
|
||||
View Status
|
||||
</button>
|
||||
<button onclick="uninstallRelease('${app.namespace}', '${app.application_name}', 'distributed')" class="flex-1 bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-2 rounded-lg transition text-sm">
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button onclick="deleteNamespace('${app.namespace}')" class="w-full bg-red-800 hover:bg-red-900 text-white font-bold py-2 px-2 rounded-lg transition text-sm">
|
||||
Delete Namespace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderStatusDetails(statusData) {
|
||||
const content = document.getElementById('statusContent');
|
||||
content.innerHTML = `
|
||||
<p class="text-xl font-bold text-white">Application: ${statusData.application_name}</p>
|
||||
<p class="text-gray-400">Namespace: ${statusData.namespace}</p>
|
||||
<p class="text-gray-400">Ready: <span class="font-semibold ${statusData.is_ready ? 'text-green-300' : 'text-yellow-300'}">${statusData.is_ready}</span></p>
|
||||
${statusData.base_access_url ? `<p class="text-gray-400">Access URL: <a href="${statusData.base_access_url}" target="_blank" class="text-blue-400 hover:underline">${statusData.base_access_url}</a></p>` : ''}
|
||||
<h4 class="font-semibold text-white mt-4 mb-2">Pod Details</h4>
|
||||
<ul class="space-y-2">
|
||||
${statusData.details.map(pod => `
|
||||
<li class="bg-gray-800/50 p-3 rounded-lg flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-bold text-white">${pod.pod_name}</p>
|
||||
<p class="text-sm text-gray-400">Phase: ${pod.status_phase} | Ready: ${pod.ready_status}</p>
|
||||
</div>
|
||||
<span class="w-3 h-3 rounded-full ${pod.is_ready ? 'bg-green-400' : 'bg-red-400'}"></span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
statusModal.classList.remove('hidden');
|
||||
statusModal.classList.add('flex');
|
||||
}
|
||||
|
||||
// --- Event Handlers & Modal Functions ---
|
||||
function openInstallModal(appName, metadata) {
|
||||
const modalTitle = document.getElementById('modalAppName');
|
||||
modalTitle.innerText = `Installing ${appName}`;
|
||||
|
||||
const modeSelect = document.getElementById('installMode');
|
||||
modeSelect.innerHTML = '';
|
||||
const modes = [
|
||||
{ name: 'distributed', data: metadata.distributed },
|
||||
{ name: 'monolithic', data: metadata.monolithic }
|
||||
];
|
||||
|
||||
modes.forEach(mode => {
|
||||
if (mode.data) {
|
||||
const option = document.createElement('option');
|
||||
option.value = mode.name;
|
||||
option.innerText = mode.name;
|
||||
modeSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
installModal.classList.remove('hidden');
|
||||
installModal.classList.add('flex');
|
||||
installForm.dataset.appName = appName;
|
||||
}
|
||||
|
||||
installForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const appName = installForm.dataset.appName;
|
||||
const mode = document.getElementById('installMode').value;
|
||||
const userOverridesText = document.getElementById('userOverrides').value;
|
||||
let userOverrides = {};
|
||||
if (userOverridesText) {
|
||||
try {
|
||||
userOverrides = JSON.parse(userOverridesText);
|
||||
} catch (error) {
|
||||
alert("Invalid JSON for user overrides.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/application-instances`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app_template_name: appName,
|
||||
mode: mode,
|
||||
user_overrides: userOverrides
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || 'Installation failed.');
|
||||
|
||||
alert(`Installation started successfully:\nNamespace: ${result.namespace}\nMessage: ${result.message}`);
|
||||
installModal.classList.remove('flex');
|
||||
installModal.classList.add('hidden');
|
||||
fetchInstalledApps(); // Refresh installed list
|
||||
} catch (error) {
|
||||
console.error("Installation error:", error);
|
||||
alert(`Installation failed: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('cancelInstallBtn').addEventListener('click', () => {
|
||||
installModal.classList.remove('flex');
|
||||
installModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
closeStatusModal.addEventListener('click', () => {
|
||||
statusModal.classList.remove('flex');
|
||||
statusModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
function viewStatusDetails(namespace, app_template_name, mode) {
|
||||
document.getElementById('statusContent').innerHTML = `<p class="text-center text-blue-400">Loading status...</p>`;
|
||||
fetchAndRenderStatus(namespace, app_template_name, mode);
|
||||
}
|
||||
|
||||
// --- Tab Switching Logic ---
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.add('hidden'));
|
||||
|
||||
if (tabId === 'tabAvailable') {
|
||||
tabAvailable.classList.add('active');
|
||||
contentAvailable.classList.remove('hidden');
|
||||
fetchAvailableApps();
|
||||
} else if (tabId === 'tabInstalled') {
|
||||
tabInstalled.classList.add('active');
|
||||
contentInstalled.classList.remove('hidden');
|
||||
fetchInstalledApps();
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
tabAvailable.addEventListener('click', () => switchTab('tabAvailable'));
|
||||
tabInstalled.addEventListener('click', () => switchTab('tabInstalled'));
|
||||
|
||||
// --- Initialization ---
|
||||
window.onload = () => {
|
||||
const storedToken = localStorage.getItem('jwtToken');
|
||||
if (storedToken) {
|
||||
tokenInput.value = storedToken;
|
||||
TOKEN = storedToken;
|
||||
switchTab('tabInstalled');
|
||||
} else {
|
||||
switchTab('tabAvailable');
|
||||
}
|
||||
tokenInput.addEventListener('input', (e) => {
|
||||
TOKEN = e.target.value;
|
||||
localStorage.setItem('jwtToken', TOKEN);
|
||||
if (TOKEN) {
|
||||
// Refresh current tab if token is entered
|
||||
const currentTab = document.querySelector('.tab-button.active');
|
||||
if (currentTab) switchTab(currentTab.id);
|
||||
}
|
||||
});
|
||||
lucide.createIcons();
|
||||
};
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user