Skip to content

Commit 7a0e847

Browse files
rcourtmanclaude
andcommitted
feat: add PVE backup and snapshot support
- Add support for local PVE backups (vzdump tasks) - Add support for backup files on PVE storage (including NFS) - Add VM/CT snapshot display with modal view - Update backup tab to show both PBS and PVE backups - Change columns to Source/Location for clarity - Update diagnostics to handle PVE-only setups Fixes #81 - PBS token permission warnings for PVE-only users Fixes #80 - Support for backups on NFS and other PVE storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9b8139d commit 7a0e847

File tree

6 files changed

+463
-14
lines changed

6 files changed

+463
-14
lines changed

server/dataFetcher.js

Lines changed: 233 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,148 @@ async function fetchAllPbsTasksForProcessing({ client, config }, nodeName) {
583583
}
584584
}
585585

586+
/**
587+
* Fetches PVE backup tasks (vzdump) for a specific node.
588+
* @param {Object} apiClient - The PVE API client instance.
589+
* @param {string} endpointId - The endpoint identifier.
590+
* @param {string} nodeName - The name of the node.
591+
* @returns {Promise<Array>} - Array of backup task objects.
592+
*/
593+
async function fetchPveBackupTasks(apiClient, endpointId, nodeName) {
594+
try {
595+
const response = await apiClient.get(`/nodes/${nodeName}/tasks`, {
596+
params: {
597+
typefilter: 'vzdump',
598+
limit: 1000
599+
}
600+
});
601+
const tasks = response.data?.data || [];
602+
603+
// Calculate 30-day cutoff timestamp
604+
const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000);
605+
606+
// Filter to last 30 days and transform to match PBS backup task format
607+
return tasks
608+
.filter(task => task.starttime >= thirtyDaysAgo)
609+
.map(task => {
610+
// Extract guest info from task description or ID
611+
let guestId = null;
612+
let guestType = null;
613+
614+
// Try to extract from task description (e.g., "vzdump VM 100")
615+
const vmMatch = task.type?.match(/VM\s+(\d+)/i) || task.id?.match(/VM\s+(\d+)/i);
616+
const ctMatch = task.type?.match(/CT\s+(\d+)/i) || task.id?.match(/CT\s+(\d+)/i);
617+
618+
if (vmMatch) {
619+
guestId = vmMatch[1];
620+
guestType = 'vm';
621+
} else if (ctMatch) {
622+
guestId = ctMatch[1];
623+
guestType = 'ct';
624+
} else if (task.id) {
625+
// Try to extract from task ID format
626+
const idMatch = task.id.match(/vzdump-(\w+)-(\d+)/);
627+
if (idMatch) {
628+
guestType = idMatch[1] === 'qemu' ? 'vm' : 'ct';
629+
guestId = idMatch[2];
630+
}
631+
}
632+
633+
return {
634+
type: 'backup',
635+
status: task.status || 'unknown',
636+
starttime: task.starttime,
637+
endtime: task.endtime || (task.starttime + 60),
638+
node: nodeName,
639+
guest: guestId ? `${guestType}/${guestId}` : task.id,
640+
guestType: guestType,
641+
guestId: guestId,
642+
upid: task.upid,
643+
user: task.user || 'unknown',
644+
// PVE-specific fields
645+
pveBackupTask: true,
646+
endpointId: endpointId,
647+
taskType: 'vzdump'
648+
};
649+
});
650+
} catch (error) {
651+
console.error(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching PVE backup tasks: ${error.message}`);
652+
return [];
653+
}
654+
}
655+
656+
/**
657+
* Fetches storage content (backup files) for a specific storage.
658+
* @param {Object} apiClient - The PVE API client instance.
659+
* @param {string} endpointId - The endpoint identifier.
660+
* @param {string} nodeName - The name of the node.
661+
* @param {string} storage - The storage name.
662+
* @returns {Promise<Array>} - Array of backup file objects.
663+
*/
664+
async function fetchStorageBackups(apiClient, endpointId, nodeName, storage) {
665+
try {
666+
const response = await apiClient.get(`/nodes/${nodeName}/storage/${storage}/content`, {
667+
params: { content: 'backup' }
668+
});
669+
const backups = response.data?.data || [];
670+
671+
// Transform to a consistent format
672+
return backups.map(backup => ({
673+
volid: backup.volid,
674+
size: backup.size,
675+
vmid: backup.vmid,
676+
ctime: backup.ctime,
677+
format: backup.format,
678+
notes: backup.notes,
679+
protected: backup.protected || false,
680+
storage: storage,
681+
node: nodeName,
682+
endpointId: endpointId
683+
}));
684+
} catch (error) {
685+
// Storage might not support backups or might be inaccessible
686+
if (error.response?.status !== 501) { // 501 = not implemented
687+
console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching backups from storage ${storage}: ${error.message}`);
688+
}
689+
return [];
690+
}
691+
}
692+
693+
/**
694+
* Fetches VM/CT snapshots for a specific guest.
695+
* @param {Object} apiClient - The PVE API client instance.
696+
* @param {string} endpointId - The endpoint identifier.
697+
* @param {string} nodeName - The name of the node.
698+
* @param {string} vmid - The VM/CT ID.
699+
* @param {string} type - 'qemu' or 'lxc'.
700+
* @returns {Promise<Array>} - Array of snapshot objects.
701+
*/
702+
async function fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type) {
703+
try {
704+
const endpoint = type === 'qemu' ? 'qemu' : 'lxc';
705+
const response = await apiClient.get(`/nodes/${nodeName}/${endpoint}/${vmid}/snapshot`);
706+
const snapshots = response.data?.data || [];
707+
708+
// Filter out the 'current' snapshot which is not a real snapshot
709+
return snapshots
710+
.filter(snap => snap.name !== 'current')
711+
.map(snap => ({
712+
name: snap.name,
713+
description: snap.description,
714+
snaptime: snap.snaptime,
715+
vmstate: snap.vmstate || false,
716+
parent: snap.parent,
717+
vmid: vmid,
718+
type: type,
719+
node: nodeName,
720+
endpointId: endpointId
721+
}));
722+
} catch (error) {
723+
// Guest might not exist or snapshots not supported
724+
return [];
725+
}
726+
}
727+
586728
/**
587729
* Fetches and processes all data for configured PBS instances.
588730
* @param {Object} currentPbsApiClients - Initialized PBS API clients.
@@ -669,12 +811,91 @@ async function fetchPbsData(currentPbsApiClients) {
669811
return pbsDataResults;
670812
}
671813

814+
/**
815+
* Fetches PVE backup data (backup tasks, storage backups, and snapshots).
816+
* @param {Object} currentApiClients - Initialized PVE API clients.
817+
* @param {Array} nodes - Array of node objects.
818+
* @param {Array} vms - Array of VM objects.
819+
* @param {Array} containers - Array of container objects.
820+
* @returns {Promise<Object>} - { backupTasks, storageBackups, guestSnapshots }
821+
*/
822+
async function fetchPveBackupData(currentApiClients, nodes, vms, containers) {
823+
const allBackupTasks = [];
824+
const allStorageBackups = [];
825+
const allGuestSnapshots = [];
826+
827+
if (!nodes || nodes.length === 0) {
828+
return { backupTasks: [], storageBackups: [], guestSnapshots: [] };
829+
}
830+
831+
// Fetch backup tasks and storage backups for each node
832+
const nodeBackupPromises = nodes.map(async node => {
833+
const endpointId = node.endpointId;
834+
const nodeName = node.node;
835+
836+
if (!currentApiClients[endpointId]) {
837+
console.warn(`[DataFetcher] No API client found for endpoint: ${endpointId}`);
838+
return;
839+
}
840+
841+
const { client: apiClient } = currentApiClients[endpointId];
842+
843+
// Fetch backup tasks for this node
844+
const backupTasks = await fetchPveBackupTasks(apiClient, endpointId, nodeName);
845+
allBackupTasks.push(...backupTasks);
846+
847+
// Fetch backups from each storage on this node
848+
if (node.storage && Array.isArray(node.storage)) {
849+
const storagePromises = node.storage
850+
.filter(storage => storage.content && storage.content.includes('backup'))
851+
.map(storage => fetchStorageBackups(apiClient, endpointId, nodeName, storage.storage));
852+
853+
const storageResults = await Promise.allSettled(storagePromises);
854+
storageResults.forEach(result => {
855+
if (result.status === 'fulfilled' && result.value) {
856+
allStorageBackups.push(...result.value);
857+
}
858+
});
859+
}
860+
});
861+
862+
// Fetch snapshots for all VMs and containers
863+
const guestSnapshotPromises = [];
864+
865+
[...vms, ...containers].forEach(guest => {
866+
const endpointId = guest.endpointId;
867+
const nodeName = guest.node;
868+
const vmid = guest.vmid;
869+
const type = guest.type || (vms.includes(guest) ? 'qemu' : 'lxc');
870+
871+
if (currentApiClients[endpointId]) {
872+
const { client: apiClient } = currentApiClients[endpointId];
873+
guestSnapshotPromises.push(
874+
fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type)
875+
.then(snapshots => allGuestSnapshots.push(...snapshots))
876+
.catch(err => {
877+
// Silently handle errors for individual guests
878+
})
879+
);
880+
}
881+
});
882+
883+
// Wait for all promises to complete
884+
await Promise.allSettled([...nodeBackupPromises, ...guestSnapshotPromises]);
885+
886+
return {
887+
backupTasks: allBackupTasks,
888+
storageBackups: allStorageBackups,
889+
guestSnapshots: allGuestSnapshots
890+
};
891+
}
892+
672893
/**
673894
* Fetches structural data: PVE nodes/VMs/CTs and all PBS data.
674895
* @param {Object} currentApiClients - Initialized PVE clients.
675896
* @param {Object} currentPbsApiClients - Initialized PBS clients.
676897
* @param {Function} [_fetchPbsDataInternal=fetchPbsData] - Internal override for testing.
677-
* @returns {Promise<Object>} - { nodes, vms, containers, pbs: pbsDataArray }
898+
* @returns {Promise<Object>} - { nodes, vms, containers, pbs: pbsDataArray, pveBackups }
678899
*/
679900
async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetchPbsDataInternal = fetchPbsData) {
680901
// console.log("[DataFetcher] Starting full discovery cycle...");
@@ -694,14 +915,23 @@ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetc
694915
return [{ nodes: [], vms: [], containers: [] }, []];
695916
});
696917

918+
// Now fetch PVE backup data using the discovered nodes, VMs, and containers
919+
const pveBackups = await fetchPveBackupData(
920+
currentApiClients,
921+
pveResult.nodes || [],
922+
pveResult.vms || [],
923+
pveResult.containers || []
924+
);
925+
697926
const aggregatedResult = {
698927
nodes: pveResult.nodes || [],
699928
vms: pveResult.vms || [],
700929
containers: pveResult.containers || [],
701-
pbs: pbsResult || [] // pbsResult is already the array we need
930+
pbs: pbsResult || [], // pbsResult is already the array we need
931+
pveBackups: pveBackups // Add PVE backup data
702932
};
703933

704-
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.`);
934+
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.`);
705935

706936
return aggregatedResult;
707937
}

server/diagnostics.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,11 @@ class DiagnosticTool {
511511
datastores: 0,
512512
sampleBackupIds: []
513513
},
514+
pveBackups: {
515+
backupTasks: state.pveBackups?.backupTasks?.length || 0,
516+
storageBackups: state.pveBackups?.storageBackups?.length || 0,
517+
guestSnapshots: state.pveBackups?.guestSnapshots?.length || 0
518+
},
514519
performance: {
515520
lastDiscoveryTime: stats.lastDiscoveryCycleTime || 'N/A',
516521
lastMetricsTime: stats.lastMetricsCycleTime || 'N/A'
@@ -701,6 +706,22 @@ class DiagnosticTool {
701706
});
702707
}
703708
}
709+
710+
// Check PVE backups
711+
if (report.state && report.state.pveBackups) {
712+
const totalPveBackups = (report.state.pveBackups.backupTasks || 0) +
713+
(report.state.pveBackups.storageBackups || 0);
714+
const totalPveSnapshots = report.state.pveBackups.guestSnapshots || 0;
715+
716+
// If no PBS configured but PVE backups exist, that's fine
717+
if ((!report.state.pbs || report.state.pbs.instances === 0) && totalPveBackups > 0) {
718+
report.recommendations.push({
719+
severity: 'info',
720+
category: 'Backup Status',
721+
message: `Found ${totalPveBackups} PVE backups and ${totalPveSnapshots} VM/CT snapshots. Note: PBS is not configured, showing only local PVE backups.`
722+
});
723+
}
724+
}
704725

705726
// Check guest count
706727
if (report.state && report.state.guests && report.state.nodes) {

server/state.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ const state = {
66
containers: [],
77
metrics: [],
88
pbs: [], // Array to hold data for each PBS instance
9+
pveBackups: { // Add PVE backup data
10+
backupTasks: [],
11+
storageBackups: [],
12+
guestSnapshots: []
13+
},
914
isConfigPlaceholder: false, // Add this flag
1015

1116
// Enhanced monitoring data
@@ -81,6 +86,7 @@ function getState() {
8186
containers: state.containers,
8287
metrics: state.metrics, // Assuming metrics are updated elsewhere
8388
pbs: state.pbs, // This is what's sent to the client and should now be correct
89+
pveBackups: state.pveBackups, // Add PVE backup data
8490
isConfigPlaceholder: state.isConfigPlaceholder,
8591

8692
// Enhanced monitoring data
@@ -99,7 +105,7 @@ function getState() {
99105
};
100106
}
101107

102-
function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) {
108+
function updateDiscoveryData({ nodes, vms, containers, pbs, pveBackups, allPbsTasks, aggregatedPbsTaskSummary }, duration = 0, errors = []) {
103109
const startTime = Date.now();
104110

105111
try {
@@ -109,6 +115,15 @@ function updateDiscoveryData({ nodes, vms, containers, pbs, allPbsTasks, aggrega
109115
state.containers = containers || [];
110116
state.pbs = pbs || [];
111117

118+
// Update PVE backup data
119+
if (pveBackups) {
120+
state.pveBackups = {
121+
backupTasks: pveBackups.backupTasks || [],
122+
storageBackups: pveBackups.storageBackups || [],
123+
guestSnapshots: pveBackups.guestSnapshots || []
124+
};
125+
}
126+
112127
// If the discovery data structure nests these under the main 'pbs' array (e.g., from fetchPbsData),
113128
// they might not be separate top-level items in the discoveryData object passed here.
114129
// If they are indeed separate, this update is fine.

src/public/index.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,9 +793,10 @@
793793
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="guestType">Type</th>
794794
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="node">Node</th>
795795
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="latestBackupTime">Latest Backup</th>
796-
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="pbsInstanceName">PBS Instance</th>
797-
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="datastoreName">Datastore</th>
796+
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="pbsInstanceName">Source</th>
797+
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="datastoreName">Location</th>
798798
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 sortable p-1 px-2 cursor-pointer select-none whitespace-nowrap" data-sort="totalBackups"># Backups</th>
799+
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 p-1 px-2 text-center select-none whitespace-nowrap">Snapshots</th>
799800
<th scope="col" class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10 p-1 px-2 text-center select-none whitespace-nowrap">7-Day History</th>
800801
</tr>
801802
</thead>

src/public/js/state.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ PulseApp.state = (() => {
1010
metricsData: [],
1111
dashboardData: [],
1212
pbsDataArray: [],
13+
pveBackups: { // Add PVE backup data
14+
backupTasks: [],
15+
storageBackups: [],
16+
guestSnapshots: []
17+
},
1318
dashboardHistory: {},
1419
initialDataReceived: false,
1520

@@ -133,7 +138,7 @@ PulseApp.state = (() => {
133138
});
134139

135140
// Check what actually changed using hashing
136-
const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs'];
141+
const dataTypes = ['nodes', 'vms', 'containers', 'metrics', 'pbs', 'pveBackups'];
137142

138143
dataTypes.forEach(type => {
139144
if (newData[type]) {
@@ -163,6 +168,9 @@ PulseApp.state = (() => {
163168
case 'pbs':
164169
internalState.pbsDataArray = newData.pbs;
165170
break;
171+
case 'pveBackups':
172+
internalState.pveBackups = newData.pveBackups;
173+
break;
166174
}
167175
}
168176
}

0 commit comments

Comments
 (0)