mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-04 05:07:30 +00:00
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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user