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>
This commit is contained in:
courtmanr@gmail.com
2025-05-28 09:01:24 +01:00
parent 9b8139d229
commit 7a0e8475fe
6 changed files with 463 additions and 14 deletions
+233 -3
View File
@@ -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>} - 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>} - 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>} - 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<Object>} - { 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<Object>} - { nodes, vms, containers, pbs: pbsDataArray }
* @returns {Promise<Object>} - { 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;
}
+21
View File
@@ -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) {
+16 -1
View File
@@ -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.
+3 -2
View File
@@ -793,9 +793,10 @@
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</tr>
</thead>
+9 -1
View File
@@ -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;
}
}
}
+181 -7
View File
@@ -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 += '</div>';
// Create snapshot button or count display
let snapshotCell = '';
if (guestStatus.snapshotCount > 0) {
snapshotCell = `<button class="text-blue-600 dark:text-blue-400 hover:underline view-snapshots-btn"
data-vmid="${guestStatus.guestId}"
data-node="${guestStatus.node}"
data-endpoint="${guestStatus.endpointId}"
data-type="${guestStatus.guestType.toLowerCase()}">${guestStatus.snapshotCount}</button>`;
} else {
snapshotCell = '<span class="text-gray-400 dark:text-gray-500">0</span>';
}
row.innerHTML = `
<td class="sticky left-0 bg-white dark:bg-gray-800 z-10 p-1 px-2 whitespace-nowrap overflow-hidden text-ellipsis max-w-0 text-gray-900 dark:text-gray-100" title="${guestStatus.guestName}">${guestStatus.guestName}</td>
<td class="p-1 px-2 text-gray-500 dark:text-gray-400">${guestStatus.guestId}</td>
@@ -297,6 +362,7 @@ PulseApp.ui.backups = (() => {
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.pbsInstanceName}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.datastoreName}</td>
<td class="p-1 px-2 text-gray-500 dark:text-gray-400">${guestStatus.totalBackups}</td>
<td class="p-1 px-2 text-center">${snapshotCell}</td>
<td class="p-1 px-2 whitespace-nowrap">${sevenDayDots}</td>
`;
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 = '<p class="text-gray-500 dark:text-gray-400">Loading snapshots...</p>';
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 = '<p class="text-gray-500 dark:text-gray-400">No snapshots found for this guest.</p>';
return;
}
// Build snapshot table
let html = `
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Description</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">RAM</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
`;
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 += `
<tr>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">${snap.name}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${created}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">${description}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${hasRam}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
modalBody.innerHTML = html;
}
return {
init,
updateBackupsTab,