diff --git a/server/dataFetcher.js b/server/dataFetcher.js index a8dcfce4d..7f2c7b871 100644 --- a/server/dataFetcher.js +++ b/server/dataFetcher.js @@ -583,6 +583,148 @@ async function fetchAllPbsTasksForProcessing({ client, config }, nodeName) { } } +/** + * Fetches PVE backup tasks (vzdump) for a specific node. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @returns {Promise} - Array of backup task objects. + */ +async function fetchPveBackupTasks(apiClient, endpointId, nodeName) { + try { + const response = await apiClient.get(`/nodes/${nodeName}/tasks`, { + params: { + typefilter: 'vzdump', + limit: 1000 + } + }); + const tasks = response.data?.data || []; + + // Calculate 30-day cutoff timestamp + const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000); + + // Filter to last 30 days and transform to match PBS backup task format + return tasks + .filter(task => task.starttime >= thirtyDaysAgo) + .map(task => { + // Extract guest info from task description or ID + let guestId = null; + let guestType = null; + + // Try to extract from task description (e.g., "vzdump VM 100") + const vmMatch = task.type?.match(/VM\s+(\d+)/i) || task.id?.match(/VM\s+(\d+)/i); + const ctMatch = task.type?.match(/CT\s+(\d+)/i) || task.id?.match(/CT\s+(\d+)/i); + + if (vmMatch) { + guestId = vmMatch[1]; + guestType = 'vm'; + } else if (ctMatch) { + guestId = ctMatch[1]; + guestType = 'ct'; + } else if (task.id) { + // Try to extract from task ID format + const idMatch = task.id.match(/vzdump-(\w+)-(\d+)/); + if (idMatch) { + guestType = idMatch[1] === 'qemu' ? 'vm' : 'ct'; + guestId = idMatch[2]; + } + } + + return { + type: 'backup', + status: task.status || 'unknown', + starttime: task.starttime, + endtime: task.endtime || (task.starttime + 60), + node: nodeName, + guest: guestId ? `${guestType}/${guestId}` : task.id, + guestType: guestType, + guestId: guestId, + upid: task.upid, + user: task.user || 'unknown', + // PVE-specific fields + pveBackupTask: true, + endpointId: endpointId, + taskType: 'vzdump' + }; + }); + } catch (error) { + console.error(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching PVE backup tasks: ${error.message}`); + return []; + } +} + +/** + * Fetches storage content (backup files) for a specific storage. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @param {string} storage - The storage name. + * @returns {Promise} - Array of backup file objects. + */ +async function fetchStorageBackups(apiClient, endpointId, nodeName, storage) { + try { + const response = await apiClient.get(`/nodes/${nodeName}/storage/${storage}/content`, { + params: { content: 'backup' } + }); + const backups = response.data?.data || []; + + // Transform to a consistent format + return backups.map(backup => ({ + volid: backup.volid, + size: backup.size, + vmid: backup.vmid, + ctime: backup.ctime, + format: backup.format, + notes: backup.notes, + protected: backup.protected || false, + storage: storage, + node: nodeName, + endpointId: endpointId + })); + } catch (error) { + // Storage might not support backups or might be inaccessible + if (error.response?.status !== 501) { // 501 = not implemented + console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching backups from storage ${storage}: ${error.message}`); + } + return []; + } +} + +/** + * Fetches VM/CT snapshots for a specific guest. + * @param {Object} apiClient - The PVE API client instance. + * @param {string} endpointId - The endpoint identifier. + * @param {string} nodeName - The name of the node. + * @param {string} vmid - The VM/CT ID. + * @param {string} type - 'qemu' or 'lxc'. + * @returns {Promise} - Array of snapshot objects. + */ +async function fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type) { + try { + const endpoint = type === 'qemu' ? 'qemu' : 'lxc'; + const response = await apiClient.get(`/nodes/${nodeName}/${endpoint}/${vmid}/snapshot`); + const snapshots = response.data?.data || []; + + // Filter out the 'current' snapshot which is not a real snapshot + return snapshots + .filter(snap => snap.name !== 'current') + .map(snap => ({ + name: snap.name, + description: snap.description, + snaptime: snap.snaptime, + vmstate: snap.vmstate || false, + parent: snap.parent, + vmid: vmid, + type: type, + node: nodeName, + endpointId: endpointId + })); + } catch (error) { + // Guest might not exist or snapshots not supported + return []; + } +} + /** * Fetches and processes all data for configured PBS instances. * @param {Object} currentPbsApiClients - Initialized PBS API clients. @@ -669,12 +811,91 @@ async function fetchPbsData(currentPbsApiClients) { return pbsDataResults; } +/** + * Fetches PVE backup data (backup tasks, storage backups, and snapshots). + * @param {Object} currentApiClients - Initialized PVE API clients. + * @param {Array} nodes - Array of node objects. + * @param {Array} vms - Array of VM objects. + * @param {Array} containers - Array of container objects. + * @returns {Promise} - { backupTasks, storageBackups, guestSnapshots } + */ +async function fetchPveBackupData(currentApiClients, nodes, vms, containers) { + const allBackupTasks = []; + const allStorageBackups = []; + const allGuestSnapshots = []; + + if (!nodes || nodes.length === 0) { + return { backupTasks: [], storageBackups: [], guestSnapshots: [] }; + } + + // Fetch backup tasks and storage backups for each node + const nodeBackupPromises = nodes.map(async node => { + const endpointId = node.endpointId; + const nodeName = node.node; + + if (!currentApiClients[endpointId]) { + console.warn(`[DataFetcher] No API client found for endpoint: ${endpointId}`); + return; + } + + const { client: apiClient } = currentApiClients[endpointId]; + + // Fetch backup tasks for this node + const backupTasks = await fetchPveBackupTasks(apiClient, endpointId, nodeName); + allBackupTasks.push(...backupTasks); + + // Fetch backups from each storage on this node + if (node.storage && Array.isArray(node.storage)) { + const storagePromises = node.storage + .filter(storage => storage.content && storage.content.includes('backup')) + .map(storage => fetchStorageBackups(apiClient, endpointId, nodeName, storage.storage)); + + const storageResults = await Promise.allSettled(storagePromises); + storageResults.forEach(result => { + if (result.status === 'fulfilled' && result.value) { + allStorageBackups.push(...result.value); + } + }); + } + }); + + // Fetch snapshots for all VMs and containers + const guestSnapshotPromises = []; + + [...vms, ...containers].forEach(guest => { + const endpointId = guest.endpointId; + const nodeName = guest.node; + const vmid = guest.vmid; + const type = guest.type || (vms.includes(guest) ? 'qemu' : 'lxc'); + + if (currentApiClients[endpointId]) { + const { client: apiClient } = currentApiClients[endpointId]; + guestSnapshotPromises.push( + fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type) + .then(snapshots => allGuestSnapshots.push(...snapshots)) + .catch(err => { + // Silently handle errors for individual guests + }) + ); + } + }); + + // Wait for all promises to complete + await Promise.allSettled([...nodeBackupPromises, ...guestSnapshotPromises]); + + return { + backupTasks: allBackupTasks, + storageBackups: allStorageBackups, + guestSnapshots: allGuestSnapshots + }; +} + /** * Fetches structural data: PVE nodes/VMs/CTs and all PBS data. * @param {Object} currentApiClients - Initialized PVE clients. * @param {Object} currentPbsApiClients - Initialized PBS clients. * @param {Function} [_fetchPbsDataInternal=fetchPbsData] - Internal override for testing. - * @returns {Promise} - { nodes, vms, containers, pbs: pbsDataArray } + * @returns {Promise} - { nodes, vms, containers, pbs: pbsDataArray, pveBackups } */ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetchPbsDataInternal = fetchPbsData) { // console.log("[DataFetcher] Starting full discovery cycle..."); @@ -694,14 +915,23 @@ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetc return [{ nodes: [], vms: [], containers: [] }, []]; }); + // Now fetch PVE backup data using the discovered nodes, VMs, and containers + const pveBackups = await fetchPveBackupData( + currentApiClients, + pveResult.nodes || [], + pveResult.vms || [], + pveResult.containers || [] + ); + const aggregatedResult = { nodes: pveResult.nodes || [], vms: pveResult.vms || [], containers: pveResult.containers || [], - pbs: pbsResult || [] // pbsResult is already the array we need + pbs: pbsResult || [], // pbsResult is already the array we need + pveBackups: pveBackups // Add PVE backup data }; - console.log(`[DataFetcher] Discovery cycle completed. Found: ${aggregatedResult.nodes.length} PVE nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} CTs, ${aggregatedResult.pbs.length} PBS instances.`); + console.log(`[DataFetcher] Discovery cycle completed. Found: ${aggregatedResult.nodes.length} PVE nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} CTs, ${aggregatedResult.pbs.length} PBS instances, ${pveBackups.backupTasks.length} PVE backup tasks.`); return aggregatedResult; } diff --git a/server/diagnostics.js b/server/diagnostics.js index 100e4f932..a9ce56a74 100644 --- a/server/diagnostics.js +++ b/server/diagnostics.js @@ -511,6 +511,11 @@ class DiagnosticTool { datastores: 0, sampleBackupIds: [] }, + pveBackups: { + backupTasks: state.pveBackups?.backupTasks?.length || 0, + storageBackups: state.pveBackups?.storageBackups?.length || 0, + guestSnapshots: state.pveBackups?.guestSnapshots?.length || 0 + }, performance: { lastDiscoveryTime: stats.lastDiscoveryCycleTime || 'N/A', lastMetricsTime: stats.lastMetricsCycleTime || 'N/A' @@ -701,6 +706,22 @@ class DiagnosticTool { }); } } + + // Check PVE backups + if (report.state && report.state.pveBackups) { + const totalPveBackups = (report.state.pveBackups.backupTasks || 0) + + (report.state.pveBackups.storageBackups || 0); + const totalPveSnapshots = report.state.pveBackups.guestSnapshots || 0; + + // If no PBS configured but PVE backups exist, that's fine + if ((!report.state.pbs || report.state.pbs.instances === 0) && totalPveBackups > 0) { + report.recommendations.push({ + severity: 'info', + category: 'Backup Status', + message: `Found ${totalPveBackups} PVE backups and ${totalPveSnapshots} VM/CT snapshots. Note: PBS is not configured, showing only local PVE backups.` + }); + } + } // Check guest count if (report.state && report.state.guests && report.state.nodes) { diff --git a/server/state.js b/server/state.js index 6819e7e47..65d73e092 100644 --- a/server/state.js +++ b/server/state.js @@ -6,6 +6,11 @@ const state = { containers: [], metrics: [], pbs: [], // Array to hold data for each PBS instance + pveBackups: { // Add PVE backup data + backupTasks: [], + storageBackups: [], + guestSnapshots: [] + }, isConfigPlaceholder: false, // Add this flag // Enhanced monitoring data @@ -81,6 +86,7 @@ function getState() { containers: state.containers, metrics: state.metrics, // Assuming metrics are updated elsewhere pbs: state.pbs, // This is what's sent to the client and should now be correct + pveBackups: state.pveBackups, // Add PVE backup data isConfigPlaceholder: state.isConfigPlaceholder, // Enhanced monitoring data @@ -99,7 +105,7 @@ function getState() { }; } -function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) { +function updateDiscoveryData({ nodes, vms, containers, pbs, pveBackups, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) { const startTime = Date.now(); try { @@ -109,6 +115,15 @@ function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggrega state.containers = containers || []; state.pbs = pbs || []; + // Update PVE backup data + if (pveBackups) { + state.pveBackups = { + backupTasks: pveBackups.backupTasks || [], + storageBackups: pveBackups.storageBackups || [], + guestSnapshots: pveBackups.guestSnapshots || [] + }; + } + // If the discovery data structure nests these under the main 'pbs' array (e.g., from fetchPbsData), // they might not be separate top-level items in the discoveryData object passed here. // If they are indeed separate, this update is fine. diff --git a/src/public/index.html b/src/public/index.html index 35c64fa52..3289cfc65 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -793,9 +793,10 @@ Type Node Latest Backup - PBS Instance - Datastore + Source + Location # Backups + Snapshots 7-Day History diff --git a/src/public/js/state.js b/src/public/js/state.js index eb5769176..8dd6aeebb 100644 --- a/src/public/js/state.js +++ b/src/public/js/state.js @@ -10,6 +10,11 @@ PulseApp.state = (() => { metricsData: [], dashboardData: [], pbsDataArray: [], + pveBackups: { // Add PVE backup data + backupTasks: [], + storageBackups: [], + guestSnapshots: [] + }, dashboardHistory: {}, initialDataReceived: false, @@ -133,7 +138,7 @@ PulseApp.state = (() => { }); // Check what actually changed using hashing - const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs']; + const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs', 'pveBackups']; dataTypes.forEach(type => { if (newData[type]) { @@ -163,6 +168,9 @@ PulseApp.state = (() => { case 'pbs': internalState.pbsDataArray = newData.pbs; break; + case 'pveBackups': + internalState.pveBackups = newData.pveBackups; + break; } } } diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js index 6fbbd82d0..1b0173d4c 100644 --- a/src/public/js/ui/backups.js +++ b/src/public/js/ui/backups.js @@ -63,36 +63,66 @@ PulseApp.ui.backups = (() => { if (window.innerWidth < 768) { _initMobileScrollIndicators(); } + + // Initialize snapshot modal handlers + _initSnapshotModal(); } function _getInitialBackupData() { const vmsData = PulseApp.state.get('vmsData') || []; const containersData = PulseApp.state.get('containersData') || []; const pbsDataArray = PulseApp.state.get('pbsDataArray') || []; + const pveBackups = PulseApp.state.get('pveBackups') || {}; const initialDataReceived = PulseApp.state.get('initialDataReceived'); const allGuests = [...vmsData, ...containersData]; - const allRecentBackupTasks = pbsDataArray.flatMap(pbs => + // Combine PBS and PVE backup tasks + const pbsBackupTasks = pbsDataArray.flatMap(pbs => (pbs.backupTasks?.recentTasks || []).map(task => ({ ...task, guestId: task.id?.split('/')[1] || null, guestTypePbs: task.id?.split('/')[0] || null, - pbsInstanceName: pbs.pbsInstanceName + pbsInstanceName: pbs.pbsInstanceName, + source: 'pbs' })) ); - const allSnapshots = pbsDataArray.flatMap(pbsInstance => + const pveBackupTasks = (pveBackups.backupTasks || []).map(task => ({ + ...task, + guestId: task.guestId, + guestTypePbs: task.guestType, + startTime: task.starttime, + source: 'pve' + })); + + const allRecentBackupTasks = [...pbsBackupTasks, ...pveBackupTasks]; + + // Combine PBS snapshots and PVE storage backups + const pbsSnapshots = pbsDataArray.flatMap(pbsInstance => (pbsInstance.datastores || []).flatMap(ds => (ds.snapshots || []).map(snap => ({ ...snap, pbsInstanceName: pbsInstance.pbsInstanceName, datastoreName: ds.name, backupType: snap['backup-type'], - backupVMID: snap['backup-id'] + backupVMID: snap['backup-id'], + source: 'pbs' })) ) ); + const pveStorageBackups = (pveBackups.storageBackups || []).map(backup => ({ + 'backup-time': backup.ctime, + backupType: backup.vmid ? 'vm' : 'ct', // Guess based on context + backupVMID: backup.vmid, + size: backup.size, + protected: backup.protected, + storage: backup.storage, + source: 'pve' + })); + + const allSnapshots = [...pbsSnapshots, ...pveStorageBackups]; + // Pre-index data by guest ID and type for performance const tasksByGuest = new Map(); const snapshotsByGuest = new Map(); @@ -141,6 +171,12 @@ PulseApp.ui.backups = (() => { function _determineGuestBackupStatus(guest, guestSnapshots, guestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo) { const guestId = String(guest.vmid); + // Get guest snapshots from pveBackups + const pveBackups = PulseApp.state.get('pveBackups') || {}; + const guestSnapshotCount = (pveBackups.guestSnapshots || []) + .filter(snap => snap.vmid === guest.vmid) + .length; + // Use pre-filtered data instead of filtering large arrays const totalBackups = guestSnapshots ? guestSnapshots.length : 0; const latestSnapshot = guestSnapshots && guestSnapshots.length > 0 @@ -208,6 +244,21 @@ PulseApp.ui.backups = (() => { return dailyStatus; }); + // Determine backup source and location + let backupSource = 'N/A'; + let backupLocation = 'N/A'; + + if (latestSnapshot || latestTask) { + const source = latestSnapshot?.source || latestTask?.source; + if (source === 'pbs') { + backupSource = latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'PBS'; + backupLocation = latestSnapshot?.datastoreName || 'N/A'; + } else if (source === 'pve') { + backupSource = 'PVE'; + backupLocation = latestSnapshot?.storage || latestTask?.node || 'Local'; + } + } + return { guestName: guest.name || `Guest ${guest.vmid}`, guestId: guest.vmid, @@ -215,11 +266,13 @@ PulseApp.ui.backups = (() => { node: guest.node, guestPveStatus: guest.status, latestBackupTime: displayTimestamp, - pbsInstanceName: latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'N/A', - datastoreName: latestSnapshot?.datastoreName || 'N/A', + pbsInstanceName: backupSource, + datastoreName: backupLocation, totalBackups: totalBackups, backupHealthStatus: healthStatus, - last7DaysBackupStatus: last7DaysBackupStatus + last7DaysBackupStatus: last7DaysBackupStatus, + snapshotCount: guestSnapshotCount, + endpointId: guest.endpointId }; } @@ -288,6 +341,18 @@ PulseApp.ui.backups = (() => { } sevenDayDots += ''; + // Create snapshot button or count display + let snapshotCell = ''; + if (guestStatus.snapshotCount > 0) { + snapshotCell = ``; + } else { + snapshotCell = '0'; + } + row.innerHTML = ` ${guestStatus.guestName} ${guestStatus.guestId} @@ -297,6 +362,7 @@ PulseApp.ui.backups = (() => { ${guestStatus.pbsInstanceName} ${guestStatus.datastoreName} ${guestStatus.totalBackups} + ${snapshotCell} ${sevenDayDots} `; return row; @@ -511,6 +577,114 @@ PulseApp.ui.backups = (() => { PulseApp.state.saveFilterState(); // Save reset state } + function _initSnapshotModal() { + const modal = document.getElementById('snapshot-modal'); + const modalClose = document.getElementById('snapshot-modal-close'); + const modalBody = document.getElementById('snapshot-modal-body'); + const modalTitle = document.getElementById('snapshot-modal-title'); + + if (!modal || !modalClose || !modalBody) { + console.warn('[Backups] Snapshot modal elements not found'); + return; + } + + // Close modal on click outside or close button + modalClose.addEventListener('click', () => { + modal.classList.add('hidden'); + modal.classList.remove('flex'); + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.add('hidden'); + modal.classList.remove('flex'); + } + }); + + // Handle snapshot button clicks + document.addEventListener('click', (e) => { + if (e.target.classList.contains('view-snapshots-btn')) { + const vmid = e.target.dataset.vmid; + const node = e.target.dataset.node; + const endpoint = e.target.dataset.endpoint; + const type = e.target.dataset.type; + + _showSnapshotModal(vmid, node, endpoint, type); + } + }); + } + + function _showSnapshotModal(vmid, node, endpoint, type) { + const modal = document.getElementById('snapshot-modal'); + const modalBody = document.getElementById('snapshot-modal-body'); + const modalTitle = document.getElementById('snapshot-modal-title'); + + if (!modal || !modalBody || !modalTitle) return; + + // Get guest info + const vmsData = PulseApp.state.get('vmsData') || []; + const containersData = PulseApp.state.get('containersData') || []; + const guest = [...vmsData, ...containersData].find(g => g.vmid === vmid); + const guestName = guest?.name || `Guest ${vmid}`; + + modalTitle.textContent = `Snapshots for ${guestName} (${type.toUpperCase()} ${vmid})`; + modalBody.innerHTML = '

Loading snapshots...

'; + + modal.classList.remove('hidden'); + modal.classList.add('flex'); + + // Get snapshots from state + const pveBackups = PulseApp.state.get('pveBackups') || {}; + const snapshots = (pveBackups.guestSnapshots || []) + .filter(snap => snap.vmid === vmid) + .sort((a, b) => (b.snaptime || 0) - (a.snaptime || 0)); + + if (snapshots.length === 0) { + modalBody.innerHTML = '

No snapshots found for this guest.

'; + return; + } + + // Build snapshot table + let html = ` +
+ + + + + + + + + + + `; + + snapshots.forEach(snap => { + const created = snap.snaptime + ? new Date(snap.snaptime * 1000).toLocaleString() + : 'Unknown'; + const hasRam = snap.vmstate ? 'Yes' : 'No'; + const description = snap.description || '-'; + + html += ` + + + + + + + `; + }); + + html += ` + +
NameCreatedDescriptionRAM
${snap.name}${created}${description}${hasRam}
+
+ `; + + modalBody.innerHTML = html; + } + return { init, updateBackupsTab,