mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat: Apply UI/UX and performance improvements
This commit is contained in:
+441
-133
@@ -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, '"');
|
||||
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
@@ -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
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user