mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
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:
+233
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user