diff --git a/public/app.js b/public/app.js index 93ec46ff7..dbe88d2e9 100644 --- a/public/app.js +++ b/public/app.js @@ -130,9 +130,46 @@ document.addEventListener('DOMContentLoaded', function() { let groupByNode = true; // Default view let filterGuestType = 'all'; // Default filter const AVERAGING_WINDOW_SIZE = 5; - const dashboardHistory = {}; + const dashboardHistory = {}; // Re-add this line let filterStatus = 'all'; // New state variable for status filter let initialDataReceived = false; // Flag to control initial rendering + let storageData = {}; // Add state for storage data + + // --- Global Helper for Text Progress Bar --- + const createProgressTextBarHTML = (percent, text, colorClass) => { + const numericPercent = isNaN(parseInt(percent)) ? 0 : parseInt(percent); + const textColorClass = 'text-gray-700 dark:text-gray-200'; + + return ` + +
+ +
+
+ + + ${text} + +
+ `; + }; + // --- End Global Helper --- + + // --- Global Helper for Usage Color --- + const getUsageColor = (percent) => { + if (isNaN(percent) || percent === 'N/A') return 'bg-gray-400 dark:bg-gray-600'; + const numericPercentage = parseInt(percent); + // Using thresholds consistent across tables now + if (numericPercentage > 85) return 'bg-red-500'; + if (numericPercentage > 70) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + // --- End Global Helper --- // --- WebSocket Connection --- const socket = io(); @@ -274,18 +311,31 @@ document.addEventListener('DOMContentLoaded', function() { const getValue = (item, col) => { if (!item) return type === 'string' ? '' : 0; // Default value based on expected type let val = item[col]; + + // Special Handling for Percentage Columns (Main and Nodes tables) + if ((type === 'main' || type === 'nodes') && (col === 'cpu' || col === 'memory' || col === 'disk')) { + // Treat N/A string as -1 for sorting purposes + if (val === 'N/A') return -1; + // Convert numeric percentage values (or numeric strings) to numbers + const numericVal = parseFloat(val); + return isNaN(numericVal) ? 0 : numericVal; // Default to 0 if parsing fails unexpectedly + } + // --- End Special Handling --- + // Handle specific column logic if needed if (type === 'main' && col === 'id') val = parseInt(item.vmid || item.id || 0); else if (type === 'nodes' && col === 'id') val = item.node; // ... other specific cases ... + + // Fallback for other types or columns return val ?? (type === 'string' ? '' : 0); // Use default if null/undefined }; valueA = getValue(a, column); valueB = getValue(b, column); - // Determine type for comparison (simple check) - const compareType = (typeof valueA === 'string' || typeof valueB === 'string') ? 'string' : 'number'; + // Determine type for comparison (Now should favor number for percentage columns) + const compareType = (typeof valueA === 'number' && typeof valueB === 'number') ? 'number' : 'string'; // Comparison logic if (compareType === 'string') { @@ -303,68 +353,79 @@ document.addEventListener('DOMContentLoaded', function() { // --- Data Update/Display Functions --- function updateNodesTable(nodes, skipSorting = false) { - const tbody = document.querySelector('#nodes-table tbody'); - if (!tbody) return; // Guard - tbody.innerHTML = ''; + // Corrected selector to target the tbody directly by its ID + const tbody = document.getElementById('nodes-table-body'); + if (!tbody) { + console.error('Critical element #nodes-table-body not found for nodes table update!'); + return; + } + tbody.innerHTML = ''; // Clear existing content const dataToDisplay = skipSorting ? (nodes || []) : sortData(nodes, sortState.nodes.column, sortState.nodes.direction, 'nodes'); if (dataToDisplay.length === 0) { - tbody.innerHTML = 'No nodes found'; + // Corrected colspan to match the actual number of columns (5) + tbody.innerHTML = 'No nodes found or data unavailable'; return; } dataToDisplay.forEach(node => { const row = document.createElement('tr'); - row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-150 ease-in-out'; + // Use same hover/transition classes as main dashboard rows + row.className = 'transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px'; - const statusColor = node.status === 'online' ? 'bg-green-500' : 'bg-red-500'; // Use red for non-online, consider gray/yellow for others if needed - const cpuPercent = (node.cpu || 0) * 100; - const memPercent = node.maxmem > 0 ? ((node.mem || 0) / node.maxmem) * 100 : 0; + // --- Determine Status (Inferring 'online' if we have data from /status endpoint) --- + // Proxmox API /nodes/{node}/status usually only returns data for online nodes. + // A more robust check might involve looking at node.uptime or specific error fallbacks from the backend. + const isOnline = node && node.uptime > 0; // Simple inference based on uptime + const statusText = isOnline ? 'online' : (node.status || 'unknown'); // Use synthesized status if available, else unknown + const statusColor = isOnline + ? 'bg-green-500 dark:bg-green-400' + : 'bg-red-500 dark:bg-red-400'; // Red for inferred offline/unknown - // Determine color based on percentage - const getUsageColor = (percent) => { - if (percent > 85) return 'bg-red-500'; - if (percent > 65) return 'bg-yellow-500'; - return 'bg-green-500'; // Default to green - }; + // Calculate percentages safely using the correct data structure + const cpuPercent = node.cpu ? (node.cpu * 100) : 0; + // Use node.memory object + const memUsed = node.memory?.used || 0; + const memTotal = node.memory?.total || 0; + const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0; + // Use node.rootfs object for disk + const diskUsed = node.rootfs?.used || 0; + const diskTotal = node.rootfs?.total || 0; + const diskPercent = (diskUsed && diskTotal > 0) ? (diskUsed / diskTotal * 100) : 0; + // Get color classes for bars const cpuColorClass = getUsageColor(cpuPercent); const memColorClass = getUsageColor(memPercent); + const diskColorClass = getUsageColor(diskPercent); + // Create tooltips and bar HTML using correct fields + const cpuTooltipText = `${cpuPercent.toFixed(1)}%`; + const memTooltipText = `${formatBytes(memUsed)} / ${formatBytes(memTotal)} (${memPercent.toFixed(1)}%)`; + const diskTooltipText = `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)} (${diskPercent.toFixed(1)}%)`; + + const cpuBarHTML = createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + const memoryBarHTML = createProgressTextBarHTML(memPercent, memTooltipText, memColorClass); + const diskBarHTML = createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); + + // Correctly generate the 5 columns matching the updated header order + // Use styling consistent with main dashboard (p-1 px-2, etc.) row.innerHTML = ` - ${node.node || 'N/A'} - - - - ${node.status || 'N/A'} + + + + ${statusText} - -
-
- -
- - -
-
- -
- - ${formatBytes(node.maxmem)} - ${formatUptime(node.uptime)} - ${node.ip || 'N/A'} + ${node.node || 'N/A'} + + ${cpuBarHTML} + ${memoryBarHTML} + ${diskBarHTML} `; + tbody.appendChild(row); }); - - // Re-enable tooltips if the library/method exists (assuming a simple CSS hover tooltip here) - // This example uses group-hover, so no extra JS needed for *these* tooltips. } function updateVmsTable(vms, skipSorting = false) { @@ -475,6 +536,264 @@ document.addEventListener('DOMContentLoaded', function() { return `${formatBytesInt(bytesPerSecond)}/s`; } + // --- Storage Data Display Function --- + function updateStorageInfo(storage) { + const contentDiv = document.getElementById('storage-info-content'); + if (!contentDiv) return; + contentDiv.innerHTML = ''; // Clear previous content + // Remove container styling, as it's now handled by the parent div in HTML + contentDiv.className = ''; + + // Check for global error first + if (storage && storage.globalError) { + // Error message styling - remove card styles, just use text/padding + contentDiv.innerHTML = `

Error: ${storage.globalError}

`; + return; + } + + if (!storage || Object.keys(storage).length === 0) { + // Empty message styling - remove card styles, just use text/padding + contentDiv.innerHTML = '

No storage data available or failed to load for any node.

'; + return; + } + + // --- Helper function for Storage Icons --- + function getStorageTypeIcon(type) { + // Simple icons using Tailwind/SVG - can be expanded + switch(type) { + case 'dir': + return ''; // Folder + case 'lvm': + case 'lvmthin': + return ''; // Database (representing logical volume) + case 'zfs': + case 'zfspool': + return ''; // Activity (representing ZFS complexity/features) + case 'nfs': + case 'cifs': + return ''; // Share-2 + case 'cephfs': + case 'rbd': + return ''; // Server (representing distributed storage) + default: + return ''; // HelpCircle (unknown) + } + } + // --- End Helper --- + + // --- Updated Helper for Content Badge Details (Class + Tooltip) --- + function getContentBadgeDetails(contentType) { + let details = { + badgeClass: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300', // Default style + tooltip: `Content type: ${contentType}` // Default tooltip + }; + + switch(contentType) { + case 'iso': + details.badgeClass = 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'; + details.tooltip = 'ISO images (e.g., for OS installation)'; + break; + case 'vztmpl': + details.badgeClass = 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300'; + details.tooltip = 'Container templates'; + break; + case 'backup': + details.badgeClass = 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300'; + details.tooltip = 'VM/Container backup files (vzdump)'; + break; + case 'images': + details.badgeClass = 'bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300'; + details.tooltip = 'VM disk images (qcow2, raw, etc.)'; + break; + case 'rootdir': + details.badgeClass = 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300'; + details.tooltip = 'Storage for container root filesystems'; + break; + case 'snippets': + details.badgeClass = 'bg-pink-100 dark:bg-pink-900/50 text-pink-700 dark:text-pink-300'; + details.tooltip = 'Snippet files (e.g., cloud-init configs)'; + break; + // Add more cases as needed + } + return details; + } + // --- End Helper --- + + // --- Helper: Sort storage array --- + function sortNodeStorageData(storageArray) { + if (!storageArray || !Array.isArray(storageArray)) return []; + // Create a shallow copy to avoid modifying the original + const sortedArray = [...storageArray]; + sortedArray.sort((a, b) => { + const nameA = String(a.storage || '').toLowerCase(); + const nameB = String(b.storage || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + return sortedArray; + } + // --- End Helper --- + + // Create ONE table for all nodes + const table = document.createElement('table'); + table.className = 'w-full text-sm border-collapse table-fixed'; + + const thead = document.createElement('thead'); + // Define widths for most columns, leave Usage column without width + thead.innerHTML = ` + + Storage + Content + Type + Shared + Usage + Avail + Total + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + tbody.className = 'divide-y divide-gray-200 dark:divide-gray-600'; + + // --- Sort nodes alphabetically before processing --- + const sortedNodeNames = Object.keys(storage).sort((a, b) => a.localeCompare(b)); + // --- End Node Sorting --- + + // Iterate through the *sorted* node names + sortedNodeNames.forEach(nodeName => { + const nodeStorageData = storage[nodeName]; + + // Add Node Header Row - Colspan needs to match data columns (7) + const nodeHeaderRow = document.createElement('tr'); + nodeHeaderRow.className = 'bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs node-storage-header'; + nodeHeaderRow.innerHTML = ` + + + Node: ${nodeName} + `; + tbody.appendChild(nodeHeaderRow); + + // Handle errors or empty data for this specific node + if (nodeStorageData.error) { + const errorRow = document.createElement('tr'); + errorRow.innerHTML = `Error loading storage: ${nodeStorageData.error}`; + tbody.appendChild(errorRow); + return; // Skip to next node + } + + if (!Array.isArray(nodeStorageData) || nodeStorageData.length === 0) { + const noDataRow = document.createElement('tr'); + noDataRow.innerHTML = `No storage configured or found for this node.`; + tbody.appendChild(noDataRow); + return; // Skip to next node + } + + // Sort storage data within this node + const sortedNodeStorageData = sortNodeStorageData(nodeStorageData); + + // Add Storage Data Rows for this node using the sorted storage data + sortedNodeStorageData.forEach(store => { + const row = document.createElement('tr'); + const isDisabled = store.enabled === 0 || store.active === 0; + row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700/60 hover:shadow-md hover:-translate-y-px ${isDisabled ? 'opacity-50 grayscale-[50%]' : ''}`; + + const usagePercent = store.total > 0 ? (store.used / store.total) * 100 : 0; + const usageTooltipText = `${formatBytes(store.used)} / ${formatBytes(store.total)} (${usagePercent.toFixed(1)}%)`; + + const usageColorClass = getUsageColor(usagePercent); + const sharedIconTooltip = store.shared === 1 ? 'Shared across cluster' : 'Local to node'; + const sharedIcon = store.shared === 1 ? `` + : ``; + + const contentTypes = (store.content || '').split(',').map(ct => ct.trim()).filter(ct => ct); + contentTypes.sort(); + const contentBadges = contentTypes.map(ct => { + const details = getContentBadgeDetails(ct); // Use the updated helper + // Re-add data-tooltip with the purpose, add trigger class and cursor + return `${ct}`; + }).join(''); + + const usageBarHTML = createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass); + + row.innerHTML = ` + ${store.storage || 'N/A'} + ${contentBadges || '-'} + ${store.type || 'N/A'} + ${sharedIcon} + ${usageBarHTML} + ${formatBytes(store.avail)} + ${formatBytes(store.total)} + `; + tbody.appendChild(row); + }); + }); // End looping through nodes + + table.appendChild(thead); + table.appendChild(tbody); + contentDiv.appendChild(table); + + // --- Tooltip Listener Setup (Moved outside updateStorageInfo) --- + if (tooltipElement) { + const storageTbody = table.querySelector('tbody'); // Get the tbody we just created + if (storageTbody) { + // Remove these listeners from here + } // End if storageTbody + } // End if tooltipElement + // --- End Tooltip Listener Setup --- + + } + + // --- Consolidated Tooltip Logic (Attached to Document Body) --- + if (tooltipElement) { + // Set faster duration (already done, kept for clarity) + tooltipElement.classList.remove('duration-100'); + tooltipElement.classList.add('duration-50'); + + document.body.addEventListener('mouseover', (event) => { + // Look for either trigger class + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (target) { + const tooltipText = target.getAttribute('data-tooltip'); + if (tooltipText) { + tooltipElement.textContent = tooltipText; + const offsetX = 10; + const offsetY = 15; + tooltipElement.style.left = `${event.pageX + offsetX}px`; + tooltipElement.style.top = `${event.pageY + offsetY}px`; + tooltipElement.classList.remove('hidden', 'opacity-0'); + tooltipElement.classList.add('opacity-100'); + } + } + }); + + document.body.addEventListener('mouseout', (event) => { + // Look for either trigger class + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (target) { + tooltipElement.classList.add('hidden', 'opacity-0'); + tooltipElement.classList.remove('opacity-100'); + } + }); + + document.body.addEventListener('mousemove', (event) => { + // Look for either trigger class + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (!tooltipElement.classList.contains('hidden') && target) { + const offsetX = 10; + const offsetY = 15; + tooltipElement.style.left = `${event.pageX + offsetX}px`; + tooltipElement.style.top = `${event.pageY + offsetY}px`; + } else if (!tooltipElement.classList.contains('hidden') && !target) { + // Optional: hide if mouse moves off trigger onto non-trigger area + // This might be less desirable with body-level listener, could hide unexpectedly. + } + }); + + } else { + console.warn('Tooltip element not found, custom tooltips disabled.'); + } + // --- End Consolidated Tooltip Logic --- + // --- Dashboard Data Processing & Display --- function refreshDashboardData() { dashboardData = []; @@ -686,31 +1005,35 @@ document.addEventListener('DOMContentLoaded', function() { row.setAttribute('data-node', guest.node.toLowerCase()); row.setAttribute('data-id', guest.id); - const memoryPercent = guest.memory; // Already calculated, possibly 'N/A' - const diskPercent = guest.disk; // Already calculated, possibly 'N/A' const cpuPercent = Math.round(guest.cpu * 100); + const memoryPercent = guest.memory; + const diskPercent = guest.disk; - const cpuAbsolute = guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''; - const memoryAbsolute = guest.memoryTotal ? `(${formatBytesInt(guest.memoryCurrent)} / ${formatBytesInt(guest.memoryTotal)})` : ''; - const diskAbsolute = guest.diskTotal ? `(${formatBytesInt(guest.diskCurrent)} / ${formatBytesInt(guest.diskTotal)})` : ''; - - const cpuUsageText = createUsageText(cpuPercent, cpuAbsolute); - const memoryUsageText = createUsageText(memoryPercent, memoryAbsolute); - const diskUsageText = createUsageText(diskPercent, diskAbsolute); + const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`; + const memoryTooltipText = guest.memoryTotal ? `${formatBytesInt(guest.memoryCurrent)} / ${formatBytesInt(guest.memoryTotal)} (${memoryPercent}%)` : `${memoryPercent}%`; + const diskTooltipText = guest.diskTotal ? `${formatBytesInt(guest.diskCurrent)} / ${formatBytesInt(guest.diskTotal)} (${diskPercent}%)` : `${diskPercent}%`; + + const cpuColorClass = getUsageColor(cpuPercent); + const memColorClass = getUsageColor(memoryPercent); + const diskColorClass = getUsageColor(diskPercent); + + const cpuBarHTML = createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + const memoryBarHTML = createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass); + const diskBarHTML = createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); const typeIconClass = guest.type === 'VM' - ? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-700' - : 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-700'; - const typeIcon = `${guest.type}`; + ? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 font-medium' + : 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-1.5 py-0.5 font-medium'; + const typeIcon = `${guest.type}`; row.innerHTML = ` ${guest.name} ${typeIcon} ${guest.id} ${formatUptime(guest.uptime)} - ${cpuUsageText} - ${memoryUsageText} - ${diskUsageText} + ${cpuBarHTML} + ${memoryBarHTML} + ${diskBarHTML} ${formatSpeedInt(guest.diskread)} ${formatSpeedInt(guest.diskwrite)} ${formatSpeedInt(guest.netin)} @@ -719,29 +1042,6 @@ document.addEventListener('DOMContentLoaded', function() { return row; } - function createUsageText(percentage, tooltipText = '') { - // console.log(`[createUsageText] Received percentage: ${percentage}, tooltip: ${tooltipText}`); - let colorClass = ''; - let displayPercentage = percentage; - - if (percentage === 'N/A' || isNaN(percentage)) { - displayPercentage = 'N/A'; - colorClass = 'text-gray-400 dark:text-gray-500'; - } else { - const numericPercentage = parseInt(percentage); - displayPercentage = `${numericPercentage}%`; - if (numericPercentage > 85) { - colorClass = 'text-red-600 dark:text-red-400 font-medium'; - } else if (numericPercentage > 65) { - colorClass = 'text-yellow-600 dark:text-yellow-400'; - } else { - colorClass = 'text-green-600 dark:text-green-400'; - } - } - const safeTooltipText = tooltipText.replace(/"/g, '"'); - return `${displayPercentage}`; - } - // --- WebSocket Message Handling --- // Add a generic listener to catch *any* events from the server socket.onAny((eventName, ...args) => { @@ -791,44 +1091,27 @@ document.addEventListener('DOMContentLoaded', function() { } } - // --- Tooltip Logic --- - if (mainTableBody && tooltipElement) { - mainTableBody.addEventListener('mouseover', (event) => { - const target = event.target.closest('.metric-tooltip-trigger'); - if (target) { - const tooltipText = target.getAttribute('data-tooltip'); - if (tooltipText) { - tooltipElement.textContent = tooltipText; - const offsetX = 10; - const offsetY = 15; - tooltipElement.style.left = `${event.pageX + offsetX}px`; - tooltipElement.style.top = `${event.pageY + offsetY}px`; - tooltipElement.classList.remove('hidden', 'opacity-0'); - tooltipElement.classList.add('opacity-100'); - } - } - }); - mainTableBody.addEventListener('mouseout', (event) => { - const target = event.target.closest('.metric-tooltip-trigger'); - if (target) { - tooltipElement.classList.add('hidden', 'opacity-0'); - tooltipElement.classList.remove('opacity-100'); - } - }); - mainTableBody.addEventListener('mousemove', (event) => { - const target = event.target.closest('.metric-tooltip-trigger'); - if (!tooltipElement.classList.contains('hidden') && target) { - // Update position while moving over the trigger - const offsetX = 10; - const offsetY = 15; - tooltipElement.style.left = `${event.pageX + offsetX}px`; - tooltipElement.style.top = `${event.pageY + offsetY}px`; - } else if (!tooltipElement.classList.contains('hidden') && !target) { - // Optional: hide if mouse moves off trigger onto non-trigger area - // tooltipElement.classList.add('hidden', 'opacity-0'); - // tooltipElement.classList.remove('opacity-100'); - } - }); + // --- Function to Reset Dashboard Filters/Sort --- + function resetDashboardView() { + console.log('Resetting dashboard view...'); + if (searchInput) searchInput.value = ''; + sortState.main = { column: 'id', direction: 'asc' }; + updateSortUI('main-table', document.querySelector('#main-table th[data-sort="id"]')); + + const groupGroupedRadio = document.getElementById('group-grouped'); + if(groupGroupedRadio) groupGroupedRadio.checked = true; + groupByNode = true; + + const filterAllRadio = document.getElementById('filter-all'); + if(filterAllRadio) filterAllRadio.checked = true; + filterGuestType = 'all'; + + const statusAllRadio = document.getElementById('filter-status-all'); + if(statusAllRadio) statusAllRadio.checked = true; + filterStatus = 'all'; + + updateDashboardTable(); + if (searchInput) searchInput.blur(); // Blur search input after reset } // --- Reset Filters/Sort Listener --- @@ -838,19 +1121,7 @@ document.addEventListener('DOMContentLoaded', function() { const isGeneralInputElement = !isSearchInputFocused && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable); if (event.key === 'Escape') { - if (searchInput) searchInput.value = ''; - sortState.main = { column: 'id', direction: 'asc' }; - updateSortUI('main-table', document.querySelector('#main-table th[data-sort="id"]')); - const groupGroupedRadio = document.getElementById('group-grouped'); - if(groupGroupedRadio) groupGroupedRadio.checked = true; - groupByNode = true; - const filterAllRadio = document.getElementById('filter-all'); - if(filterAllRadio) filterAllRadio.checked = true; - filterGuestType = 'all'; - filterStatus = 'all'; - updateDashboardTable(); - if (searchInput) searchInput.blur(); // Blur on Escape as well - + resetDashboardView(); // Call the reset function } else if (isSearchInputFocused && event.key === 'Enter') { searchInput.blur(); event.preventDefault(); // Prevent any default Enter key behavior @@ -871,6 +1142,14 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Add listener for the new Reset button + const resetButton = document.getElementById('reset-filters-button'); + if (resetButton) { + resetButton.addEventListener('click', resetDashboardView); // Call the same reset function + } else { + console.warn('Reset button #reset-filters-button not found.'); + } + // --- Initial Setup Calls --- updateSortUI('main-table', document.querySelector('#main-table th[data-sort="id"]')); // Data is requested on socket 'connect' event @@ -882,6 +1161,30 @@ document.addEventListener('DOMContentLoaded', function() { updateVmsTable(vmsData); updateContainersTable(containersData); refreshDashboardData(); // Process and update the main dashboard + updateStorageInfo(storageData); // Update storage info tab + } + + // Add a separate fetch for storage data, maybe less frequent? + async function fetchStorageData() { + try { + const response = await fetch('/api/storage'); + // Removed the response.ok check here, as we want to parse the JSON even for 500 errors + // to check for the globalError property. + storageData = await response.json(); + // console.log('[Storage Fetch] Fetched storage data:', storageData); + + // If the server responded with an error status but *didn't* include our globalError JSON, + // synthesize an error state for the UI. + if (!response.ok && !storageData.globalError) { + console.error('Error fetching storage data: Server returned status', response.status, 'but no globalError field.'); + storageData = { globalError: `Failed to load storage data (Status: ${response.status})` }; + } + } catch (error) { + console.error('Error fetching or parsing storage data:', error); + // Network error or JSON parsing error + storageData = { globalError: 'Failed to load storage data due to a network or parsing error.' }; + } + // We don't call updateStorageInfo here anymore, the main interval handles it. } setInterval(() => { @@ -890,6 +1193,11 @@ document.addEventListener('DOMContentLoaded', function() { updateAllUITables(); } }, 2000); // Update UI every 2 seconds + + // Fetch storage data periodically (e.g., every 10 seconds) + setInterval(fetchStorageData, 10000); + fetchStorageData(); // Initial fetch on load + // --- End Frontend Render Interval --- // --- Fetch and display version --- diff --git a/public/index.html b/public/index.html index 14534a0e6..24cf309b8 100644 --- a/public/index.html +++ b/public/index.html @@ -94,6 +94,7 @@
Main
Nodes
+
Storage
@@ -101,34 +102,34 @@ +
-
+ + +
@@ -173,9 +174,9 @@ - - - + + + @@ -212,6 +213,17 @@
Loading dashboard data...
+ + diff --git a/server/index.js b/server/index.js index 413f12765..30ad9925b 100644 --- a/server/index.js +++ b/server/index.js @@ -78,9 +78,14 @@ const proxmoxConfig = { // Server configuration const DEBUG_METRICS = false; // Set to true to show detailed metrics logs -const UPDATE_INTERVAL = 2000; // 2 seconds for updates const PORT = 7655; // Using a different port from the main server +// --- Define Update Intervals --- +// How often to fetch dynamic metrics (CPU, Mem, IO) for running guests +const METRIC_UPDATE_INTERVAL = 2000; // Default: 2 seconds +// How often to fetch structural data (node list, guest lists, node status) +const DISCOVERY_UPDATE_INTERVAL = 30000; // Default: 30 seconds + // Create Proxmox API client const proxmoxApi = axios.create({ baseURL: proxmoxConfig.node1.host.includes('://') @@ -140,6 +145,86 @@ app.get('/api/version', (req, res) => { }); // --- End API endpoint --- +// --- Add API endpoint for Storage --- +app.get('/api/storage', async (req, res) => { + const storageData = {}; + let nodesToQuery = []; + let discoveryFailed = false; // Flag for discovery failure + + try { + // Reuse the node discovery logic from fetchRawProxmoxData + const nodesResponse = await proxmoxApi.get('/nodes'); + const basicNodeInfo = nodesResponse.data.data || []; + nodesToQuery = basicNodeInfo.map(n => n.node); // Get just the names for further queries + + if (nodesToQuery.length === 0) { + console.warn('/api/storage: Proxmox API returned 0 nodes from /nodes. Attempting single node discovery.'); + try { + const versionResponse = await proxmoxApi.get('/version'); + const discoveredNodeName = versionResponse.data.data.node; + if (discoveredNodeName) { + nodesToQuery = [discoveredNodeName]; + } else { + throw new Error("Could not determine node name from /version."); + } + } catch (discoveryError) { + console.error(`/api/storage: Single node discovery failed: ${discoveryError.message}`); + discoveryFailed = true; // Mark discovery as failed + } + } else { + // console.log(`/api/storage: Found ${nodesToQuery.length} nodes.`); // Reduced verbosity + } + } catch (error) { + console.warn(`/api/storage: Failed to fetch /nodes (${error.message}). Attempting single node discovery.`); + try { + const versionResponse = await proxmoxApi.get('/version'); + const discoveredNodeName = versionResponse.data.data.node; + if (discoveredNodeName) { + nodesToQuery = [discoveredNodeName]; + console.log(`/api/storage: Discovered single node: ${discoveredNodeName}`); + } else { + throw new Error("Could not determine node name from /version."); + } + } catch (discoveryError) { + console.error(`/api/storage: Single node discovery failed after /nodes error: ${discoveryError.message}`); + discoveryFailed = true; // Mark discovery as failed + } + } + + // If discovery failed entirely, return a specific error structure + if (discoveryFailed) { + return res.status(500).json({ globalError: 'Failed to discover any Proxmox nodes to query for storage.' }); + } + + // Fetch storage for each node in parallel + const storagePromises = nodesToQuery.map(async (nodeName) => { + if (!nodeName) { + console.warn('/api/storage: Skipping node with missing name:', nodeName); + return; // Skip if node name is missing + } + try { + const response = await proxmoxApi.get(`/nodes/${nodeName}/storage`); + storageData[nodeName] = response.data.data || []; // Store storage info per node + // console.log(`/api/storage: Successfully fetched storage for node ${nodeName}`); // Reduced verbosity + } catch (err) { + console.error(`/api/storage: Error fetching storage for node ${nodeName}: ${err.message}`); + storageData[nodeName] = { error: `Failed to fetch storage: ${err.message}` }; // Indicate error for this node + } + }); + + await Promise.allSettled(storagePromises); + + if (Object.keys(storageData).length === 0 && nodesToQuery.length > 0) { + // This case might happen if all parallel fetches failed for discovered nodes + // We still return the object, but it might contain only node names with error objects. + console.warn('/api/storage: Failed to fetch storage for any discovered node.'); + } + + // console.log(`/api/storage: Returning data for ${Object.keys(storageData).length} nodes.`); // Reduced verbosity + res.json(storageData); // Return the potentially mixed success/error data per node +}); +// --- End Storage API endpoint --- + // Create HTTP server const server = http.createServer(app); @@ -151,9 +236,17 @@ const io = new Server(server, { } }); -// Variables to track connected clients - REMOVED as we use io.engine.clientsCount -// let connectedClients = 0; -let initialNodesLogged = false; // Flag to log node count only once +// --- Global State Variables --- +// These will hold the latest fetched data +let currentNodes = []; +let currentVms = []; +let currentContainers = []; +let currentMetrics = []; +let isDiscoveryRunning = false; // Prevent concurrent discovery runs +let isMetricsRunning = false; // Prevent concurrent metric runs +let discoveryTimeoutId = null; +let metricTimeoutId = null; +// --- End Global State --- // Helper function to fetch data for a single node async function fetchDataForNode(nodeName) { @@ -247,197 +340,318 @@ async function fetchDataForNode(nodeName) { return nodeData; // Return collected data for this node } -// Helper function to get raw Proxmox data -async function fetchRawProxmoxData() { - const rawData = { +// --- Refactored Data Fetching Logic --- + +/** + * Fetches structural data: node list, node statuses, VM list, Container list. + */ +async function fetchDiscoveryData() { + console.log('[Discovery Cycle] Starting fetch...'); + const discoveryResult = { nodes: [], vms: [], - containers: [], - metrics: [] + containers: [] }; - let nodesToQuery = []; - let discoveredNodeName = null; + let basicNodeInfo = []; // Store basic info separately + // --- Step 1: Fetch Node List and Basic Info --- try { - // Attempt to fetch cluster nodes const nodesResponse = await proxmoxApi.get('/nodes'); - nodesToQuery = nodesResponse.data.data || []; - rawData.nodes = nodesToQuery; + basicNodeInfo = nodesResponse.data.data || []; + nodesToQuery = basicNodeInfo.map(n => n.node).filter(Boolean); if (nodesToQuery.length === 0) { - console.warn('Proxmox API returned 0 nodes from /nodes endpoint. Attempting single node discovery.'); - // If /nodes returns empty, still try to discover the single node via /version + console.warn('[Discovery Cycle] /nodes returned 0 nodes. Attempting single node discovery.'); try { const versionResponse = await proxmoxApi.get('/version'); - discoveredNodeName = versionResponse.data.data.node; - if (!discoveredNodeName) { - throw new Error("Could not determine node name from /version endpoint."); - } - console.log(`Discovered single node name: ${discoveredNodeName}`); - nodesToQuery = [{ node: discoveredNodeName }]; // Use the discovered name - - // Optionally, try to get status for the single node - try { - const statusResponse = await proxmoxApi.get(`/nodes/${discoveredNodeName}/status`); - rawData.nodes = [statusResponse.data.data]; // Use actual status if available - } catch (statusError) { - console.warn(`Could not fetch status for discovered single node ${discoveredNodeName}: ${statusError.message}`); - rawData.nodes = [{ node: discoveredNodeName, status: 'unknown' }]; // Fallback to discovered name + const discoveredNodeName = versionResponse.data.data.node; + if (discoveredNodeName) { + nodesToQuery = [discoveredNodeName]; + // We need some basic info for the single node if /nodes failed + basicNodeInfo = [{ node: discoveredNodeName, ip: 'unknown', status: 'unknown' }]; + } else { + throw new Error("Could not determine node name from /version."); } } catch (discoveryError) { - console.error(`Single node discovery failed: ${discoveryError.message}. Cannot proceed.`); - // Return empty data if we can't even discover the node name - return { nodes: [], vms: [], containers: [], metrics: [] }; + console.error(`[Discovery Cycle] Single node discovery failed: ${discoveryError.message}. Cannot proceed.`); + return discoveryResult; // Return empty structure on fatal discovery error } } } catch (error) { - console.warn(`Failed to fetch /nodes (attempting single node discovery): ${error.message}`); - // Assume single node mode if /nodes fails, try discovery via /version + console.warn(`[Discovery Cycle] Failed to fetch /nodes (${error.message}). Attempting single node discovery.`); try { const versionResponse = await proxmoxApi.get('/version'); - discoveredNodeName = versionResponse.data.data.node; - if (!discoveredNodeName) { - throw new Error("Could not determine node name from /version endpoint."); - } - console.log(`Discovered single node name: ${discoveredNodeName}`); - nodesToQuery = [{ node: discoveredNodeName }]; // Use the discovered name - - // Optionally, try to get status for the single node - try { - const statusResponse = await proxmoxApi.get(`/nodes/${discoveredNodeName}/status`); - rawData.nodes = [statusResponse.data.data]; // Use actual status if available - } catch (statusError) { - console.warn(`Could not fetch status for discovered single node ${discoveredNodeName}: ${statusError.message}`); - rawData.nodes = [{ node: discoveredNodeName, status: 'unknown' }]; // Fallback to discovered name + const discoveredNodeName = versionResponse.data.data.node; + if (discoveredNodeName) { + nodesToQuery = [discoveredNodeName]; + basicNodeInfo = [{ node: discoveredNodeName, ip: 'unknown', status: 'unknown' }]; + } else { + throw new Error("Could not determine node name from /version."); } } catch (discoveryError) { - console.error(`Single node discovery failed after /nodes error: ${discoveryError.message}. Cannot proceed.`); - // Return empty data if discovery fails - return { nodes: [], vms: [], containers: [], metrics: [] }; + console.error(`[Discovery Cycle] Single node discovery failed after /nodes error: ${discoveryError.message}. Cannot proceed.`); + return discoveryResult; } } - // --- Start Parallel Fetching --- - const nodeDataPromises = nodesToQuery.map(node => { - const nodeName = node.node; - if (!nodeName) { - console.error("Node object missing 'node' property:", node); - return Promise.resolve(null); // Resolve immediately for invalid node objects - } - return fetchDataForNode(nodeName); - }); - - const results = await Promise.allSettled(nodeDataPromises); - - results.forEach((result, index) => { - const nodeName = nodesToQuery[index]?.node; // Get corresponding node name - if (result.status === 'fulfilled' && result.value) { - // Aggregate successful results - const nodeData = result.value; - rawData.vms.push(...nodeData.vms); - rawData.containers.push(...nodeData.containers); - rawData.metrics.push(...nodeData.metrics); - } else if (result.status === 'rejected') { - console.error(`Failed to fetch data for node ${nodeName || 'UNKNOWN'}: ${result.reason}`); - // Optionally add placeholder data or mark node as errored in rawData.nodes if needed - } - }); - // --- End Parallel Fetching --- - - // console.log(`Collected raw data: ${rawData.nodes.length} nodes, ${rawData.vms.length} VMs, ${rawData.containers.length} containers, ${rawData.metrics.length} metric sets`); - return rawData; -} - -// Socket.io connection handling -io.on('connection', (socket) => { - // connectedClients++; - REMOVED - console.log(`[socket] Client connected. Total clients: ${io.engine.clientsCount}`); - - // Fetch and send initial data, log node count on first successful fetch - fetchRawProxmoxData().then(data => { - socket.emit('rawData', data); - if (!initialNodesLogged && data && data.nodes && data.nodes.length > 0) { - console.log(`Initial connection successful. Found ${data.nodes.length} Proxmox node(s).`); - initialNodesLogged = true; - } else if (!initialNodesLogged && data && data.nodes && data.nodes.length === 0) { - console.log('Initial connection successful. Found 0 Proxmox nodes.'); - initialNodesLogged = true; - } else if (!initialNodesLogged) { - // Log if initial fetch failed but connection handler still ran - console.warn('Initial Proxmox data fetch failed or returned no nodes.'); - initialNodesLogged = true; // Prevent repeated warnings - } - }).catch(error => { - console.error('Error fetching initial Proxmox data for client:', error.message); - if (!initialNodesLogged) { - initialNodesLogged = true; // Prevent repeated warnings even on error + // --- Step 2: Fetch Detailed Status for Each Node (and merge IP) --- + const nodeStatusPromises = nodesToQuery.map(async (nodeName) => { + if (!nodeName) return null; + try { + const statusResponse = await proxmoxApi.get(`/nodes/${nodeName}/status`); + const statusData = statusResponse.data.data; + const basicInfo = basicNodeInfo.find(n => n.node === nodeName); + if (statusData) { + if (!statusData.node) { statusData.node = nodeName; } + statusData.ip = basicInfo?.ip || 'fetch_error'; + } else { + return { node: nodeName, status: 'unknown', ip: basicInfo?.ip || 'fetch_error' }; + } + return statusData; + } catch (statusError) { + console.warn(`[Discovery Cycle] Could not fetch status for node ${nodeName}: ${statusError.message}`); + const basicInfo = basicNodeInfo.find(n => n.node === nodeName); + return basicInfo + ? { ...basicInfo, status: 'offline', ip: basicInfo.ip || 'fetch_error' } + : { node: nodeName, status: 'offline', ip: 'fetch_error' }; } }); + const detailedNodeResults = await Promise.allSettled(nodeStatusPromises); + discoveryResult.nodes = detailedNodeResults + .filter(result => result.status === 'fulfilled' && result.value) + .map(result => result.value); + // Fallback if all status calls failed + if (discoveryResult.nodes.length === 0 && basicNodeInfo.length > 0) { + console.warn("[Discovery Cycle] All node status fetches failed. Falling back to basic node info from /nodes."); + discoveryResult.nodes = basicNodeInfo.map(node => ({ ...node, ip: node.ip || 'unknown' })); + } + + // --- Step 3: Fetch VM/Container Lists for Each Node --- + const finalNodeNames = discoveryResult.nodes.map(n => n.node).filter(Boolean); + const guestListPromises = finalNodeNames.map(async (nodeName) => { + let vms = []; + let containers = []; + try { + const vmsResponse = await proxmoxApi.get(`/nodes/${nodeName}/qemu`); + if (vmsResponse.data.data && Array.isArray(vmsResponse.data.data)) { + vms = vmsResponse.data.data.map(vm => ({ ...vm, node: nodeName })); + } + } catch (err) { console.error(`[Discovery Cycle] Error fetching VMs from ${nodeName}: ${err.message}`); } + try { + const ctsResponse = await proxmoxApi.get(`/nodes/${nodeName}/lxc`); + if (ctsResponse.data.data && Array.isArray(ctsResponse.data.data)) { + containers = ctsResponse.data.data.map(ct => ({ ...ct, node: nodeName })); + } + } catch (err) { console.error(`[Discovery Cycle] Error fetching containers from ${nodeName}: ${err.message}`); } + return { vms, containers }; + }); + + const guestListResults = await Promise.allSettled(guestListPromises); + guestListResults.forEach(result => { + if (result.status === 'fulfilled' && result.value) { + discoveryResult.vms.push(...result.value.vms); + discoveryResult.containers.push(...result.value.containers); + } + }); + + console.log(`[Discovery Cycle] Completed. Found: ${discoveryResult.nodes.length} nodes, ${discoveryResult.vms.length} VMs, ${discoveryResult.containers.length} containers.`); + return discoveryResult; +} + +/** + * Fetches dynamic metric data ONLY for currently known running VMs and Containers. + */ +async function fetchMetricsData(runningVms, runningContainers) { + // console.log(`[Metrics Cycle] Starting fetch for ${runningVms.length} VMs, ${runningContainers.length} CTs...`); // Reduced verbosity + let metrics = []; + + // --- Fetch VM Metrics --- + const vmMetricPromises = runningVms.map(async (vm) => { + try { + const [rrdData, currentData] = await Promise.all([ + proxmoxApi.get(`/nodes/${vm.node}/qemu/${vm.vmid}/rrddata`, { params: { timeframe: 'hour', cf: 'AVERAGE' } }), + proxmoxApi.get(`/nodes/${vm.node}/qemu/${vm.vmid}/status/current`) + ]); + let metricData = { + id: vm.vmid, name: vm.name, node: vm.node, type: 'qemu', data: [], + current: currentData?.data?.data || null + }; + if (rrdData?.data?.data?.length > 0) metricData.data = rrdData.data.data; + return metricData; + } catch (err) { + // Log less verbosely for metrics errors + // console.error(`[Metrics Cycle] Failed metrics for VM ${vm.vmid} on ${vm.node}: ${err.message}`); + return null; // Return null on error for this specific VM + } + }); + + // --- Fetch Container Metrics --- + const ctMetricPromises = runningContainers.map(async (ct) => { + try { + const [rrdData, currentData] = await Promise.all([ + proxmoxApi.get(`/nodes/${ct.node}/lxc/${ct.vmid}/rrddata`, { params: { timeframe: 'hour', cf: 'AVERAGE' } }), + proxmoxApi.get(`/nodes/${ct.node}/lxc/${ct.vmid}/status/current`) + ]); + let metricData = { + id: ct.vmid, name: ct.name, node: ct.node, type: 'lxc', data: [], + current: currentData?.data?.data || null + }; + if (rrdData?.data?.data?.length > 0) metricData.data = rrdData.data.data; + return metricData; + } catch (err) { + // console.error(`[Metrics Cycle] Failed metrics for CT ${ct.vmid} on ${ct.node}: ${err.message}`); + return null; // Return null on error for this specific container + } + }); + + // --- Combine Results --- + const allPromises = [...vmMetricPromises, ...ctMetricPromises]; + const metricResults = await Promise.allSettled(allPromises); + metricResults.forEach(result => { + if (result.status === 'fulfilled' && result.value) { + metrics.push(result.value); + } + }); + // console.log(`[Metrics Cycle] Completed. Fetched ${metrics.length} metric sets.`); // Reduced verbosity + return metrics; +} + +// --- Socket.io connection handling (Initial data fetch needs update) --- +io.on('connection', (socket) => { + console.log(`[socket] Client connected. Total clients: ${io.engine.clientsCount}`); + + // Send initial data immediately if available + if (currentNodes.length > 0 || currentVms.length > 0 || currentContainers.length > 0) { + console.log('[socket] Sending existing data to new client.'); + socket.emit('rawData', { + nodes: currentNodes, + vms: currentVms, + containers: currentContainers, + metrics: currentMetrics + }); + } else { + // If no data yet, trigger a discovery cycle (if not already running) + console.log('[socket] No data yet, triggering initial discovery for new client...'); + if (!isDiscoveryRunning) { + runDiscoveryCycle(); + } + } // Handle disconnect socket.on('disconnect', () => { - // connectedClients--; - REMOVED - // Use timeout to log count *after* socket.io updates internal count setTimeout(() => { console.log(`[socket] Client disconnected. Total clients: ${io.engine.clientsCount}`); + // Optional: Stop polling if client count drops to 0? (Handled in run cycles) }, 100); }); }); -// --- Use recursive setTimeout for reliable interval after async operations --- -let updateTimeoutId = null; // To potentially clear timeout if needed +// --- New Update Cycle Logic --- -async function runUpdateCycle() { - // Clear previous timeout ID if somehow it was still set (belt-and-suspenders) - if (updateTimeoutId) clearTimeout(updateTimeoutId); - updateTimeoutId = null; - - // Only poll if clients are connected - if (io.engine.clientsCount > 0) { - try { - console.log(`[interval] Updating raw data for ${io.engine.clientsCount} client(s)...`); - const data = await fetchRawProxmoxData(); - io.emit('rawData', data); - } catch (error) { - console.error(`[interval] Error during update: ${error.message}`); - } - } else { - // console.log('[interval] No clients connected, skipping Proxmox API poll.'); +// Discovery Cycle Runner +async function runDiscoveryCycle() { + if (isDiscoveryRunning) { + // console.log('[Discovery Cycle] Already running, skipping.'); + return; } + isDiscoveryRunning = true; - // Schedule the next update cycle regardless of errors or client count - // This ensures polling resumes when clients reconnect - scheduleNextUpdate(); + try { + const discoveryData = await fetchDiscoveryData(); + // Update global state + currentNodes = discoveryData.nodes; + currentVms = discoveryData.vms; + currentContainers = discoveryData.containers; + + // Emit combined data only if clients are connected + if (io.engine.clientsCount > 0) { + console.log('[Discovery Cycle] Emitting updated structural data.'); + io.emit('rawData', { + nodes: currentNodes, + vms: currentVms, + containers: currentContainers, + metrics: currentMetrics // Include latest metrics + }); + // Trigger metrics immediately after discovery if needed? + // if (!isMetricsRunning) runMetricCycle(); + } + } catch (error) { + console.error(`[Discovery Cycle] Error during execution: ${error.message}`); + } finally { + isDiscoveryRunning = false; + // Schedule the next discovery cycle + scheduleNextDiscovery(); + } } -function scheduleNextUpdate() { - // Schedule the next run after UPDATE_INTERVAL milliseconds - updateTimeoutId = setTimeout(runUpdateCycle, UPDATE_INTERVAL); +// Metric Cycle Runner +async function runMetricCycle() { + if (isMetricsRunning) { + // console.log('[Metrics Cycle] Already running, skipping.'); + return; + } + // Only run if clients are connected + if (io.engine.clientsCount === 0) { + // console.log('[Metrics Cycle] No clients connected, skipping fetch.'); + scheduleNextMetric(); // Still schedule next check + return; + } + + isMetricsRunning = true; + + try { + // Filter for running guests based on current state + const runningVms = currentVms.filter(vm => vm.status === 'running'); + const runningContainers = currentContainers.filter(ct => ct.status === 'running'); + + if (runningVms.length > 0 || runningContainers.length > 0) { + currentMetrics = await fetchMetricsData(runningVms, runningContainers); + // Emit combined data + // console.log('[Metrics Cycle] Emitting updated metrics data.'); // Reduced verbosity + io.emit('rawData', { + nodes: currentNodes, + vms: currentVms, + containers: currentContainers, + metrics: currentMetrics + }); + } else { + // console.log('[Metrics Cycle] No running guests found, skipping metric fetch.'); + currentMetrics = []; // Clear metrics if no guests running + // Optionally emit state if metrics were cleared? + io.emit('rawData', { + nodes: currentNodes, vms: currentVms, + containers: currentContainers, metrics: currentMetrics + }); + } + + } catch (error) { + console.error(`[Metrics Cycle] Error during execution: ${error.message}`); + } finally { + isMetricsRunning = false; + // Schedule the next metric cycle + scheduleNextMetric(); + } } -// Start the first update cycle -scheduleNextUpdate(); -// --- End recursive setTimeout implementation --- +// Schedulers using setTimeout +function scheduleNextDiscovery() { + if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId); + discoveryTimeoutId = setTimeout(runDiscoveryCycle, DISCOVERY_UPDATE_INTERVAL); +} -// Periodic update interval for all connected clients - REMOVED -/* -const updateInterval = setInterval(async () => { - // Only poll if clients are connected - if (io.engine.clientsCount > 0) { - try { - console.log(`[interval] Updating raw data for ${io.engine.clientsCount} client(s)...`); - const data = await fetchRawProxmoxData(); - io.emit('rawData', data); - } catch (error) { - console.error(`[interval] Error during update: ${error.message}`); - } - } else { - // Optional: Log that polling is skipped - // console.log('[interval] No clients connected, skipping Proxmox API poll.'); - } -}, UPDATE_INTERVAL); -*/ +function scheduleNextMetric() { + if (metricTimeoutId) clearTimeout(metricTimeoutId); + metricTimeoutId = setTimeout(runMetricCycle, METRIC_UPDATE_INTERVAL); +} + +// Start the initial cycles +console.log('Starting initial data fetch cycles...'); +runDiscoveryCycle(); // Run discovery first +// Metrics will be triggered after discovery or by its own timer if clients connect later +scheduleNextMetric(); // Start scheduling metrics right away + +// --- End New Update Cycle Logic --- // Start the server server.listen(PORT, () => {