fix: resolve PVE backup visibility issue with hybrid node discovery and filtering fixes

Implements comprehensive solution for GitHub issue #80 where PVE backups stored on local node storage weren't appearing in the backup tab. Adds intelligent node discovery system that creates direct connections to access node-local storage while maintaining backward compatibility with shared storage setups.

Key improvements:
- Hybrid discovery approach with automatic node IP detection and connection caching
- Fixed data structure mismatches and key collision issues for duplicate VMIDs across nodes
- Corrected PVE backup filtering logic in summary cards and calendar views
- Enhanced API token permission documentation for storage content access
- Optimized connection handling with reduced timeouts for faster discovery cycles

🤖 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-30 23:43:56 +01:00
parent 8c13225c5f
commit 7216d7e977
5 changed files with 584 additions and 140 deletions
+27 -3
View File
@@ -399,7 +399,25 @@ If monitoring PBS, create a token within the PBS interface.
### Required Permissions
- **Proxmox VE:** The `PVEAuditor` role assigned at path `/` with `Propagate` enabled is recommended.
- **Proxmox VE:**
- **Basic monitoring:** The `PVEAuditor` role assigned at path `/` with `Propagate` enabled.
- **To view PVE backup files:** Additionally requires `PVEDatastoreAdmin` role on `/storage` (or specific storage paths).
<details>
<summary>Important: Storage Content Visibility (Click to Expand)</summary>
Due to Proxmox API limitations, viewing backup files in storage requires elevated permissions:
- `PVEAuditor` alone is NOT sufficient to list storage contents via API
- You must grant `PVEDatastoreAdmin` role which includes `Datastore.Allocate` permission
- This applies even for read-only access to backup listings
To fix empty PVE backup listings:
```bash
# Grant storage admin permissions to your API token
pveum acl modify /storage --tokens user@realm!tokenname --roles PVEDatastoreAdmin
```
</details>
<details>
<summary>Permissions included in PVEAuditor (Click to Expand)</summary>
- `Datastore.Audit`
@@ -408,6 +426,7 @@ If monitoring PBS, create a token within the PBS interface.
- `Sys.Audit`
- `VM.Audit`
</details>
- **Proxmox Backup Server:** The `Audit` role assigned at path `/` with `Propagate` enabled is recommended.
### Running from Release Tarball
@@ -446,12 +465,15 @@ For development purposes or running directly from source, see the **[DEVELOPMENT
- Lightweight monitoring for Proxmox VE nodes, VMs, and Containers.
- Real-time status updates via WebSockets.
- Simple, responsive web interface.
- **Comprehensive backup monitoring:**
- Proxmox Backup Server (PBS) snapshots and tasks
- PVE backup files stored on local and shared storage
- VM/CT snapshot tracking with calendar heatmap visualization
- Built-in diagnostic tool with API permission testing and troubleshooting guidance.
- Advanced alert system with configurable thresholds and durations.
- Efficient polling: Stops API polling when no clients are connected.
- Docker support.
- Multi-environment PVE monitoring support.
- Proxmox Backup Server (PBS) monitoring support.
- LXC installation script.
## 💻 System Requirements
@@ -644,7 +666,9 @@ Pulse includes a comprehensive built-in diagnostic tool to help troubleshoot con
### Common Issues
* **Empty Backups Tab:** Usually caused by missing `PBS Node Name` in the settings configuration. SSH to your PBS server and run `hostname` to find the correct value.
* **Empty Backups Tab:**
- **PBS backups not showing:** Usually caused by missing `PBS Node Name` in the settings configuration. SSH to your PBS server and run `hostname` to find the correct value.
- **PVE backups not showing:** Ensure your API token has `PVEDatastoreAdmin` role on `/storage` to view backup files. See the permissions section above.
* **Pulse Application Logs:** Check container logs (`docker logs pulse_monitor`) or service logs (`sudo journalctl -u pulse-monitor.service -f`) for errors (401 Unauthorized, 403 Forbidden, connection refused, timeout).
* **Configuration Issues:** Use the settings modal to verify all connection details. Test connections with the built-in connectivity tester before saving. Ensure no placeholder values remain.
* **Network Connectivity:** Can the machine running Pulse reach the PVE/PBS hostnames/IPs and ports (usually 8006 for PVE, 8007 for PBS)? Check firewalls.
+133 -14
View File
@@ -664,9 +664,16 @@ fix_service_paths() {
systemctl daemon-reload
# Start service with new configuration
systemctl start "$SERVICE_NAME" 2>/dev/null || true
print_success "Systemd service paths updated."
# Validate and test the service after path updates
if validate_service_configuration; then
if test_service_startup; then
print_success "Systemd service paths updated and verified."
else
print_warning "Service paths updated but startup test failed."
fi
else
print_warning "Service path update completed but validation failed."
fi
fi
fi
}
@@ -724,9 +731,15 @@ migrate_from_old_path() {
systemctl daemon-reload
# Start service with new configuration
systemctl start "$SERVICE_NAME" 2>/dev/null || true
print_success "Systemd service updated for new path."
# Note: .env file is not required - Pulse handles missing configuration automatically
# Test the service startup
if test_service_startup; then
print_success "Systemd service updated and tested successfully."
else
print_warning "Service migration completed but startup test failed."
print_warning "You may need to reconfigure Pulse through the web interface."
fi
fi
local old_service_file="/etc/systemd/system/pulse-proxmox.service"
@@ -1621,6 +1634,101 @@ configure_environment() {
return 0
}
validate_service_configuration() {
print_info "Validating systemd service configuration..."
local service_file="/etc/systemd/system/$SERVICE_NAME"
if [ ! -f "$service_file" ]; then
print_warning "Service file $service_file not found."
return 1
fi
# Check if WorkingDirectory path exists
local working_dir=$(grep "^WorkingDirectory=" "$service_file" | cut -d'=' -f2)
if [ -n "$working_dir" ] && [ ! -d "$working_dir" ]; then
print_error "Service WorkingDirectory '$working_dir' does not exist!"
return 1
fi
# Check if EnvironmentFile path exists (if specified) - but don't create it as Pulse handles missing .env
local env_file=$(grep "^EnvironmentFile=" "$service_file" | cut -d'=' -f2)
if [ -n "$env_file" ] && [ ! -f "$env_file" ]; then
print_info "Service EnvironmentFile '$env_file' does not exist - Pulse will handle configuration setup."
fi
print_success "Service configuration validation completed."
return 0
}
test_service_startup() {
print_info "Testing service startup..."
# First validate the configuration
if ! validate_service_configuration; then
print_error "Service configuration validation failed."
return 1
fi
# Try to start the service
print_info "Attempting to start $SERVICE_NAME..."
if systemctl start "$SERVICE_NAME" >/dev/null 2>&1; then
sleep 3 # Give it a moment to start up
# Check if it's actually running
if systemctl is-active --quiet "$SERVICE_NAME"; then
print_success "Service started successfully and is running."
return 0
else
print_error "Service started but is not running properly."
diagnose_service_failure
return 1
fi
else
print_error "Failed to start service."
diagnose_service_failure
return 1
fi
}
diagnose_service_failure() {
print_error "Diagnosing service failure..."
# Get the most recent journal entries for the service
local recent_logs=$(journalctl -u "$SERVICE_NAME" --since "1 minute ago" --no-pager -q 2>/dev/null)
if echo "$recent_logs" | grep -q "Failed to load environment files"; then
print_error "✗ Environment file (.env) is missing or inaccessible"
local env_file=$(grep "^EnvironmentFile=" "/etc/systemd/system/$SERVICE_NAME" | cut -d'=' -f2)
if [ -n "$env_file" ]; then
print_error " Expected location: $env_file"
print_info " → This should not happen with current Pulse versions."
print_info " → Pulse handles missing .env files automatically."
print_info " → Check file permissions or systemd service configuration."
fi
elif echo "$recent_logs" | grep -q "No such file or directory"; then
print_error "✗ Required files or directories are missing"
print_error " → Check that Pulse is properly installed in $PULSE_DIR"
elif echo "$recent_logs" | grep -q "Cannot find module"; then
print_error "✗ Node.js dependencies are missing"
print_error " → Run: cd $PULSE_DIR && npm install"
elif echo "$recent_logs" | grep -q "Permission denied"; then
print_error "✗ Permission issues detected"
print_error " → Check ownership: chown -R $PULSE_USER:$PULSE_USER $PULSE_DIR"
elif echo "$recent_logs" | grep -q "EADDRINUSE.*7655"; then
print_error "✗ Port 7655 is already in use"
print_error " → Another service is using the default port"
else
print_error "✗ Unknown service failure"
print_error "Recent log entries:"
echo "$recent_logs" | tail -5 | sed 's/^/ /'
fi
print_info ""
print_info "For more details, run:"
print_info " systemctl status $SERVICE_NAME"
print_info " journalctl -u $SERVICE_NAME -f"
}
setup_systemd_service() {
local update_mode=${1:-false}
@@ -1697,16 +1805,27 @@ EOF
return 1
fi
print_info "Starting $SERVICE_NAME..."
if systemctl start "$SERVICE_NAME"; then
print_success "Service started successfully."
# Use the new service testing function for better error handling
if test_service_startup; then
print_success "Service configured and started successfully."
return 0
else
print_error "Failed to start systemd service."
print_warning "Please check the service status using: systemctl status $SERVICE_NAME"
print_warning "And check the logs using: journalctl -u $SERVICE_NAME"
return 1
print_error "Service startup failed or encountered issues."
print_warning "Attempting automatic recovery..."
# Try to fix common issues and restart
if validate_service_configuration && systemctl start "$SERVICE_NAME" >/dev/null 2>&1; then
print_success "Service recovered successfully after fixing configuration issues."
return 0
else
print_error "Automatic recovery failed."
print_warning "Manual intervention may be required."
print_warning "Run the following for detailed diagnosis:"
print_warning " systemctl status $SERVICE_NAME"
print_warning " journalctl -u $SERVICE_NAME -f"
return 1
fi
fi
return 0
}
+282 -37
View File
@@ -1,9 +1,115 @@
const { processPbsTasks } = require('./pbsUtils'); // Assuming pbsUtils.js exists or will be created
const { createApiClientInstance } = require('./apiClients');
const axios = require('axios');
const https = require('https');
let pLimit;
let requestLimiter;
let pLimitInitialized = false;
// Cache for direct node connections
const nodeConnectionCache = new Map();
/**
* Creates a direct connection to a specific node, bypassing cluster routing.
* This is necessary for accessing node-local (non-shared) storage.
* @param {Object} node - The node object containing node information
* @param {Object} clusterConfig - The cluster endpoint configuration
* @returns {Promise<Object>} - API client for direct node connection
*/
async function getDirectNodeConnection(node, clusterConfig) {
const cacheKey = `${node.node}-${clusterConfig.id}`;
// Check cache first
if (nodeConnectionCache.has(cacheKey)) {
return nodeConnectionCache.get(cacheKey);
}
try {
// First, we need to get the node's IP address
// We'll try to resolve it through the cluster API
const nodeIp = node.ip || null;
if (!nodeIp) {
console.warn(`[DataFetcher] Cannot create direct connection to node ${node.node}: No IP address available`);
return null;
}
// Create a new API client with the node's direct IP
const nodeBaseURL = `https://${nodeIp}:8006/api2/json`;
// Use the same auth configuration as the cluster
const authInterceptor = (config) => {
config.headers.Authorization = `PVEAPIToken=${clusterConfig.tokenId}=${clusterConfig.tokenSecret}`;
return config;
};
const retryConfig = {
retries: 1, // Reduce retries for direct connections
retryDelayLogger: (retryCount, error) => {
console.warn(`Retrying direct node API request for ${node.node} (attempt ${retryCount}) due to error: ${error.message}`);
return 500; // Fixed 500ms delay for fast failing
},
retryConditionChecker: (error) => {
return error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.response?.status >= 500;
}
};
// Create a faster client for direct connections
const nodeClient = axios.create({
baseURL: nodeBaseURL,
timeout: 3000, // Very short timeout for direct connections
httpsAgent: new https.Agent({
rejectUnauthorized: !clusterConfig.allowSelfSignedCerts,
timeout: 3000, // Agent-level timeout too
freeSocketTimeout: 3000
}),
headers: {
'Content-Type': 'application/json'
}
});
// Add auth interceptor
nodeClient.interceptors.request.use(authInterceptor);
// Add retry with reduced settings
if (retryConfig) {
const axiosRetry = require('axios-retry').default;
axiosRetry(nodeClient, {
retries: retryConfig.retries || 1,
retryDelay: retryConfig.retryDelayLogger,
retryCondition: retryConfig.retryConditionChecker
});
}
// Test the connection before caching with a quick timeout
try {
// Use a race condition with a very short timeout to fail fast
await Promise.race([
nodeClient.get('/version'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection test timeout')), 1500)
)
]);
console.log(`[DataFetcher] Successfully tested direct connection to node ${node.node} at ${nodeIp}`);
} catch (testError) {
console.warn(`[DataFetcher] Direct connection test failed for ${node.node}: ${testError.message}`);
// Don't cache failed connections
return null;
}
// Cache the connection
nodeConnectionCache.set(cacheKey, nodeClient);
console.log(`[DataFetcher] Created direct connection to node ${node.node} at ${nodeIp}`);
return nodeClient;
} catch (error) {
console.error(`[DataFetcher] Failed to create direct connection to node ${node.node}: ${error.message}`);
return null;
}
}
async function initializePLimit() {
if (pLimitInitialized) return;
// Adding a try-catch for robustness, though module resolution should handle not found.
@@ -136,6 +242,25 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
return { nodes: [], vms: [], containers: [] };
}
// Get node IP addresses from cluster status
const nodeIpMap = new Map();
try {
const clusterStatusResponse = await apiClientInstance.get('/cluster/status');
const clusterStatus = clusterStatusResponse.data?.data || [];
clusterStatus.forEach(item => {
if (item.type === 'node' && item.ip) {
nodeIpMap.set(item.name, item.ip);
}
});
if (nodeIpMap.size > 0) {
console.log(`[DataFetcher - ${endpointName}] Found IP addresses for ${nodeIpMap.size} nodes`);
}
} catch (error) {
console.warn(`[DataFetcher - ${endpointName}] Could not fetch cluster status for node IPs: ${error.message}`);
}
// Pass the correct endpointId to fetchDataForNode with concurrency limiting
const guestPromises = nodes.map(node =>
requestLimiter(() => fetchDataForNode(apiClientInstance, endpointId, node.node))
@@ -161,6 +286,7 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
endpointId: endpointId, // Use endpointId for tagging node
clusterIdentifier: actualClusterName, // Use actual cluster name or endpoint name
endpointType: endpointType, // Added to differentiate cluster vs standalone for labeling
ip: nodeIpMap.get(correspondingNodeInfo.node) || null, // Add IP address for direct connections
};
if (result.status === 'fulfilled' && result.value) {
@@ -648,6 +774,33 @@ async function fetchAllPbsTasksForProcessing({ client, config }, nodeName) {
*/
async function fetchPveBackupTasks(apiClient, endpointId, nodeName) {
try {
// IMPORTANT: Since all backups in this environment go to PBS,
// we should return an empty array for PVE backup tasks.
// This function should only return tasks for traditional PVE storage backups
// (e.g., to local, NFS, or other non-PBS storage).
// Check if any non-PBS backup storage exists
let hasNonPbsBackupStorage = false;
try {
const storageResponse = await apiClient.get('/storage');
const allStorage = storageResponse.data?.data || [];
// Check if there's any storage that supports backups but isn't PBS
hasNonPbsBackupStorage = allStorage.some(storage =>
storage.type !== 'pbs' &&
storage.content &&
storage.content.includes('backup')
);
if (!hasNonPbsBackupStorage) {
// No non-PBS backup storage exists, so there can't be any PVE backups
console.log(`[DataFetcher - ${endpointId}-${nodeName}] No non-PBS backup storage found, skipping PVE backup task collection`);
return [];
}
} catch (error) {
console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Could not fetch storage list: ${error.message}`);
}
const response = await apiClient.get(`/nodes/${nodeName}/tasks`, {
params: {
typefilter: 'vzdump',
@@ -660,10 +813,65 @@ async function fetchPveBackupTasks(apiClient, endpointId, nodeName) {
const backupHistoryDays = parseInt(process.env.BACKUP_HISTORY_DAYS || '365');
const thirtyDaysAgo = Math.floor((Date.now() - backupHistoryDays * 24 * 60 * 60 * 1000) / 1000);
// Filter to configured history period and transform to match PBS backup task format
return tasks
.filter(task => task.starttime >= thirtyDaysAgo)
.map(task => {
// Get PBS storage names to exclude PBS-destined backup tasks
let pbsStorageNames = [];
try {
const storageResponse = await apiClient.get('/storage');
const allStorage = storageResponse.data?.data || [];
pbsStorageNames = allStorage
.filter(storage => storage.type === 'pbs')
.map(storage => storage.storage);
} catch (error) {
console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Could not fetch storage list for PBS filtering: ${error.message}`);
}
// Filter out PBS-destined tasks
const pveOnlyTasks = [];
const recentTasks = tasks.filter(task => task.starttime >= thirtyDaysAgo);
// Since PBS storage exists, we need to carefully filter out PBS tasks
if (pbsStorageNames.length > 0 && recentTasks.length > 0) {
// Check ALL tasks, not just recent ones, to ensure accuracy
for (const task of recentTasks) {
let isPbsTask = false;
try {
// Get first few log lines to check storage destination
const logResponse = await apiClient.get(`/nodes/${nodeName}/tasks/${task.upid}/log`, {
params: { limit: 5, start: 0 }
});
const logEntries = logResponse.data?.data || [];
// Look for storage destination in the log
const logText = logEntries.map(entry => entry.t || '').join(' ');
// Check if this task uses PBS storage
if (pbsStorageNames.some(pbsName => logText.includes(`--storage ${pbsName}`)) ||
logText.includes('proxmox-backup-client') ||
logText.includes('Proxmox Backup Server') ||
logText.includes('--repository')) {
isPbsTask = true;
}
} catch (error) {
// If we can't check the log, assume it's PBS to be safe
isPbsTask = true;
}
if (!isPbsTask) {
pveOnlyTasks.push(task);
}
}
} else if (pbsStorageNames.length === 0) {
// No PBS storage, so all tasks are PVE backups
pveOnlyTasks.push(...recentTasks);
}
// Debug: Log filtering results
if (recentTasks.length > 0) {
console.log(`[DataFetcher - ${endpointId}-${nodeName}] Filtered backup tasks: ${recentTasks.length} recent vzdump tasks -> ${pveOnlyTasks.length} PVE-only (${recentTasks.length - pveOnlyTasks.length} were PBS)`);
}
// Transform remaining PVE-only tasks to match PBS backup task format
return pveOnlyTasks.map(task => {
// Extract guest info from task description or ID
let guestId = null;
let guestType = null;
@@ -716,15 +924,28 @@ async function fetchPveBackupTasks(apiClient, endpointId, nodeName) {
* @param {string} endpointId - The endpoint identifier.
* @param {string} nodeName - The name of the node.
* @param {string} storage - The storage name.
* @param {number} isShared - Whether the storage is shared (0 = local, 1 = shared).
* @param {Object} node - The full node object containing IP address.
* @param {Object} config - The endpoint configuration for auth.
* @returns {Promise<Array>} - Array of backup file objects.
*/
async function fetchStorageBackups(apiClient, endpointId, nodeName, storage) {
async function fetchStorageBackups(apiClient, endpointId, nodeName, storage, isShared, node, config) {
try {
// Skip PBS storage based on the storage name (PBS storages usually have 'pbs' in the name)
// We can't check the global storage config due to permission issues
if (storage.toLowerCase().includes('pbs')) {
console.log(`[DataFetcher - ${endpointId}-${nodeName}] Skipping PBS storage '${storage}' for PVE backup collection`);
return [];
}
// Use the provided API client (direct connection handling is done at higher level)
const response = await apiClient.get(`/nodes/${nodeName}/storage/${storage}/content`, {
params: { content: 'backup' }
});
const backups = response.data?.data || [];
console.log(`[DataFetcher - ${endpointId}-${nodeName}] Found ${backups.length} backup files in storage '${storage}'`);
// Transform to a consistent format
return backups.map(backup => ({
volid: backup.volid,
@@ -740,8 +961,10 @@ async function fetchStorageBackups(apiClient, endpointId, nodeName, storage) {
}));
} 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}`);
if (error.response?.status === 403) {
console.error(`[DataFetcher - ${endpointId}-${nodeName}] Permission denied (403) accessing storage ${storage}. Token needs 'Datastore.Audit' or 'Datastore.AllocateSpace' permission.`);
} else if (error.response?.status !== 501) { // 501 = not implemented
console.warn(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching backups from storage ${storage}: ${error.message} (Status: ${error.response?.status})`);
}
return [];
}
@@ -910,20 +1133,46 @@ async function fetchPveBackupData(currentApiClients, nodes, vms, containers) {
// 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 backupStorages = node.storage.filter(storage =>
storage.content && storage.content.includes('backup')
);
const storageResults = await Promise.allSettled(storagePromises);
storageResults.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
allStorageBackups.push(...result.value);
if (backupStorages.length > 0) {
// Get or create direct connection once per node for efficiency
let directClient = null;
const hasLocalStorage = backupStorages.some(storage => storage.shared === 0);
if (hasLocalStorage && node.ip) {
directClient = await getDirectNodeConnection(node, currentApiClients[endpointId].config);
}
});
const storagePromises = backupStorages.map(async storage => {
// Use pre-established direct connection if available and needed
const clientToUse = (storage.shared === 0 && directClient) ? directClient : apiClient;
console.log(`[DataFetcher - ${endpointId}-${nodeName}] Processing storage '${storage.storage}' (shared=${storage.shared}, type=${storage.type})`);
return fetchStorageBackups(
clientToUse,
endpointId,
nodeName,
storage.storage,
storage.shared,
node,
currentApiClients[endpointId].config
);
});
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
// Fetch snapshots for all VMs and containers, with better error handling
const guestSnapshotPromises = [];
[...vms, ...containers].forEach(guest => {
@@ -938,7 +1187,7 @@ async function fetchPveBackupData(currentApiClients, nodes, vms, containers) {
fetchGuestSnapshots(apiClient, endpointId, nodeName, vmid, type)
.then(snapshots => allGuestSnapshots.push(...snapshots))
.catch(err => {
// Silently handle errors for individual guests
// Silently handle errors for individual guests to prevent blocking
})
);
}
@@ -964,29 +1213,25 @@ async function fetchPveBackupData(currentApiClients, nodes, vms, containers) {
async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetchPbsDataInternal = fetchPbsData) {
// console.log("[DataFetcher] Starting full discovery cycle...");
// Fetch PVE and PBS data in parallel
const [pveResult, pbsResult] = await Promise.all([
fetchPveDiscoveryData(currentApiClients),
_fetchPbsDataInternal(currentPbsApiClients) // Use the potentially injected function
// Fetch PVE discovery data first (needed for backup data)
const pveResult = await fetchPveDiscoveryData(currentApiClients);
// Now fetch PBS and PVE backup data in parallel
const [pbsResult, pveBackups] = await Promise.all([
_fetchPbsDataInternal(currentPbsApiClients),
fetchPveBackupData(
currentApiClients,
pveResult.nodes || [],
pveResult.vms || [],
pveResult.containers || []
)
])
/* istanbul ignore next */ // Ignore this catch block - tested via synchronous error injection
.catch(error => {
// Add a catch block to handle potential rejections from Promise.all itself
// This might happen if one of the main fetch functions throws an unhandled error
// *before* returning a promise (less likely with current async/await structure but safer)
console.error("[DataFetcher] Error during discovery cycle Promise.all:", error);
// Return default structure on catastrophic failure
return [{ nodes: [], vms: [], containers: [] }, []];
console.error("[DataFetcher] Error during parallel backup data fetch:", error);
// Return default structure on failure
return [[], { backupTasks: [], storageBackups: [], guestSnapshots: [] }];
});
// 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 || [],
@@ -995,7 +1240,7 @@ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetc
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, ${pveBackups.backupTasks.length} PVE backup tasks, ${pveBackups.guestSnapshots.length} guest snapshots.`);
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, ${pveBackups.storageBackups.length} PVE storage backups, ${pveBackups.guestSnapshots.length} guest snapshots.`);
return aggregatedResult;
}
+1 -1
View File
@@ -438,7 +438,7 @@ PulseApp.socketHandler = (() => {
const backupsTab = document.querySelector('[data-tab="backups"]');
const isBackupsTabActive = backupsTab && backupsTab.classList.contains('active');
if (PulseApp.ui && PulseApp.ui.backups && data.pbs && isBackupsTabActive) {
if (PulseApp.ui && PulseApp.ui.backups && (data.pbs || data.pveBackups) && isBackupsTabActive) {
PulseApp.ui.backups.updateBackupsTab(false); // Mark as API update, not user action
}
} catch (error) {
+141 -85
View File
@@ -4,6 +4,24 @@ PulseApp.ui.backups = (() => {
let backupsSearchInput = null;
let resetBackupsButton = null;
let backupsTabContent = null;
// Cache for expensive data transformations
let dataCache = {
lastStateHash: null,
processedBackupData: null,
guestBackupStatus: null
};
function _generateStateHash(vmsData, containersData, pbsDataArray, pveBackups) {
// Simple hash based on data lengths and timestamps
const vmCount = vmsData.length;
const ctCount = containersData.length;
const pbsCount = pbsDataArray.length;
const pveTaskCount = pveBackups?.backupTasks?.length || 0;
const pveStorageCount = pveBackups?.storageBackups?.length || 0;
return `${vmCount}-${ctCount}-${pbsCount}-${pveTaskCount}-${pveStorageCount}`;
}
function _initMobileScrollIndicators() {
const tableContainer = document.querySelector('#backups .table-container');
@@ -125,6 +143,7 @@ PulseApp.ui.backups = (() => {
let totalSnapshots = 0;
backupStatusByGuest.forEach(guest => {
switch (guest.backupHealthStatus) {
case 'ok':
case 'stale':
@@ -263,16 +282,27 @@ PulseApp.ui.backups = (() => {
return card;
}
function _extractBackupTypeFromVolid(volid, vmid) {
// Extract type from volid format: vzdump-{type}-{vmid}-{timestamp}
const volidMatch = volid.match(/vzdump-(qemu|lxc)-(\d+)-/);
return volidMatch ? (volidMatch[1] === 'qemu' ? 'vm' : 'ct') : (vmid ? 'ct' : 'vm');
}
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');
// Check if we can use cached data
const currentHash = _generateStateHash(vmsData, containersData, pbsDataArray, pveBackups);
if (dataCache.lastStateHash === currentHash && dataCache.processedBackupData) {
return dataCache.processedBackupData;
}
const allGuests = [...vmsData, ...containersData];
// Debug logging for PBS data
// Combine PBS and PVE backup tasks
const pbsBackupTasks = pbsDataArray.flatMap(pbs => {
return (pbs.backupTasks?.recentTasks || []).map(task => ({
@@ -314,11 +344,15 @@ PulseApp.ui.backups = (() => {
const pveStorageBackups = (pveBackups.storageBackups || []).map(backup => ({
'backup-time': backup.ctime,
backupType: backup.vmid ? 'vm' : 'ct', // Guess based on context
backupType: _extractBackupTypeFromVolid(backup.volid, backup.vmid),
backupVMID: backup.vmid,
vmid: backup.vmid, // Ensure vmid is preserved for filtering
size: backup.size,
protected: backup.protected,
storage: backup.storage,
volid: backup.volid,
node: backup.node,
endpointId: backup.endpointId,
source: 'pve'
}));
@@ -329,16 +363,26 @@ PulseApp.ui.backups = (() => {
const snapshotsByGuest = new Map();
allRecentBackupTasks.forEach(task => {
// Debug task mapping for first few tasks
const key = `${task.guestId}-${task.guestTypePbs}`;
// Include node/endpointId in key to handle multiple clusters with same vmid
const nodeKey = task.node ? `-${task.node}` : (task.endpointId ? `-${task.endpointId}` : '');
const key = `${task.guestId}-${task.guestTypePbs}${nodeKey}`;
if (!tasksByGuest.has(key)) tasksByGuest.set(key, []);
tasksByGuest.get(key).push(task);
});
allSnapshots.forEach(snap => {
const key = `${snap.backupVMID}-${snap.backupType}`;
// Different key strategies for PBS vs PVE:
// PBS: centralized storage, use vmid-type (accessible by any guest with same vmid)
// PVE: node-specific storage, use vmid-type-node
let key;
if (snap.source === 'pbs') {
key = `${snap.backupVMID}-${snap.backupType}`;
} else {
// PVE backups are node-specific
const nodeKey = snap.node ? `-${snap.node}` : (snap.endpointId ? `-${snap.endpointId}` : '');
key = `${snap.backupVMID}-${snap.backupType}${nodeKey}`;
}
if (!snapshotsByGuest.has(key)) snapshotsByGuest.set(key, []);
snapshotsByGuest.get(key).push(snap);
});
@@ -361,7 +405,7 @@ PulseApp.ui.backups = (() => {
const threeDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 3) / 1000);
const sevenDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 7) / 1000);
return {
const result = {
allGuests,
initialDataReceived,
tasksByGuest,
@@ -370,6 +414,12 @@ PulseApp.ui.backups = (() => {
threeDaysAgo,
sevenDaysAgo
};
// Cache the processed data
dataCache.lastStateHash = currentHash;
dataCache.processedBackupData = result;
return result;
}
function _determineGuestBackupStatus(guest, guestSnapshots, guestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo) {
@@ -382,8 +432,6 @@ PulseApp.ui.backups = (() => {
.filter(snap => parseInt(snap.vmid, 10) === parseInt(guest.vmid, 10))
.length;
// Debug disabled
// Use pre-filtered data instead of filtering large arrays
const totalBackups = guestSnapshots ? guestSnapshots.length : 0;
const latestSnapshot = guestSnapshots && guestSnapshots.length > 0
@@ -441,9 +489,6 @@ PulseApp.ui.backups = (() => {
recentFailures = recentFailedTasks.length;
// Debug logging for failed tasks
if (parseInt(guest.vmid) <= 105 && recentFailedTasks.length > 0) {
}
// Find the most recent failure timestamp
if (recentFailedTasks.length > 0) {
@@ -459,12 +504,6 @@ PulseApp.ui.backups = (() => {
recentFailures = 1;
lastFailureTime = displayTimestamp;
if (parseInt(guest.vmid) <= 105) {
}
}
// Final debug log
if (parseInt(guest.vmid) <= 105) {
}
// Enhanced 7-day backup status calculation with backup type tracking
@@ -622,6 +661,7 @@ PulseApp.ui.backups = (() => {
}
}
return {
guestName: guest.name || `Guest ${guest.vmid}`,
guestId: guest.vmid,
@@ -869,22 +909,22 @@ PulseApp.ui.backups = (() => {
// Get PVE storage backups
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
if (pveBackups?.storageBackups && Array.isArray(pveBackups.storageBackups)) {
pveBackups.storageBackups.forEach(backup => {
pveStorageBackups.push({
'backup-time': backup.ctime,
backupType: _extractBackupTypeFromVolid(backup.volid, backup.vmid),
backupVMID: backup.vmid,
vmid: backup.vmid, // Ensure vmid is preserved for filtering
size: backup.size,
protected: backup.protected,
storage: backup.storage,
volid: backup.volid,
format: backup.format,
node: backup.node,
endpointId: backup.endpointId,
source: 'pve'
});
});
}
@@ -963,7 +1003,24 @@ PulseApp.ui.backups = (() => {
const containersData = PulseApp.state.get('containersData') || [];
const allGuests = [...vmsData, ...containersData];
const { tasksByGuest, snapshotsByGuest, dayBoundaries, threeDaysAgo, sevenDaysAgo } = _getInitialBackupData();
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const backupStatusByGuest = allGuests.map(guest => {
// Try both PBS (generic) and PVE (node-specific) keys
const baseKey = `${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`;
const nodeKey = guest.node ? `-${guest.node}` : (guest.endpointId ? `-${guest.endpointId}` : '');
const nodeSpecificKey = `${baseKey}${nodeKey}`;
// Get snapshots from both keys and combine them
const pbsSnapshots = snapshotsByGuest.get(baseKey) || [];
const pveSnapshots = snapshotsByGuest.get(nodeSpecificKey) || [];
const allGuestSnapshots = [...pbsSnapshots, ...pveSnapshots];
// Similar for tasks
const pbsTasks = tasksByGuest.get(baseKey) || [];
const pveTasks = tasksByGuest.get(nodeSpecificKey) || [];
const allGuestTasks = [...pbsTasks, ...pveTasks];
return _determineGuestBackupStatus(guest, allGuestSnapshots, allGuestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo);
});
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
// Get the current backup data
@@ -983,22 +1040,12 @@ PulseApp.ui.backups = (() => {
);
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
if (pveBackups?.storageBackups && Array.isArray(pveBackups.storageBackups)) {
pveBackups.storageBackups.forEach(backup => {
pveStorageBackups.push({
...backup,
source: 'pve'
});
});
}
@@ -1171,21 +1218,13 @@ PulseApp.ui.backups = (() => {
if (pbsSnapshots.length > 0) return true;
// Check PVE storage backups
if (pveBackups.storageBackups) {
for (const [nodeName, nodeData] of Object.entries(pveBackups.storageBackups)) {
if (nodeData && typeof nodeData === 'object') {
for (const [storage, backups] of Object.entries(nodeData)) {
if (Array.isArray(backups)) {
const matchingBackups = backups.filter(backup => {
return backup.vmid == guestId &&
backup.ctime >= startTimestamp &&
backup.ctime < endTimestamp;
});
if (matchingBackups.length > 0) return true;
}
}
}
}
if (pveBackups.storageBackups && Array.isArray(pveBackups.storageBackups)) {
const matchingBackups = pveBackups.storageBackups.filter(backup => {
return backup.vmid == guestId &&
backup.ctime >= startTimestamp &&
backup.ctime < endTimestamp;
});
if (matchingBackups.length > 0) return true;
}
// Check VM snapshots
@@ -1273,7 +1312,24 @@ PulseApp.ui.backups = (() => {
}
loadingMsg.classList.add('hidden');
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const backupStatusByGuest = allGuests.map(guest => {
// Try both PBS (generic) and PVE (node-specific) keys
const baseKey = `${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`;
const nodeKey = guest.node ? `-${guest.node}` : (guest.endpointId ? `-${guest.endpointId}` : '');
const nodeSpecificKey = `${baseKey}${nodeKey}`;
// Get snapshots from both keys and combine them
const pbsSnapshots = snapshotsByGuest.get(baseKey) || [];
const pveSnapshots = snapshotsByGuest.get(nodeSpecificKey) || [];
const allGuestSnapshots = [...pbsSnapshots, ...pveSnapshots];
// Similar for tasks
const pbsTasks = tasksByGuest.get(baseKey) || [];
const pveTasks = tasksByGuest.get(nodeSpecificKey) || [];
const allGuestTasks = [...pbsTasks, ...pveTasks];
return _determineGuestBackupStatus(guest, allGuestSnapshots, allGuestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo);
});
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
// Prepare backup data for consolidated summary
@@ -1294,22 +1350,22 @@ PulseApp.ui.backups = (() => {
// Get PVE storage backups
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
if (pveBackups?.storageBackups && Array.isArray(pveBackups.storageBackups)) {
pveBackups.storageBackups.forEach(backup => {
pveStorageBackups.push({
'backup-time': backup.ctime,
backupType: _extractBackupTypeFromVolid(backup.volid, backup.vmid),
backupVMID: backup.vmid,
vmid: backup.vmid, // Ensure vmid is preserved for filtering
size: backup.size,
protected: backup.protected,
storage: backup.storage,
volid: backup.volid,
format: backup.format,
node: backup.node,
endpointId: backup.endpointId,
source: 'pve'
});
});
}
@@ -1836,7 +1892,7 @@ PulseApp.ui.backups = (() => {
const pveDates = {};
backupData.pveBackups.forEach(backup => {
if (backup.vmid == guestId) {
const timestamp = backup.ctime;
const timestamp = backup['backup-time'] || backup.ctime;
if (timestamp) {
const date = new Date(timestamp * 1000);
const dateKey = date.toISOString().split('T')[0];