diff --git a/server/index.js b/server/index.js index ea3cd5694..eb653930a 100644 --- a/server/index.js +++ b/server/index.js @@ -110,6 +110,7 @@ const cors = require('cors'); const { Server } = require('socket.io'); const axios = require('axios'); const https = require('https'); +const { URL } = require('url'); // <--- ADD: Import URL constructor const axiosRetry = require('axios-retry').default; // Import axios-retry // Development specific dependencies @@ -140,12 +141,22 @@ function loadPbsConfig(index = null) { const portVar = `PBS_PORT${suffix}`; const selfSignedVar = `PBS_ALLOW_SELF_SIGNED_CERTS${suffix}`; - const pbsHost = process.env[hostVar]; - if (!pbsHost) { + const pbsHostUrl = process.env[hostVar]; // Rename variable to reflect it's a URL + if (!pbsHostUrl) { // No more PBS configs if PBS_HOST is missing return false; // Indicate no more configs found } + // ---> ADDED: URL Parsing for Hostname Fallback <---\ + let pbsHostname = pbsHostUrl; // Default to full URL if parsing fails + try { + const parsedUrl = new URL(pbsHostUrl); + pbsHostname = parsedUrl.hostname; // Extract just the hostname + } catch (e) { + console.warn(`WARN: Could not parse PBS_HOST URL "${pbsHostUrl}". Using full value as fallback name.`); + } + // ---> END ADDED <---\ + const pbsUser = process.env[userVar]; const pbsPassword = process.env[passVar]; const pbsTokenId = process.env[tokenIdVar]; @@ -157,7 +168,7 @@ function loadPbsConfig(index = null) { // Check User/Password first if (pbsUser && pbsPassword) { const pbsPlaceholders = placeholderValues.filter(p => - pbsHost.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p) + pbsHostUrl.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p) // Check against URL ); if (pbsPlaceholders.length > 0) { console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (User/Pass). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`); @@ -165,8 +176,8 @@ function loadPbsConfig(index = null) { config = { id: `${idPrefix}_userpass`, authMethod: 'userpass', - name: process.env[nodeNameVar] || pbsHost, // User-defined name or host - host: pbsHost, + name: process.env[nodeNameVar] || pbsHostname, // User-defined name or parsed hostname + host: pbsHostUrl, // Keep original full URL here port: process.env[portVar] || '8007', user: pbsUser, password: pbsPassword, @@ -177,11 +188,11 @@ function loadPbsConfig(index = null) { }; console.log(`INFO: Found PBS configuration ${index || 'primary'} (User/Password): ${config.name} (${config.host})`); } - } + } // Check Token second else if (pbsTokenId && pbsTokenSecret) { const pbsPlaceholders = placeholderValues.filter(p => - pbsHost.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) + pbsHostUrl.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) // Check against URL ); if (pbsPlaceholders.length > 0) { console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (Token). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`); @@ -189,8 +200,8 @@ function loadPbsConfig(index = null) { config = { id: `${idPrefix}_token`, authMethod: 'token', - name: process.env[nodeNameVar] || pbsHost, - host: pbsHost, + name: process.env[nodeNameVar] || pbsHostname, + host: pbsHostUrl, // Keep original full URL here port: process.env[portVar] || '8007', tokenId: pbsTokenId, tokenSecret: pbsTokenSecret, @@ -200,7 +211,7 @@ function loadPbsConfig(index = null) { }; console.log(`INFO: Found PBS configuration ${index || 'primary'} (API Token): ${config.name} (${config.host})`); } - } + } // Warn if host is set but auth is incomplete else { console.warn(`WARN: Partial PBS configuration found for ${hostVar}. Please set either (${userVar} + ${passVar}) or (${tokenIdVar} + ${tokenSecretVar}) along with ${hostVar}.`); @@ -914,14 +925,45 @@ async function fetchDiscoveryData() { // Only proceed if we have a node name if (instanceData.nodeName) { - // Fetch datastores and the consolidated task list concurrently - const [datastoresResult, allTasksResult] = await Promise.all([ - fetchPbsDatastoreData({ client: pbsClientInstance, config: pbsInstanceConfig }), - fetchAllPbsTasksForProcessing({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName) // New function call - ]); + // Fetch datastores first, then snapshots, then tasks + const datastoresResult = await fetchPbsDatastoreData({ client: pbsClientInstance, config: pbsInstanceConfig }); + + // Fetch snapshots for each datastore + const snapshotFetchPromises = (datastoresResult || []).map(async (ds) => { + const storeName = ds.name; // Assuming 'name' holds the datastore ID + if (!storeName) { + console.warn(`WARN: [PBS Discovery - ${instanceName}] Skipping snapshot fetch for datastore with no name:`, ds); + ds.snapshots = []; // Ensure snapshots array exists even if skipped + ds.snapshotError = 'Missing datastore name'; + return ds; // Return the datastore object as is + } + try { + console.log(`INFO: [PBS Discovery - ${instanceName}] Fetching snapshots for datastore '${storeName}'...`); + const snapshotResponse = await pbsClientInstance.get(`/admin/datastore/${storeName}/snapshots`); + ds.snapshots = snapshotResponse.data?.data ?? []; + ds.snapshotError = null; + // console.log(`INFO: [PBS Discovery - ${instanceName}] Fetched ${ds.snapshots.length} snapshots for datastore ${storeName}.`); + } catch (snapshotError) { + const status = snapshotError.response?.status ? ` (Status: ${snapshotError.response.status})` : ''; + console.error(`ERROR: [PBS Discovery - ${instanceName}] Failed to fetch snapshots for datastore ${storeName}${status}: ${snapshotError.message}`); + ds.snapshots = []; // Ensure snapshots array exists on error + ds.snapshotError = snapshotError.message; + // Propagate specific auth errors if needed + if (snapshotError.response?.status === 401 || snapshotError.response?.status === 403) { + // Optionally re-throw or handle critical permission errors differently + } + } + return ds; // Return the datastore object with snapshots added + }); + + // Wait for all snapshot fetches for this instance to complete + const datastoresWithSnapshots = await Promise.all(snapshotFetchPromises); + + // Fetch tasks after getting datastores and snapshots + const allTasksResult = await fetchAllPbsTasksForProcessing({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName); // New function call // Assign datastore results - instanceData.datastores = datastoresResult; + instanceData.datastores = datastoresWithSnapshots; // Use the array that now includes snapshots // Process the single task list for all summaries and details if (allTasksResult && allTasksResult.tasks) { diff --git a/src/public/app.js b/src/public/app.js index 4c5b83d75..76397b283 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -91,33 +91,41 @@ document.addEventListener('DOMContentLoaded', function() { // --- Tab Functionality --- const tabs = document.querySelectorAll('.tab'); const tabContents = document.querySelectorAll('.tab-content'); - let showTab = 'main'; // Default visible tab tabs.forEach(tab => { tab.addEventListener('click', () => { - const tabId = tab.getAttribute('data-tab'); - + // Deactivate all tabs and hide all content tabs.forEach(t => { - t.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border', 'border-gray-300', 'dark:border-gray-700', 'border-b-0', '-mb-px'); + // Remove active classes, add inactive classes + t.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white'); t.classList.add('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700'); }); - tab.classList.add('active', 'bg-white', 'dark:bg-gray-800', 'border', 'border-gray-300', 'dark:border-gray-700', 'border-b-0', '-mb-px'); + tabContents.forEach(content => content.classList.add('hidden')); + + // Activate clicked tab and show its content + // Add active classes, remove inactive classes + tab.classList.add('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white', '-mb-px'); tab.classList.remove('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700'); - - tabContents.forEach(content => { - content.classList.remove('block'); - content.classList.add('hidden'); - if (content.id === tabId) { - content.classList.remove('hidden'); - content.classList.add('block'); + + const tabId = tab.getAttribute('data-tab'); + const activeContent = document.getElementById(tabId); + if (activeContent) { + activeContent.classList.remove('hidden'); + // If switching to dashboard, re-apply filter (in case data updated while on another tab) + if (tabId === 'main') { + applyDashboardFilters(); } - }); - - showTab = tabId; // Update global state - // Potentially trigger data refresh if needed for the specific tab + // ---> ADDED: Trigger Backups Tab update if switching to it <--- + if (tabId === 'backups') { + updateBackupsTab(); // Call the update function when tab is selected + } + // ---> END ADDED <--- + } }); }); + // --- End Tab Switching Logic --- + // --- Data Storage and State --- let nodesData = []; let vmsData = []; @@ -129,24 +137,19 @@ document.addEventListener('DOMContentLoaded', function() { const savedSortState = JSON.parse(localStorage.getItem('pulseSortState')) || {}; const sortState = { nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) }, - main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) } - // ---> REMOVE: PBS sort state <--- - // pbsBackup: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsBackup || {}) }, - // pbsVerify: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsVerify || {}) }, - // pbsSync: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsSync || {}) }, - // pbsPruneGc: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsPruneGc || {}) } - // ---> END REMOVE <--- + main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) }, + backups: { column: 'latestBackupTime', direction: 'desc', ...(savedSortState.backups || {}) } }; let groupByNode = true; // Default view - let filterGuestType = 'all'; // Default filter + let filterGuestType = 'all'; // Restore this state variable const AVERAGING_WINDOW_SIZE = 5; 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 - // ---> REMOVE: pbsConfigured flag is less relevant now - // let pbsConfigured = false; // Flag to track if PBS is configured - // <--- END REMOVE + // ---> ADDED: State for Backups Tab Filters <--- + let backupsFilterHealth = 'all'; // 'ok', 'warning', 'error', 'none' + // ---> END RENAMED <--- // Define initial limit for PBS task tables const INITIAL_PBS_TASK_LIMIT = 5; @@ -219,6 +222,10 @@ document.addEventListener('DOMContentLoaded', function() { derivedKey = 'nodes'; } else if (tableId.startsWith('main-')) { derivedKey = 'main'; + // ---> ADDED: Handle backups table < --- + } else if (tableId.startsWith('backups-')) { + derivedKey = 'backups'; + // ---> END ADDED < --- } else { derivedKey = null; } @@ -282,8 +289,8 @@ document.addEventListener('DOMContentLoaded', function() { // Save updated sort state to localStorage (Only relevant keys) const stateToSave = { nodes: sortState.nodes, - main: sortState.main - // Note: PBS sort state is not saved to localStorage currently + main: sortState.main, + backups: sortState.backups }; localStorage.setItem('pulseSortState', JSON.stringify(stateToSave)); @@ -293,6 +300,9 @@ document.addEventListener('DOMContentLoaded', function() { case 'vms': updateVmsTable(vmsData); break; case 'containers': updateContainersTable(containersData); break; case 'main': updateDashboardTable(); break; + // ---> ADDED: Trigger update for backups table < --- + case 'backups': updateBackupsTab(); break; + // ---> END ADDED < --- default: console.error('Unknown table type for sorting:', tableType); } @@ -306,6 +316,7 @@ document.addEventListener('DOMContentLoaded', function() { // setupTableSorting('vms-table'); // Removed - Table doesn't exist in base HTML // setupTableSorting('containers-table'); // Removed - Table doesn't exist in base HTML setupTableSorting('main-table'); + setupTableSorting('backups-overview-table'); // Setup sorting for the backups table // --- Filtering Logic --- // Grouping Filter @@ -320,11 +331,13 @@ document.addEventListener('DOMContentLoaded', function() { }); // Type Filter + // Restore the event listener for main dashboard type filter document.querySelectorAll('input[name="type-filter"]').forEach(radio => { radio.addEventListener('change', function() { if (this.checked) { - filterGuestType = this.value; - updateDashboardTable(); + filterGuestType = this.value; // Use the restored state variable + updateDashboardTable(); // Update the main dashboard table + // REMOVED: updateBackupsTab(); // Don't update backups tab from here if (searchInput) searchInput.dispatchEvent(new Event('input')); // Re-apply text filter } }); @@ -351,6 +364,31 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // ---> ADDED: Event listeners for Backups Tab Filters <---\ + // Backup Type Filter - REMOVED + /* + document.querySelectorAll('input[name="backups-type-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + backupsFilterType = this.value; + updateBackupsTab(); // Only update the backups tab + } + }); + }); + */ + + // ---> MODIFIED: Event listener for Backups Health Filter <--- + // Backup Status/Age Filter -> Backup Health Filter + document.querySelectorAll('input[name="backups-status-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + backupsFilterHealth = this.value; // Update the health filter state + updateBackupsTab(); // Only update the backups tab + } + }); + }); + // ---> END MODIFIED <--- + // --- Data Sorting Function --- function sortData(data, column, direction, type) { if (!column || !data) return data || []; // Return empty array if data is null/undefined @@ -363,9 +401,9 @@ document.addEventListener('DOMContentLoaded', function() { // Use a helper to get comparable values, handling potential missing data const getValue = (item, col) => { - if (!item) return type === 'string' ? '' : 0; // Default value based on expected type + if (!item) return type === 'string' ? '' : (col === 'latestBackupTime' ? null : 0); // Handle backups time 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 @@ -379,10 +417,27 @@ document.addEventListener('DOMContentLoaded', function() { // 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; + // ---> ADDED: Handle backup table specific columns < --- + else if (type === 'backups') { + // Map health status to a sortable value (e.g., Failed=1, Old=2, Stale=3, None=4, OK=5) + if (col === 'backupHealthStatus') { + switch (item[col]) { + case 'failed': return 1; + case 'old': return 2; + case 'stale': return 3; + case 'none': return 4; + case 'ok': return 5; + default: return 0; // Should not happen + } + } + if (col === 'guestId' || col === 'totalBackups') val = parseInt(item[col] || 0); + if (col === 'latestBackupTime') val = item[col]; // Keep as timestamp (number or null) + } + // ---> END ADDED < --- // ... other specific cases ... - + // Fallback for other types or columns - return val ?? (type === 'string' ? '' : 0); // Use default if null/undefined + return val ?? (type === 'string' ? '' : (col === 'latestBackupTime' ? null : 0)); // Use default if null/undefined }; // Handle specific sorting logic for nodes @@ -409,20 +464,29 @@ document.addEventListener('DOMContentLoaded', function() { valueB = getValue(b, column); } - // Determine type for comparison (Now should favor number for percentage/uptime/loadavg columns) - const compareType = (typeof valueA === 'number' && typeof valueB === 'number') ? 'number' : 'string'; + // Determine type for comparison (Now should favor number for percentage/uptime/loadavg/timestamp columns) + // ---> MODIFIED: Handle null timestamps for backups < --- + const compareType = (typeof valueA === 'number' && typeof valueB === 'number') || (column === 'latestBackupTime' && (typeof valueA === 'number' || valueA === null) && (typeof valueB === 'number' || valueB === null)) + ? 'number' + : 'string'; // Comparison logic if (compareType === 'string') { - valueA = String(valueA).toLowerCase(); - valueB = String(valueB).toLowerCase(); + valueA = String(valueA ?? '').toLowerCase(); // Handle potential null/undefined + valueB = String(valueB ?? '').toLowerCase(); // Handle potential null/undefined return direction === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); - } else { - // Ensure numeric comparison + } else { // Numeric comparison (including timestamps) + // Treat null timestamps as very old (or very new if sorting asc) + if (valueA === null && valueB === null) return 0; + if (valueA === null) return direction === 'asc' ? -1 : 1; + if (valueB === null) return direction === 'asc' ? 1 : -1; + + // Ensure numeric comparison for non-null numbers valueA = parseFloat(valueA) || 0; valueB = parseFloat(valueB) || 0; return direction === 'asc' ? valueA - valueB : valueB - valueA; } + // ---> END MODIFIED < --- }); } @@ -1115,34 +1179,41 @@ document.addEventListener('DOMContentLoaded', function() { mainTableBody.innerHTML = ''; // Clear const currentSearchTerm = searchInput ? searchInput.value.toLowerCase() : ''; + const searchTerms = currentSearchTerm.split(',').map(term => term.trim()).filter(term => term); - // Apply type filter - const typeFilteredData = (dashboardData || []).filter(guest => - filterGuestType === 'all' || (guest.type && guest.type.toLowerCase() === filterGuestType) - ); + // ---> REVISED FILTERING LOGIC <--- + // 1. Start with the raw dashboardData + let dataToProcess = dashboardData || []; - // Apply status filter - const statusFilteredData = typeFilteredData.filter(guest => - filterStatus === 'all' || guest.status === filterStatus - ); + // 2. Apply sorting first (using the current state) + let sortedData = sortData(dataToProcess, sortState.main.column, sortState.main.direction, 'main'); - // Apply sorting - const sortedData = sortData(statusFilteredData, sortState.main.column, sortState.main.direction, 'main'); - - // Apply search filter - const searchTerms = (searchInput ? searchInput.value.toLowerCase().split(',').map(term => term.trim()).filter(term => term) : []); - - // Filter data based on search terms, type filter, and status filter + // 3. Apply all filters (type, status, search) in one pass let filteredData = sortedData.filter(item => { - const typeMatch = (filterGuestType === 'all' || (item.type && item.type.toLowerCase() === filterGuestType)); - const statusMatch = (filterStatus === 'all' || item.status === filterStatus); - const nameMatch = searchTerms.length === 0 || searchTerms.some(term => - (item.name?.toLowerCase() || '').includes(term) || - (item.node?.toLowerCase() || '').includes(term) || // Allow searching node name - (item.id?.toString() || '').includes(term) // Allow searching ID - ); - return typeMatch && statusMatch && nameMatch; // Combine all filters + // Check Status Filter + const statusMatch = (filterStatus === 'all' || item.status === filterStatus); + if (!statusMatch) return false; // Early exit if status doesn't match + + // Check Type Filter + const typeMatch = (filterGuestType === 'all') || + (filterGuestType === 'vm' && item.type === 'VM') || + (filterGuestType === 'ct' && item.type === 'CT'); + if (!typeMatch) return false; // Early exit if type doesn't match + + // Check Search Filter (only if terms exist) + if (searchTerms.length > 0) { + const nameMatch = searchTerms.some(term => + (item.name?.toLowerCase() || '').includes(term) || + (item.node?.toLowerCase() || '').includes(term) || + (item.id?.toString() || '').includes(term) + ); + if (!nameMatch) return false; // Early exit if search doesn't match + } + + // If all checks passed, include the item + return true; }); + // ---> END REVISED FILTERING LOGIC <--- // Group data if needed const nodeGroups = {}; @@ -1183,9 +1254,8 @@ document.addEventListener('DOMContentLoaded', function() { // Handle empty table states if (visibleCount === 0) { const filterText = currentSearchTerm ? ` match filter "${currentSearchTerm}"` : ''; - const typeText = filterGuestType !== 'all' ? filterGuestType.toUpperCase() + 's' : 'guests'; const statusText = filterStatus !== 'all' ? ` (${filterStatus})` : ''; // Add status to the message - mainTableBody.innerHTML = `