feat: Apply UI/UX and performance improvements

This commit is contained in:
courtmanr@gmail.com
2025-04-21 16:09:04 +01:00
parent 49e6e70d75
commit fd374b6c66
3 changed files with 840 additions and 306 deletions
+441 -133
View File
@@ -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 `
<!-- Outer container with lighter background - REMOVED title attribute -->
<div
class=\"w-full rounded h-4 relative overflow-hidden bg-gray-100 dark:bg-gray-700/50\"
>
<!-- Inner div represents the actual progress, with opacity -->
<div
class=\"absolute top-0 left-0 h-full rounded ${colorClass} opacity-50\"
style="width: ${numericPercent}%;"
>
</div>
<!-- Text is centered within the outer container -->
<span class="absolute inset-0 flex items-center justify-center text-xs font-medium ${textColorClass} px-1 truncate">
${text}
</span>
</div>
`;
};
// --- 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 = '<tr><td colspan="7" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found</td></tr>';
// Corrected colspan to match the actual number of columns (5)
tbody.innerHTML = '<tr><td colspan="5" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found or data unavailable</td></tr>';
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 = `
<td class="p-2 px-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">${node.node || 'N/A'}</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
<span class="flex items-center">
<span class="h-2.5 w-2.5 rounded-full ${statusColor} mr-2"></span>
${node.status || 'N/A'}
<td class=\"p-1 px-2 whitespace-nowrap\">
<span class=\"flex items-center\">
<span class=\"h-2.5 w-2.5 rounded-full ${statusColor} mr-2 flex-shrink-0\"></span>
<span class=\"capitalize\">${statusText}</span>
</span>
</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2.5 group relative">
<div class="${cpuColorClass} h-2.5 rounded-full" style="width: ${cpuPercent.toFixed(1)}%"></div>
<span class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-1 hidden group-hover:block px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap">
${cpuPercent.toFixed(1)}%
</span>
</div>
</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2.5 group relative">
<div class="${memColorClass} h-2.5 rounded-full" style="width: ${memPercent.toFixed(1)}%"></div>
<span class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-1 hidden group-hover:block px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap">
${formatBytes(node.mem)} / ${formatBytes(node.maxmem)} (${memPercent.toFixed(1)}%)
</span>
</div>
</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300 text-right">${formatBytes(node.maxmem)}</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300 text-right">${formatUptime(node.uptime)}</td>
<td class="p-2 px-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">${node.ip || 'N/A'}</td>
<td class=\"p-1 px-2 whitespace-nowrap font-medium text-gray-900 dark:text-gray-100\" title=\"${node.node || 'N/A'}\">${node.node || 'N/A'}</td>
<!-- Add metric-tooltip-trigger and data-tooltip for custom tooltip -->
<td class=\"p-1 px-2 text-right\">${cpuBarHTML}</td>
<td class=\"p-1 px-2 text-right\">${memoryBarHTML}</td>
<td class=\"p-1 px-2 text-right\">${diskBarHTML}</td>
`;
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 = `<p class="p-4 text-red-700 dark:text-red-300">Error: ${storage.globalError}</p>`;
return;
}
if (!storage || Object.keys(storage).length === 0) {
// Empty message styling - remove card styles, just use text/padding
contentDiv.innerHTML = '<p class="text-gray-500 dark:text-gray-400 p-4 text-center">No storage data available or failed to load for any node.</p>';
return;
}
// --- Helper function for Storage Icons ---
function getStorageTypeIcon(type) {
// Simple icons using Tailwind/SVG - can be expanded
switch(type) {
case 'dir':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-yellow-600 dark:text-yellow-400"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; // Folder
case 'lvm':
case 'lvmthin':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-purple-600 dark:text-purple-400"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>'; // Database (representing logical volume)
case 'zfs':
case 'zfspool':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-red-600 dark:text-red-400"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>'; // Activity (representing ZFS complexity/features)
case 'nfs':
case 'cifs':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-blue-600 dark:text-blue-400"><path d="M16 17l5-5-5-5"></path><path d="M8 17l-5-5 5-5"></path></svg>'; // Share-2
case 'cephfs':
case 'rbd':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-indigo-600 dark:text-indigo-400"><path d="M18 8h1a4 4 0 0 1 0 8h-1"></path><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path><line x1="6" y1="1" x2="6" y2="4"></line><line x1="10" y1="1" x2="10" y2="4"></line><line x1="14" y1="1" x2="14" y2="4"></line></svg>'; // Server (representing distributed storage)
default:
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-gray-500"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>'; // 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 = `
<tr class="border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 sticky top-0 z-10">
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-3/12">Storage</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-2/12">Content</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-1/12">Type</th>
<th class="text-center p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-[80px]">Shared</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Usage</th>
<th class="text-right p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-1/12">Avail</th>
<th class="text-right p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 w-1/12">Total</th>
</tr>
`;
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 = `
<td colspan="7" class="p-1.5 px-3">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
Node: ${nodeName}
</td>`;
tbody.appendChild(nodeHeaderRow);
// Handle errors or empty data for this specific node
if (nodeStorageData.error) {
const errorRow = document.createElement('tr');
errorRow.innerHTML = `<td colspan="7" class="p-2 px-3 text-sm text-red-600 dark:text-red-400 italic">Error loading storage: ${nodeStorageData.error}</td>`;
tbody.appendChild(errorRow);
return; // Skip to next node
}
if (!Array.isArray(nodeStorageData) || nodeStorageData.length === 0) {
const noDataRow = document.createElement('tr');
noDataRow.innerHTML = `<td colspan="7" class="p-2 px-3 text-sm text-gray-500 dark:text-gray-400 italic">No storage configured or found for this node.</td>`;
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 ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block text-green-600 dark:text-green-400"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block text-gray-400 dark:text-gray-500 opacity-50"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`;
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 `<span data-tooltip="${details.tooltip}" class="storage-tooltip-trigger inline-block ${details.badgeClass} rounded px-1.5 py-0.5 text-xs font-medium mr-1 mb-1 cursor-default">${ct}</span>`;
}).join('');
const usageBarHTML = createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass);
row.innerHTML = `
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-900 dark:text-gray-100 font-medium">${store.storage || 'N/A'}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-600 dark:text-gray-300 text-xs">${contentBadges || '-'}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-600 dark:text-gray-300">${store.type || 'N/A'}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-center storage-tooltip-trigger cursor-default" data-tooltip="${sharedIconTooltip}">${sharedIcon}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-600 dark:text-gray-300">${usageBarHTML}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-600 dark:text-gray-300 text-right">${formatBytes(store.avail)}</td>
<td class="p-2 px-3 py-1 whitespace-nowrap text-gray-600 dark:text-gray-300 text-right">${formatBytes(store.total)}</td>
`;
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 = `<span class="type-icon inline-block w-5 h-5 leading-5 text-center rounded text-[10px] font-bold align-middle ${typeIconClass}">${guest.type}</span>`;
? '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 = `<span class="type-icon inline-block rounded text-xs align-middle ${typeIconClass}">${guest.type}</span>`;
row.innerHTML = `
<td class="p-1 px-2 whitespace-nowrap truncate" title="${guest.name}">${guest.name}</td>
<td class="p-1 px-1 text-center">${typeIcon}</td>
<td class="p-1 px-2 text-center">${guest.id}</td>
<td class="p-1 px-2 whitespace-nowrap">${formatUptime(guest.uptime)}</td>
<td class="p-1 px-2 text-center">${cpuUsageText}</td>
<td class="p-1 px-2 text-center">${memoryUsageText}</td>
<td class="p-1 px-2 text-center">${diskUsageText}</td>
<td class="p-1 px-2">${cpuBarHTML}</td>
<td class="p-1 px-2">${memoryBarHTML}</td>
<td class="p-1 px-2">${diskBarHTML}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.diskread)}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.diskwrite)}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.netin)}</td>
@@ -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, '&quot;');
return `<span class="${colorClass} metric-tooltip-trigger cursor-default" data-tooltip="${safeTooltipText}">${displayPercentage}</span>`;
}
// --- 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 ---
+32 -20
View File
@@ -94,6 +94,7 @@
<!-- Add Tailwind classes, JS will toggle active classes -->
<div class="tab active px-3 py-1.5 cursor-pointer bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 rounded-t text-sm -mb-px" data-tab="main">Main</div>
<div class="tab px-3 py-1.5 cursor-pointer bg-gray-100 dark:bg-gray-700/50 border-transparent text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-t" data-tab="nodes">Nodes</div>
<div class="tab px-3 py-1.5 cursor-pointer bg-gray-100 dark:bg-gray-700/50 border-transparent text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-t" data-tab="storage">Storage</div>
</div>
<!-- Removed outer .section wrapper, apply styles directly to tab content where needed -->
@@ -101,34 +102,34 @@
<div id="nodes" class="tab-content hidden bg-white dark:bg-gray-800 rounded-b rounded-tr shadow p-3 mb-2">
<h2 class="text-lg font-medium mb-2 text-gray-800 dark:text-gray-200">Nodes</h2>
<div class="overflow-x-auto">
<table id="nodes-table" class="w-full border-collapse text-sm">
<thead>
<tr class="border-b border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
<th class="sortable text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none" data-sort="id">ID</th>
<th class="sortable text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none" data-sort="status">Status</th>
<th class="sortable text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none w-1/6" data-sort="cpu">CPU Usage</th>
<th class="sortable text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none w-1/6" data-sort="mem">Mem Usage</th>
<th class="sortable text-right p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none" data-sort="maxmem">Mem Total</th>
<th class="sortable text-right p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none" data-sort="uptime">Uptime</th>
<th class="sortable text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300 cursor-pointer select-none" data-sort="ip">IP</th>
<table id="nodes-table" class="min-w-full divide-y divide-gray-700 table-fixed">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 sticky top-0 z-10">
<tr>
<th scope="col" class="sortable px-6 py-3 w-2/12 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="status">Status</th>
<th scope="col" class="sortable px-6 py-3 w-4/12 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="node">Node Name</th>
<th scope="col" class="sortable px-6 py-3 w-2/12 text-right font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="cpu">CPU</th>
<th scope="col" class="sortable px-6 py-3 w-2/12 text-right font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="memory">Mem</th>
<th scope="col" class="sortable px-6 py-3 w-2/12 text-right font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="disk">Disk</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td colspan="7" class="p-3 text-center text-gray-500 dark:text-gray-400">Loading data...</td>
</tr>
<!-- Rows added by JS -->
<tbody id="nodes-table-body" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Node rows will be inserted here -->
<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">Loading node data...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="main" class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow p-3 mb-2">
<!-- --- Dashboard Filter Bar --- -->
<div class="dashboard-filter flex flex-col md:flex-row justify-between items-stretch md:items-center gap-3 mb-3 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<div class="dashboard-filter-controls flex-grow flex items-center gap-2">
<input type="text" id="dashboard-search" placeholder="Search (use ',' for OR)" class="flex-grow p-1 px-2 h-7 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none" />
</div>
<!-- Reset Button -->
<button id="reset-filters-button" title="Reset Filters & Sort (Esc)" class="p-1 h-7 w-7 flex-shrink-0 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg> <!-- RotateLeft icon -->
</button>
</div>
<!-- Combined Toggle Wrapper -->
<div class="filter-toggles-wrapper flex items-center gap-4 flex-wrap">
<!-- View Toggle -->
@@ -173,9 +174,9 @@
<col class="type-col" style="width: 50px;">
<col class="id-col" style="width: 50px;">
<col class="uptime-col" style="width: var(--uptime-col-width, 80px);">
<col class="usage-col" style="width: 70px;"> <!-- CPU -->
<col class="usage-col" style="width: 70px;"> <!-- Mem -->
<col class="usage-col" style="width: 70px;"> <!-- Disk -->
<col class="usage-col">
<col class="usage-col">
<col class="usage-col">
<col class="net-disk-col" style="width: 80px;">
<col class="net-disk-col" style="width: 80px;">
<col class="net-disk-col" style="width: 80px;">
@@ -212,6 +213,17 @@
</div>
<span id="dashboard-status-text" class="text-xs text-gray-500 dark:text-gray-400">Loading dashboard data...</span>
</div>
<div id="storage" class="tab-content hidden bg-white dark:bg-gray-800 rounded-b rounded-tr shadow p-3 mb-2">
<h2 class="text-lg font-medium mb-2 text-gray-800 dark:text-gray-200">Storage Information</h2>
<!-- Add table container wrapper similar to main dashboard -->
<div class="table-container max-h-[80vh] overflow-y-auto mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<div id="storage-info-content">
<p class="text-gray-500 dark:text-gray-400 p-4 text-center">Loading storage data...</p> <!-- Keep placeholder -->
<!-- Storage table will be populated here by JS -->
</div>
</div>
</div>
</div>
<!-- Custom Tooltip Element - Styled with Tailwind -->
+367 -153
View File
@@ -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, () => {