diff --git a/.env.example b/.env.example index 0cb3030fd..239b207b3 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,83 @@ -# Proxmox VE Connection Details for Pulse Monitoring +# Pulse Environment Variables Example +# Copy this file to .env in the same directory and fill in your details. +# DO NOT commit your actual .env file to version control. -# --- Primary Proxmox Endpoint (Required) --- -# URL of your Proxmox server or a node within the cluster -PROXMOX_HOST=https://your-proxmox-ip-or-hostname:8006 - -# Proxmox API Token ID (e.g., user@pam!tokenid) - Recommended & Required +# --- Primary Proxmox VE Endpoint --- +# Required connection details for your main Proxmox VE host or cluster. +PROXMOX_HOST=your-proxmox-ip-or-hostname PROXMOX_TOKEN_ID=your-api-token-id@pam!your-token-name - -# Proxmox API Token Secret (UUID format) - Recommended & Required PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid -# --- Optional Primary Endpoint Settings --- -# Display name for this endpoint (defaults to PROXMOX_HOST if unset) -# PROXMOX_NODE_NAME=MyPrimaryCluster +# Optional: Node name for display (defaults to PROXMOX_HOST if not set) +# PROXMOX_NODE_NAME=MyPrimaryNode -# Allow connections to servers with self-signed SSL certificates (true/false) -# Defaults to false if unset. Set to true only if needed and you understand the risks. -# PROXMOX_ALLOW_SELF_SIGNED_CERTS=false - -# Proxmox API port (defaults to 8006 if unset) +# Optional: Proxmox API port (defaults to 8006 if not set) # PROXMOX_PORT=8006 -# --- Additional Proxmox Endpoints (Optional) --- -# To monitor more than one separate Proxmox environment (cluster or standalone node), -# add blocks of variables starting with index 2 (_2, _3, etc.) +# Optional: Set to 'false' to disable certificate validation (useful for self-signed certs) +# Defaults to 'true' (validation enabled) if not set. +PROXMOX_ALLOW_SELF_SIGNED_CERTS=true -# Example for a second endpoint: -# PROXMOX_HOST_2=https://second-proxmox-ip:8006 -# PROXMOX_TOKEN_ID_2=user@pam!second-token # Required for additional endpoints -# PROXMOX_TOKEN_SECRET_2=another-api-token-secret-uuid # Required for additional endpoints -# Optional settings for the second endpoint: -# PROXMOX_NODE_NAME_2=MySecondCluster -# PROXMOX_ALLOW_SELF_SIGNED_CERTS_2=false +# Optional: Fallback username/password (use Token Auth primarily) +# PROXMOX_USERNAME=your_username +# PROXMOX_PASSWORD=your_password +# PROXMOX_REALM=pam # or pve, etc. + +# Optional: Set to 'false' to disable this endpoint (defaults to true) +# PROXMOX_ENABLED=true + +# --- Additional Proxmox VE Endpoints --- +# Use suffixes _2, _3, etc., for more endpoints. HOST, TOKEN_ID, and TOKEN_SECRET are required for each. +# PROXMOX_HOST_2=second-proxmox-ip +# PROXMOX_TOKEN_ID_2=user@realm!token_name +# PROXMOX_TOKEN_SECRET_2=secret-uuid +# PROXMOX_NODE_NAME_2=MySecondNode # PROXMOX_PORT_2=8006 +# PROXMOX_ALLOW_SELF_SIGNED_CERTS_2=true +# PROXMOX_ENABLED_2=true -# Example for a third endpoint: -# PROXMOX_HOST_3=https://third-proxmox-ip:8006 -# PROXMOX_TOKEN_ID_3=user@pve!third-token # Required for additional endpoints -# PROXMOX_TOKEN_SECRET_3=yet-another-api-token-secret-uuid # Required for additional endpoints -# Optional settings for the third endpoint: -# PROXMOX_NODE_NAME_3=StandaloneNode -# PROXMOX_ALLOW_SELF_SIGNED_CERTS_3=true +# PROXMOX_HOST_3=third-proxmox-ip +# PROXMOX_TOKEN_ID_3=user@realm!token_name +# PROXMOX_TOKEN_SECRET_3=secret-uuid +# ... and so on -# --- Pulse Server Settings --- -# Port for the Pulse monitoring server itself to listen on -# Defaults to 7655 if unset -# PORT=7655 +# --- Primary Proxmox Backup Server (PBS) Endpoint --- +# Required: Set PBS_HOST and EITHER (PBS_USER + PBS_PASSWORD) OR (PBS_TOKEN_ID + PBS_TOKEN_SECRET) +PBS_HOST=your-pbs-ip-or-hostname + +# Option 1: User/Password Authentication +# PBS_USER=your_pbs_user@pbs # e.g., root@pam or backupuser@pbs +# PBS_PASSWORD=your_pbs_password +# PBS_REALM=pbs # Optional: defaults to 'pbs' if using user/pass + +# Option 2: API Token Authentication (Recommended) +PBS_TOKEN_ID=your_pbs_api_token_id@pbs!your_token_name # e.g., backup@pbs!pulse-token +PBS_TOKEN_SECRET=your_pbs_api_token_secret_uuid + +# Optional: PBS instance name for display (defaults to PBS_HOST if not set) +# PBS_NODE_NAME=MyPrimaryPBS + +# Optional: PBS API port (defaults to 8007 if not set) +# PBS_PORT=8007 + +# Optional: Set to 'false' to disable certificate validation for PBS +# Defaults to 'true' (validation enabled) if not set. +PBS_ALLOW_SELF_SIGNED_CERTS=true + +# --- Additional Proxmox Backup Server (PBS) Endpoints --- +# Use suffixes _2, _3, etc., for more PBS instances. PBS_HOST_n and authentication details are required. +# PBS_HOST_2=second-pbs-ip +# Option 1: User/Password +# PBS_USER_2=user@realm +# PBS_PASSWORD_2=password +# PBS_REALM_2=pbs +# Option 2: Token +# PBS_TOKEN_ID_2=token_id@realm!token_name +# PBS_TOKEN_SECRET_2=secret-uuid +# PBS_NODE_NAME_2=MySecondPBS +# PBS_PORT_2=8007 +# PBS_ALLOW_SELF_SIGNED_CERTS_2=true + +# --- Development Settings --- +# Set to 'development' to enable features like hot-reloading (requires chokidar dev dependency) +# NODE_ENV=development \ No newline at end of file diff --git a/server/index.js b/server/index.js index ab0603ee8..122dc55b5 100644 --- a/server/index.js +++ b/server/index.js @@ -124,71 +124,108 @@ if (process.env.NODE_ENV === 'development') { // --- Create API Clients for Each Endpoint --- const apiClients = {}; // Use an object to store clients, keyed by endpoint.id -let pbsApiClient = null; // Initialize PBS client as null -let pbsConfig = null; // Initialize PBS config as null +const pbsConfigs = []; // Array to hold all parsed PBS configurations +const pbsApiClients = {}; // Object to hold initialized clients, keyed by pbsConfig.id // --- Load PBS Configuration (if provided) --- -// Check for User/Password first -if (process.env.PBS_HOST && process.env.PBS_USER && process.env.PBS_PASSWORD) { - const pbsHost = process.env.PBS_HOST; - const pbsUser = process.env.PBS_USER; - const pbsPassword = process.env.PBS_PASSWORD; - const pbsRealm = process.env.PBS_REALM || 'pbs'; // Default realm 'pbs' +function loadPbsConfig(index = null) { + const suffix = index ? `_${index}` : ''; + const hostVar = `PBS_HOST${suffix}`; + const userVar = `PBS_USER${suffix}`; + const passVar = `PBS_PASSWORD${suffix}`; + const realmVar = `PBS_REALM${suffix}`; + const tokenIdVar = `PBS_TOKEN_ID${suffix}`; + const tokenSecretVar = `PBS_TOKEN_SECRET${suffix}`; + const nodeNameVar = `PBS_NODE_NAME${suffix}`; + const portVar = `PBS_PORT${suffix}`; + const selfSignedVar = `PBS_ALLOW_SELF_SIGNED_CERTS${suffix}`; - // Basic placeholder check for PBS user/pass vars - const pbsPlaceholders = placeholderValues.filter(p => - pbsHost.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p) - ); - - if (pbsPlaceholders.length > 0) { - console.warn(`WARN: Skipping PBS configuration. The following variables seem to contain placeholder values: ${pbsPlaceholders.join(', ')}`); - } else { - pbsConfig = { - id: 'pbs_primary_userpass', - authMethod: 'userpass', // Indicate auth method - name: process.env.PBS_NODE_NAME || pbsHost, - host: pbsHost, - port: process.env.PBS_PORT || '8007', - user: pbsUser, - password: pbsPassword, - realm: pbsRealm, - nodeName: process.env.PBS_NODE_NAME, - allowSelfSignedCerts: process.env.PBS_ALLOW_SELF_SIGNED_CERTS !== 'false', - enabled: true - }; - console.log(`INFO: Found PBS configuration (User/Password): ${pbsConfig.name} (${pbsConfig.host})`); + const pbsHost = process.env[hostVar]; + if (!pbsHost) { + // No more PBS configs if PBS_HOST is missing + return false; // Indicate no more configs found } -// Check for Token second (fallback) -} else if (process.env.PBS_HOST && process.env.PBS_TOKEN_ID && process.env.PBS_TOKEN_SECRET) { - const pbsHost = process.env.PBS_HOST; - const pbsTokenId = process.env.PBS_TOKEN_ID; - const pbsTokenSecret = process.env.PBS_TOKEN_SECRET; - // Basic placeholder check for PBS token vars - const pbsPlaceholders = placeholderValues.filter(p => - pbsHost.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) - ); + const pbsUser = process.env[userVar]; + const pbsPassword = process.env[passVar]; + const pbsTokenId = process.env[tokenIdVar]; + const pbsTokenSecret = process.env[tokenSecretVar]; - if (pbsPlaceholders.length > 0) { - console.warn(`WARN: Skipping PBS configuration (Token). The following variables seem to contain placeholder values: ${pbsPlaceholders.join(', ')}`); - } else { - pbsConfig = { - id: 'pbs_primary_token', - authMethod: 'token', // Indicate auth method - name: process.env.PBS_NODE_NAME || pbsHost, - host: pbsHost, - port: process.env.PBS_PORT || '8007', - tokenId: pbsTokenId, - tokenSecret: pbsTokenSecret, - nodeName: process.env.PBS_NODE_NAME, - allowSelfSignedCerts: process.env.PBS_ALLOW_SELF_SIGNED_CERTS !== 'false', - enabled: true - }; - console.log(`INFO: Found PBS configuration (API Token): ${pbsConfig.name} (${pbsConfig.host})`); + let config = null; + let idPrefix = index ? `pbs_endpoint_${index}` : 'pbs_primary'; + + // Check User/Password first + if (pbsUser && pbsPassword) { + const pbsPlaceholders = placeholderValues.filter(p => + pbsHost.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p) + ); + if (pbsPlaceholders.length > 0) { + console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (User/Pass). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`); + } else { + config = { + id: `${idPrefix}_userpass`, + authMethod: 'userpass', + name: process.env[nodeNameVar] || pbsHost, // User-defined name or host + host: pbsHost, + port: process.env[portVar] || '8007', + user: pbsUser, + password: pbsPassword, + realm: process.env[realmVar] || 'pbs', + nodeName: process.env[nodeNameVar], // Store explicitly set node name + allowSelfSignedCerts: process.env[selfSignedVar] !== 'false', + enabled: true // Assuming enabled if configured + }; + console.log(`INFO: Found PBS configuration ${index || 'primary'} (User/Password): ${config.name} (${config.host})`); + } + } + // Check Token second + else if (pbsTokenId && pbsTokenSecret) { + const pbsPlaceholders = placeholderValues.filter(p => + pbsHost.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) + ); + if (pbsPlaceholders.length > 0) { + console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (Token). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`); + } else { + config = { + id: `${idPrefix}_token`, + authMethod: 'token', + name: process.env[nodeNameVar] || pbsHost, + host: pbsHost, + port: process.env[portVar] || '8007', + tokenId: pbsTokenId, + tokenSecret: pbsTokenSecret, + nodeName: process.env[nodeNameVar], + allowSelfSignedCerts: process.env[selfSignedVar] !== 'false', + enabled: true + }; + console.log(`INFO: Found PBS configuration ${index || 'primary'} (API Token): ${config.name} (${config.host})`); + } + } + // Warn if host is set but auth is incomplete + else { + console.warn(`WARN: Partial PBS configuration found for ${hostVar}. Please set either (${userVar} + ${passVar}) or (${tokenIdVar} + ${tokenSecretVar}) along with ${hostVar}.`); } -} else if (process.env.PBS_HOST || process.env.PBS_TOKEN_ID || process.env.PBS_TOKEN_SECRET || process.env.PBS_USER || process.env.PBS_PASSWORD) { - // Warn if some but not all required PBS vars are set - console.warn("WARN: Partial PBS configuration found. Please set PBS_HOST and either (PBS_TOKEN_ID + PBS_TOKEN_SECRET) or (PBS_USER + PBS_PASSWORD) to enable PBS monitoring."); + + if (config) { + pbsConfigs.push(config); + return true; // Indicate a config was found and added + } + return true; // Indicate we should check the next index even if this one was partial/invalid +} + +// Load primary PBS config (index=null) +loadPbsConfig(); + +// Load additional PBS configs (index=2, 3, ...) +let pbsIndex = 2; +while (loadPbsConfig(pbsIndex)) { + pbsIndex++; +} + +if (pbsConfigs.length > 0) { + console.log(`INFO: Loaded configuration for ${pbsConfigs.length} PBS instances.`); +} else { + console.log("INFO: No PBS instances configured."); } // --- End PBS Configuration Loading --- @@ -321,54 +358,69 @@ async function getPbsAuthTicketAndSetupClient(config) { } } -async function initializePbsClient() { - if (pbsConfig) { - if (pbsConfig.authMethod === 'userpass') { - pbsApiClient = await getPbsAuthTicketAndSetupClient(pbsConfig); - if (pbsApiClient) { - console.log(`INFO: Initialized API client for PBS (User/Password Auth): ${pbsConfig.name} (${pbsConfig.host})`); - } else { - console.error("ERROR: Failed to initialize PBS client using User/Password."); - } - } else if (pbsConfig.authMethod === 'token') { - // Original Token Auth Logic - const pbsBaseURL = pbsConfig.host.includes('://') - ? `${pbsConfig.host}/api2/json` - : `https://${pbsConfig.host}:${pbsConfig.port}/api2/json`; +async function initializeAllPbsClients() { + if (pbsConfigs.length === 0) return; - const pbsAxiosInstance = axios.create({ - baseURL: pbsBaseURL, - httpsAgent: new https.Agent({ - rejectUnauthorized: !pbsConfig.allowSelfSignedCerts - }), - headers: { - 'Content-Type': 'application/json' + console.log(`INFO: Initializing API clients for ${pbsConfigs.length} PBS instances...`); + const initPromises = pbsConfigs.map(async (config) => { + let clientData = null; + try { + if (config.authMethod === 'userpass') { + clientData = await getPbsAuthTicketAndSetupClient(config); + if (clientData) { + console.log(`INFO: Initialized API client for PBS (User/Password Auth): ${config.name} (${config.host})`); + } else { + console.error(`ERROR: Failed to initialize PBS client (User/Password) for: ${config.name} (${config.host})`); } - }); + } else if (config.authMethod === 'token') { + // Token Auth Logic (adapted from old initializePbsClient) + const pbsBaseURL = config.host.includes('://') + ? `${config.host}/api2/json` + : `https://${config.host}:${config.port}/api2/json`; - pbsAxiosInstance.interceptors.request.use(config => { - config.headers.Authorization = `PBSAPIToken ${pbsConfig.tokenId}:${pbsConfig.tokenSecret}`; - return config; - }); + const pbsAxiosInstance = axios.create({ + baseURL: pbsBaseURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: !config.allowSelfSignedCerts + }), + headers: { 'Content-Type': 'application/json' } + }); - axiosRetry(pbsAxiosInstance, { - retries: 3, - retryDelay: (retryCount, error) => { - console.warn(`Retrying PBS API request (Token Auth - attempt ${retryCount}) due to error: ${error.message}`); - return axiosRetry.exponentialDelay(retryCount); - }, - retryCondition: (error) => { - return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error); - }, - }); + pbsAxiosInstance.interceptors.request.use(reqConfig => { + reqConfig.headers.Authorization = `PBSAPIToken ${config.tokenId}:${config.tokenSecret}`; + return reqConfig; + }); - pbsApiClient = { client: pbsAxiosInstance, config: pbsConfig }; - console.log(`INFO: Initialized API client for PBS (Token Auth): ${pbsConfig.name} (${pbsConfig.host})`); + axiosRetry(pbsAxiosInstance, { + retries: 3, + retryDelay: (retryCount, error) => { + console.warn(`Retrying PBS API request for ${config.name} (Token Auth - attempt ${retryCount}) due to error: ${error.message}`); + return axiosRetry.exponentialDelay(retryCount); + }, + retryCondition: (error) => { + return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error); + }, + }); + + clientData = { client: pbsAxiosInstance, config: config }; + console.log(`INFO: Initialized API client for PBS (Token Auth): ${config.name} (${config.host})`); + } else { + console.error(`ERROR: Unknown authMethod '${config.authMethod}' for PBS config: ${config.name}`); + } + + if (clientData) { + pbsApiClients[config.id] = clientData; // Store successful client keyed by config ID + } + } catch (error) { + console.error(`ERROR: Unhandled exception during PBS client initialization for ${config.name}: ${error.message}`); } - } + }); + + await Promise.allSettled(initPromises); + console.log(`INFO: Finished initializing PBS API clients. ${Object.keys(pbsApiClients).length} clients successfully initialized.`); } -if (Object.keys(apiClients).length === 0 && !pbsApiClient) { +if (Object.keys(apiClients).length === 0 && pbsConfigs.length === 0) { console.error("\n--- Configuration Error ---"); console.error("No enabled Proxmox VE or PBS endpoints could be configured. Please check your .env file and environment variables."); process.exit(1); @@ -562,7 +614,7 @@ let currentNodes = []; let currentVms = []; let currentContainers = []; let currentMetrics = []; -let pbsData = { status: 'initializing' }; // Initial status +let pbsDataArray = []; // Array to hold data for each PBS instance let isDiscoveryRunning = false; // Prevent concurrent discovery runs let isMetricsRunning = false; // Prevent concurrent metric runs let discoveryTimeoutId = null; @@ -705,8 +757,7 @@ async function fetchDataForNode(apiClient, endpointId, nodeName) { */ async function fetchDiscoveryData() { console.log("[Discovery Cycle] Starting fetch across all endpoints..."); - // Initialize with the *current* pbsData state to preserve status between fetches if a fetch fails - let aggregatedResult = { nodes: [], vms: [], containers: [], pbs: pbsData || { status: 'initializing' } }; + let aggregatedResult = { nodes: [], vms: [], containers: [], pbs: [] }; // pbs is now an array // --- PVE Data Fetching --- const pveEndpointIds = Object.keys(apiClients).filter(id => !apiClients[id].isPbs); // Exclude PBS client if present @@ -818,111 +869,113 @@ async function fetchDiscoveryData() { } // --- Fetch PBS Data (if configured) --- - let pbsFetchStatus = 'unconfigured'; // Default status if not configured - let fetchedPbsData = null; // Store successfully fetched data + const pbsClientIds = Object.keys(pbsApiClients); + const pbsDataResults = []; // Array to hold results for each PBS instance - if (pbsApiClient) { - console.log("INFO: Fetching PBS discovery data..."); - let pbsNodeName = pbsApiClient.config.nodeName; // Use configured name if available + if (pbsClientIds.length > 0) { + console.log(`INFO: Fetching discovery data for ${pbsClientIds.length} PBS instances...`); + const pbsPromises = pbsClientIds.map(async (pbsClientId) => { + const { client: pbsClientInstance, config: pbsInstanceConfig } = pbsApiClients[pbsClientId]; + const instanceName = pbsInstanceConfig.name; // Use the configured name + let instanceData = { + pbsEndpointId: pbsClientId, // Identifier for this PBS instance + pbsInstanceName: instanceName, // Human-readable name + status: 'error', // Default to error + nodeName: pbsInstanceConfig.nodeName, // Start with configured node name + backupTasks: { recentTasks: [], summary: {} }, + datastores: [], + verificationTasks: { summary: {} }, + syncTasks: { summary: {} }, + pruneTasks: { summary: {} } + }; - if (!pbsApiClient.client) { - console.error("ERROR: pbsApiClient.client is not initialized!"); - pbsFetchStatus = 'error'; // Set status to error if client isn't ready - } else { - if (!pbsNodeName) { - pbsNodeName = await fetchPbsNodeName(pbsApiClient); - if (pbsNodeName && pbsNodeName !== 'localhost') { - pbsApiClient.config.nodeName = pbsNodeName; + try { + // Ensure client is valid (redundant check, should be caught in init) + if (!pbsClientInstance) { + throw new Error(`Client not initialized for PBS instance: ${instanceName}`); } - } - // Only proceed if we have a node name (or fallback 'localhost') - if (pbsNodeName) { - try { - // Fetch all data concurrently + // Determine node name if not pre-configured + if (!instanceData.nodeName) { + instanceData.nodeName = await fetchPbsNodeName({ client: pbsClientInstance, config: pbsInstanceConfig }); // Pass the object + // Store detected name back in config for future use (within this run cycle) + if (instanceData.nodeName && instanceData.nodeName !== 'localhost') { + pbsInstanceConfig.nodeName = instanceData.nodeName; // Update the config object directly + } + } + + // Only proceed if we have a node name + if (instanceData.nodeName) { + // Fetch all data concurrently for this instance const [ - backupTasks, // Now includes detailed recent tasks - datastores, - verificationTasks, - syncTasks, - pruneTasks + backupTasksResult, + datastoresResult, + verificationTasksResult, + syncTasksResult, + pruneTasksResult ] = await Promise.all([ - fetchPbsTaskData(pbsApiClient, pbsNodeName), // Enhanced function - fetchPbsDatastoreData(pbsApiClient), - fetchPbsTaskSummaryByType(pbsApiClient, pbsNodeName, ['verify']), // Fetch verification tasks - fetchPbsTaskSummaryByType(pbsApiClient, pbsNodeName, ['sync']), // Fetch sync tasks - fetchPbsTaskSummaryByType(pbsApiClient, pbsNodeName, ['garbage_collection', 'prune']) // Fetch GC/Prune tasks + fetchPbsTaskData({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName), + fetchPbsDatastoreData({ client: pbsClientInstance, config: pbsInstanceConfig }), + fetchPbsTaskSummaryByType({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName, ['verify']), + fetchPbsTaskSummaryByType({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName, ['sync']), + fetchPbsTaskSummaryByType({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName, ['garbage_collection', 'prune']) ]); - // Combine fetched data *only on success* - fetchedPbsData = { - backupTasks: backupTasks, // Contains { recentTasks: [], summary: {...} } - datastores: datastores, // Contains GC status per datastore - verificationTasks: verificationTasks, // Contains summary: {...} - syncTasks: syncTasks, // Contains summary: {...} - pruneTasks: pruneTasks, // Contains summary: {...} for GC/Prune - nodeName: pbsNodeName, - status: 'ok' // Overall status is OK if all fetches succeed - }; - pbsFetchStatus = 'ok'; // Update status on successful fetch - console.log("INFO: Finished fetching PBS discovery data successfully."); - - } catch (pbsError) { - console.error(`ERROR: Failed fetching PBS data during discovery cycle: ${pbsError.message}`); - pbsFetchStatus = 'error'; // Set status to error on fetch failure - if (pbsError.response?.status === 401) { - console.error("ERROR: PBS API Authentication Expired or Invalid (401). Re-login needed."); - } - // Keep fetchedPbsData as null + // Assign results to the instanceData object + instanceData.backupTasks = backupTasksResult; + instanceData.datastores = datastoresResult; + instanceData.verificationTasks = verificationTasksResult; + instanceData.syncTasks = syncTasksResult; + instanceData.pruneTasks = pruneTasksResult; + instanceData.status = 'ok'; // Mark as OK if all fetches succeeded + console.log(`INFO: Finished fetching PBS discovery data successfully for: ${instanceName}`); + } else { + console.error(`ERROR: Could not determine node name for PBS instance ${instanceName}, cannot fetch task data.`); + instanceData.status = 'error'; // Keep status as error } - } else { - console.error("ERROR: Could not determine PBS node name, cannot fetch PBS task data."); - pbsFetchStatus = 'error'; // Error if node name is missing + + } catch (pbsError) { + console.error(`ERROR: Failed fetching PBS data for instance ${instanceName}: ${pbsError.message}`); + instanceData.status = 'error'; // Ensure status is error on any failure + if (pbsError.response?.status === 401) { + console.error(`ERROR: PBS API Authentication Expired/Invalid (401) for ${instanceName}.`); + } + // instanceData already defaults to error state with empty data structures } - } + return instanceData; // Return the data object for this instance (ok or error state) + }); // End map over pbsClientIds + + const settledPbsResults = await Promise.allSettled(pbsPromises); + settledPbsResults.forEach(result => { + if (result.status === 'fulfilled') { + pbsDataResults.push(result.value); // Add the instance data (ok or error) + } else { + // This should ideally not happen if errors are caught within the promise + console.error(`ERROR: Unhandled rejection fetching PBS data: ${result.reason}`); + // Could push a generic error object here if needed + } + }); + } else { - console.log("[Discovery Cycle] No PBS client configured."); - pbsFetchStatus = 'unconfigured'; - // Ensure a default structure for unconfigured state - fetchedPbsData = { - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - datastores: [], - verificationTasks: { summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - syncTasks: { summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - pruneTasks: { summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - nodeName: null, - status: 'unconfigured' - }; + console.log("[Discovery Cycle] No PBS instances configured or initialized."); + // pbsDataResults remains empty } // --- End Fetch PBS Data --- // --- Update Global State --- - // Update pbsData only if the fetch was successful OR if it's the first time and status is unconfigured - if (fetchedPbsData && (pbsFetchStatus === 'ok' || (pbsFetchStatus === 'unconfigured' && pbsData.status === 'initializing'))) { - pbsData = fetchedPbsData; - } else if (pbsFetchStatus === 'error') { - // If fetch failed, update only the status field in the global pbsData - // Preserve the last known good data for other fields - pbsData = { - ...(pbsData || {}), // Keep existing data - status: 'error', // Update status to error - nodeName: pbsApiClient?.config?.nodeName || pbsData?.nodeName || null // Preserve node name if possible - }; - // Ensure essential structures exist if pbsData was previously null/empty - pbsData.backupTasks = pbsData.backupTasks || { recentTasks: [], summary: {} }; - pbsData.datastores = pbsData.datastores || []; - pbsData.verificationTasks = pbsData.verificationTasks || { summary: {} }; - pbsData.syncTasks = pbsData.syncTasks || { summary: {} }; - pbsData.pruneTasks = pbsData.pruneTasks || { summary: {} }; - } + // ---> CHANGE: Update pbsDataArray (the global state) + pbsDataArray = pbsDataResults; // Replace the global state with the newly fetched array + // <--- END CHANGE // Always update PVE data aggregatedResult.nodes = tempNodes; aggregatedResult.vms = tempVms; aggregatedResult.containers = tempContainers; - // Add the final, potentially updated pbsData state to the result - aggregatedResult.pbs = pbsData; + // ---> CHANGE: Add the final pbsDataArray state to the result + // aggregatedResult.pbs = pbsData; + aggregatedResult.pbs = pbsDataArray; + // <--- END CHANGE if (DEBUG_METRICS) { console.log(`[Discovery Cycle] Aggregated PVE results. Total: ${aggregatedResult.nodes.length} nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} containers across ${pveEndpointIds.length} configured PVE endpoints.`); @@ -1035,34 +1088,32 @@ async function fetchMetricsData(runningVms, runningContainers) { io.on('connection', (socket) => { console.log(`[socket] Client connected. Total clients: ${io.engine.clientsCount}`); - // ---> NEW: Send initial PBS status immediately <--- - let initialPbsStatus = 'unconfigured'; - if (pbsConfig) { - initialPbsStatus = 'configured'; // Assume configured if ENV vars are set - // We don't know the *actual* connection status yet, - // discovery cycle will provide 'ok' or 'error' later. + // ---> CHANGE: Send initial status based on pbsConfigs array + let initialPbsStatuses = pbsConfigs.map(conf => ({ + pbsEndpointId: conf.id, + pbsInstanceName: conf.name, + status: 'configured' // Assume configured initially + })); + if (initialPbsStatuses.length === 0) { + initialPbsStatuses.push({ pbsEndpointId: 'none', pbsInstanceName: 'None', status: 'unconfigured' }); } - console.log(`[socket] Sending initial PBS status to new client: ${initialPbsStatus}`); - socket.emit('pbsInitialStatus', { status: initialPbsStatus }); - // ---> END NEW SECTION <--- + console.log(`[socket] Sending initial PBS status array to new client:`, initialPbsStatuses); + socket.emit('pbsInitialStatus', initialPbsStatuses); // Send the array + // ---> END CHANGE <--- - // Send initial PVE/Metric data immediately if available + // ---> CHANGE: Send initial data using pbsDataArray if (currentNodes.length > 0 || currentVms.length > 0 || currentContainers.length > 0) { console.log('[socket] Sending existing PVE/Metric data to new client.'); - // Send existing data but ensure pbs status reflects the initial one sent above, - // as the global pbsData might not be initialized or might be stale if discovery hasn't run. - let pbsToSend = pbsData || { tasks: {}, datastores: [], nodeName: null, status: initialPbsStatus }; - // Make sure the status aligns with what we just sent in pbsInitialStatus if global state is uninit - if (pbsToSend.status === 'initializing') { - pbsToSend.status = initialPbsStatus; - } + + // If pbsDataArray is empty but configs exist, send the initial configured statuses + let pbsToSend = (pbsDataArray && pbsDataArray.length > 0) ? pbsDataArray : initialPbsStatuses.map(s => ({ ...s })); // Use copy socket.emit('rawData', { nodes: currentNodes, vms: currentVms, containers: currentContainers, metrics: currentMetrics, - pbs: pbsToSend // Send current global PBS state or initial status + pbs: pbsToSend // Send the array of PBS data/statuses }); } else { // If no data yet, trigger a discovery cycle (if not already running) @@ -1092,21 +1143,17 @@ async function runDiscoveryCycle() { isDiscoveryRunning = true; try { - const discoveryData = await fetchDiscoveryData(); // This now returns {nodes, vms, containers, pbs} + const discoveryData = await fetchDiscoveryData(); // Returns {nodes, vms, containers, pbs: pbsDataArray} - // Update global state variables from the returned data - currentNodes = discoveryData.nodes || []; // Use fallback for safety + // Update global state variables + currentNodes = discoveryData.nodes || []; currentVms = discoveryData.vms || []; currentContainers = discoveryData.containers || []; - // Only update pbsData if it's present in the result (handles initial state) - if (discoveryData.pbs) { - pbsData = discoveryData.pbs; - } else { - // If fetchDiscoveryData somehow didn't return pbs, keep old state or default - pbsData = pbsData || { tasks: {}, datastores: [], nodeName: null, status: 'error' }; - } + // ---> CHANGE: Update pbsDataArray + pbsDataArray = discoveryData.pbs || []; // Update the global array + // <--- END CHANGE - // Emit combined data using the updated global variables + // Emit combined data if (io.engine.clientsCount > 0) { if (DEBUG_METRICS) { console.log('[Discovery Cycle] Emitting updated structural data including PBS.'); @@ -1115,7 +1162,7 @@ async function runDiscoveryCycle() { nodes: currentNodes, vms: currentVms, containers: currentContainers, - pbs: pbsData // Use the updated global pbs state + pbs: pbsDataArray }); } } catch (error) { @@ -1168,11 +1215,11 @@ async function runMetricCycle() { // Emit combined data (always emit, even if metrics weren't updated this cycle) io.emit('rawData', { - nodes: currentNodes, // Send current nodes/vms/cts state + nodes: currentNodes, vms: currentVms, containers: currentContainers, - pbs: pbsData, // ADDED: Include current PBS state - metrics: currentMetrics // Send the newly fetched metrics + pbs: pbsDataArray, + metrics: currentMetrics }); } else { // console.log('[Metrics Cycle] No running guests found, skipping metric fetch.'); @@ -1181,7 +1228,7 @@ async function runMetricCycle() { io.emit('rawData', { nodes: currentNodes, vms: currentVms, containers: currentContainers, metrics: currentMetrics, - pbs: pbsData // ADDED: Include current PBS state + pbs: pbsDataArray }); } @@ -1211,8 +1258,6 @@ runDiscoveryCycle(); // Run discovery first // Metrics will be triggered after discovery or by its own timer if clients connect later scheduleNextMetric(); // Start scheduling metrics right away -// --- End New Update Cycle Logic --- - // --- PBS Data Fetching Functions --- async function fetchPbsNodeName(pbsClient) { @@ -1446,7 +1491,7 @@ async function fetchPbsDatastoreData(pbsClient) { // Start the server async function startServer() { - await initializePbsClient(); // Initialize PBS client (might fail) + await initializeAllPbsClients(); server.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); diff --git a/src/public/app.js b/src/public/app.js index c4656ce34..37a761037 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -121,7 +121,10 @@ document.addEventListener('DOMContentLoaded', function() { let containersData = []; let metricsData = []; let dashboardData = []; - let pbsData = {}; // Add state for PBS data + // ---> CHANGE: pbsData becomes pbsDataArray + // let pbsData = {}; // Add state for PBS data + let pbsDataArray = []; // Holds data for multiple PBS instances + // <--- END CHANGE // Load saved sort state from localStorage or use defaults const savedSortState = JSON.parse(localStorage.getItem('pulseSortState')) || {}; const sortState = { @@ -137,7 +140,9 @@ document.addEventListener('DOMContentLoaded', function() { let filterStatus = 'all'; // New state variable for status filter let initialDataReceived = false; // Flag to control initial rendering let storageData = {}; // Add state for storage data - let pbsConfigured = false; // Flag to track if PBS is configured + // ---> REMOVE: pbsConfigured flag is less relevant now + // let pbsConfigured = false; // Flag to track if PBS is configured + // <--- END REMOVE // --- Global Helper for Text Progress Bar --- const createProgressTextBarHTML = (percent, text, colorClass) => { @@ -1151,26 +1156,17 @@ document.addEventListener('DOMContentLoaded', function() { } // ---> END MODIFICATION <--- - let pbsStatusReceived = false; // Track if we got PBS status in *this* update - // Only update pbsData if it exists in the incoming data + // ---> CHANGE: Update pbsDataArray if (data.hasOwnProperty('pbs')) { - pbsData = data.pbs; - pbsStatusReceived = true; - // Determine configuration status based on the *first* received status - if (!initialDataReceived) { - pbsConfigured = pbsData.status !== 'unconfigured'; - } + // Expecting an array now + pbsDataArray = Array.isArray(data.pbs) ? data.pbs : []; + console.log(`[socket.on("rawData")] Updated pbsDataArray with ${pbsDataArray.length} instance(s).`); } else { - // If pbs field is missing, KEEP the existing pbsData state. - // If this is the *very first* data load and pbs is missing, assume unconfigured - if (!initialDataReceived && !pbsStatusReceived) { - pbsConfigured = false; - pbsData = { status: 'unconfigured' }; // Set a default state - } - // We could optionally default it only if pbsData is currently null/undefined, - // but preserving the last known state is safer. - // pbsData = pbsData || { status: 'unconfigured' }; + // If pbs key is missing, preserve the existing array + console.log('[socket.on("rawData")] PBS key missing in rawData, preserving existing pbsDataArray.'); } + // <--- END CHANGE + console.log('[socket.on("rawData")] Parsed data and updated stores'); // Set flag after first successful data parse @@ -1194,18 +1190,29 @@ document.addEventListener('DOMContentLoaded', function() { } }); - // ---> NEW: Listener for initial PBS status <--- - socket.on('pbsInitialStatus', (data) => { - console.log('[socket] Received pbsInitialStatus:', data); - if (data && data.status) { - // Update the UI immediately with the basic configuration status - // We pass a minimal object because full data isn't available yet - updatePbsInfo({ status: data.status }); - // Optionally update pbsConfigured flag if still needed elsewhere - pbsConfigured = data.status !== 'unconfigured'; + // ---> CHANGE: Handle initial PBS status array + socket.on('pbsInitialStatus', (pbsStatusArray) => { + console.log('[socket] Received pbsInitialStatus array:', pbsStatusArray); + if (Array.isArray(pbsStatusArray)) { + // Update the global pbsDataArray with these initial statuses + // This ensures the UI shows something before the first full discovery + pbsDataArray = pbsStatusArray.map(statusInfo => ({ + ...statusInfo, // includes pbsEndpointId, pbsInstanceName, status + // Add default empty structures for other fields expected by updatePbsInfo + backupTasks: { recentTasks: [], summary: {} }, + datastores: [], + verificationTasks: { summary: {} }, + syncTasks: { summary: {} }, + pruneTasks: { summary: {} }, + nodeName: null // Node name isn't known yet + })); + // Trigger an immediate partial UI update for PBS status + updatePbsInfo(pbsDataArray); + } else { + console.warn('[socket] Received non-array data for pbsInitialStatus:', pbsStatusArray); } }); - // ---> END NEW LISTENER <--- + // ---> END CHANGE function requestFullData() { console.log("Requesting full data..."); @@ -1292,11 +1299,14 @@ document.addEventListener('DOMContentLoaded', function() { function updateAllUITables() { // Update UI tables using the currently stored data updateNodesTable(nodesData); - updateVmsTable(vmsData); - updateContainersTable(containersData); + // updateVmsTable(vmsData); // No separate VM table + // updateContainersTable(containersData); // No separate CT table refreshDashboardData(); // Process and update the main dashboard updateStorageInfo(storageData); // Update storage info tab - updatePbsInfo(pbsData); // Update PBS info + // ---> CHANGE: Pass pbsDataArray + // updatePbsInfo(pbsData); + updatePbsInfo(pbsDataArray); + // <--- END CHANGE } // Add a separate fetch for storage data, maybe less frequent? @@ -1438,7 +1448,6 @@ document.addEventListener('DOMContentLoaded', function() { return `${gcStatus}`; }; - // --- Function to Update Specific Task Summary Card --- function updatePbsTaskSummaryCard(prefix, summaryData) { const okEl = document.getElementById(`pbs-${prefix}-ok`); @@ -1475,152 +1484,236 @@ document.addEventListener('DOMContentLoaded', function() { } // --- Updated Function: Update PBS Info Section --- - function updatePbsInfo(pbs) { - // Add logging to inspect received data structure - console.log('[updatePbsInfo] Received PBS data:', JSON.stringify(pbs, null, 2)); // Log the received object - - // Get references to all elements - const statusElement = document.getElementById('pbs-connection-status'); - const dsSection = document.getElementById('pbs-datastores-section'); - const dsTableBody = document.getElementById('pbs-datastores-table-body'); - const summariesSection = document.getElementById('pbs-tasks-summaries-section'); - const recentTasksSection = document.getElementById('pbs-recent-tasks-section'); - const recentTasksTableBody = document.getElementById('pbs-recent-tasks-table-body'); - // Old tasks section element (to ensure it remains hidden or remove if safe) - const oldTasksSection = document.getElementById('pbs-tasks-section'); - - // Basic check for essential elements - if (!statusElement || !dsSection || !dsTableBody || !summariesSection || !recentTasksSection || !recentTasksTableBody) { - console.warn("One or more PBS UI elements not found, cannot update fully."); + function updatePbsInfo(pbsArray) { + const container = document.getElementById('pbs-instances-container'); + if (!container) { + console.error("PBS container element #pbs-instances-container not found!"); return; } - // Hide old task section if it exists - if (oldTasksSection) oldTasksSection.classList.add('hidden'); + container.innerHTML = ''; // Clear previous content - // ---> Ensure all detail sections are hidden initially <--- - dsSection.classList.add('hidden'); - summariesSection.classList.add('hidden'); - recentTasksSection.classList.add('hidden'); - // ---> End initial hide <--- + // Log the array being processed + console.log('[updatePbsInfo] Processing PBS array:', pbsArray); - // Update Connection Status - let statusText = 'Loading...'; - let showDetails = false; - statusElement.className = 'mb-3 text-sm'; // Reset classes - - switch (pbs.status) { - case 'configured': - statusText = `PBS Configured (${pbs.nodeName || '...'}), attempting connection...`; - statusElement.classList.add('text-gray-600', 'dark:text-gray-400'); - showDetails = false; - break; - case 'ok': - statusText = `Connected to PBS: ${pbs.nodeName || 'Unknown Node'}`; - statusElement.classList.add('text-green-600', 'dark:text-green-400'); - showDetails = true; - break; - case 'error': - statusText = `Error connecting to PBS: ${pbs.nodeName || 'Configured Host'}. Check Pulse logs.`; - statusElement.classList.add('text-red-600', 'dark:text-red-400'); - showDetails = false; - break; - case 'unconfigured': - statusText = 'PBS monitoring is not configured.'; - statusElement.classList.add('text-gray-600', 'dark:text-gray-400'); - showDetails = false; - break; - default: - statusText = `PBS status: ${pbs.status || 'Unknown'}`; - statusElement.classList.add('text-gray-600', 'dark:text-gray-400'); - showDetails = false; - break; + if (!Array.isArray(pbsArray) || pbsArray.length === 0) { + container.innerHTML = '

No PBS instances configured or data available.

'; + return; } - statusElement.textContent = statusText; - // Show/Hide Detail Sections based on connection status - // Use toggle with the 'force' parameter based on showDetails - dsSection.classList.toggle('hidden', !showDetails); - summariesSection.classList.toggle('hidden', !showDetails); - recentTasksSection.classList.toggle('hidden', !showDetails); + // Create content for each PBS instance + pbsArray.forEach((pbsInstance, index) => { + const instanceId = pbsInstance.pbsEndpointId || `instance-${index}`; // Use ID from backend or generate one + const instanceName = pbsInstance.pbsInstanceName || `PBS Instance ${index + 1}`; - // Update Datastores Table (only if showing details and data exists) - if (showDetails && pbs.datastores) { - dsTableBody.innerHTML = ''; // Clear previous rows - if (pbs.datastores.length === 0) { - dsTableBody.innerHTML = `No PBS datastores found or accessible.`; - } else { - pbs.datastores.forEach(ds => { - const totalBytes = ds.total || 0; - const usedBytes = ds.used || 0; - // Calculate available, defaulting to 0 if total is null/0 - const availableBytes = (ds.available !== null && ds.available !== undefined) ? ds.available : (totalBytes > 0 ? totalBytes - usedBytes : 0); - const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0; - const usageColor = getUsageColor(usagePercent); - const usageText = totalBytes > 0 ? `${usagePercent}% (${formatBytes(usedBytes)} of ${formatBytes(totalBytes)})` : 'N/A'; - const gcStatusHtml = getPbsGcStatusText(ds.gcStatus); + // --- Create Wrapper Div for this instance --- + const instanceWrapper = document.createElement('div'); + instanceWrapper.className = 'pbs-instance-section border border-gray-200 dark:border-gray-700 rounded p-4 bg-gray-50/30 dark:bg-gray-800/30'; + instanceWrapper.id = `pbs-instance-${instanceId}`; - const row = document.createElement('tr'); - row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; - row.innerHTML = ` - ${ds.name || 'N/A'} - ${ds.path || 'N/A'} - ${formatBytes(usedBytes)} - ${formatBytes(availableBytes)} - ${totalBytes > 0 ? formatBytes(totalBytes) : 'N/A'} - ${totalBytes > 0 ? createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'} - ${gcStatusHtml} - `; - dsTableBody.appendChild(row); - }); + // --- Instance Header (Name and Status) --- + const headerDiv = document.createElement('div'); + headerDiv.className = 'flex justify-between items-center mb-3'; + + const instanceTitle = document.createElement('h3'); + instanceTitle.className = 'text-lg font-semibold text-gray-800 dark:text-gray-200'; + instanceTitle.textContent = instanceName; + + const statusElement = document.createElement('div'); + statusElement.className = 'text-sm'; // Base class + statusElement.id = `pbs-status-${instanceId}`; + + headerDiv.appendChild(instanceTitle); + headerDiv.appendChild(statusElement); + instanceWrapper.appendChild(headerDiv); + + // --- Determine Status Text and Detail Visibility --- + let statusText = 'Loading...'; + let showDetails = false; + let statusColorClass = 'text-gray-600 dark:text-gray-400'; + + switch (pbsInstance.status) { + case 'configured': + statusText = `Configured (${pbsInstance.nodeName || '...'}), attempting connection...`; + statusColorClass = 'text-gray-600 dark:text-gray-400'; + showDetails = false; + break; + case 'ok': + // ---> CHANGE: Simplify the 'ok' status text + // statusText = `Connected: ${pbsInstance.nodeName || 'Unknown Node'}`; + statusText = `Status: OK (${pbsInstance.nodeName || 'Unknown Node'})`; // Show Node Name in status + // <--- END CHANGE + statusColorClass = 'text-green-600 dark:text-green-400'; + showDetails = true; + break; + case 'error': + statusText = `Error connecting. Check Pulse logs.`; + statusColorClass = 'text-red-600 dark:text-red-400'; + showDetails = false; + break; + case 'unconfigured': + statusText = 'Not configured.'; + statusColorClass = 'text-gray-600 dark:text-gray-400'; + showDetails = false; + break; + default: + statusText = `Status: ${pbsInstance.status || 'Unknown'}`; + statusColorClass = 'text-gray-600 dark:text-gray-400'; + showDetails = false; + break; } - } else if (!showDetails) { - // Clear table if not showing details - dsTableBody.innerHTML = `${statusText}`; - } + statusElement.textContent = statusText; + statusElement.classList.add(...statusColorClass.split(' ')); - // Update Task Summary Cards (only if showing details) - if (showDetails) { - // Access the nested summary objects correctly - updatePbsTaskSummaryCard('tasks', pbs.backupTasks); // Pass the whole backupTasks object which contains .summary - updatePbsTaskSummaryCard('verify', pbs.verificationTasks); // Pass the whole object - updatePbsTaskSummaryCard('sync', pbs.syncTasks); // Pass the whole object - updatePbsTaskSummaryCard('prune', pbs.pruneTasks); // Pass the whole object - } else { - // Clear all summary cards if not showing details - updatePbsTaskSummaryCard('tasks', null); - updatePbsTaskSummaryCard('verify', null); - updatePbsTaskSummaryCard('sync', null); - updatePbsTaskSummaryCard('prune', null); - } + // --- Create Containers for Details (Datastores, Summaries, Tasks) --- + const detailsContainer = document.createElement('div'); + detailsContainer.className = `pbs-instance-details space-y-4 ${showDetails ? '' : 'hidden'}`; + detailsContainer.id = `pbs-details-${instanceId}`; - // Update Recent Backup Tasks Table (only if showing details and data exists) - // Access the nested recentTasks array correctly - if (showDetails && pbs.backupTasks && pbs.backupTasks.recentTasks) { - recentTasksTableBody.innerHTML = ''; // Clear previous rows - const recentTasks = pbs.backupTasks.recentTasks; // Use the correct path - - if (recentTasks.length === 0) { - recentTasksTableBody.innerHTML = `No recent backup tasks found (last 7 days).`; - } else { - recentTasks.forEach(task => { - const row = document.createElement('tr'); - row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; - row.innerHTML = ` - ${task.id || 'N/A'} - ${getPbsStatusIcon(task.status)} - ${formatPbsTimestamp(task.startTime)} - ${formatDuration(task.duration)} - - ${task.upid || 'N/A'} - `; - recentTasksTableBody.appendChild(row); - }); + // --- Datastores Section --- + const dsSection = document.createElement('div'); + dsSection.id = `pbs-ds-section-${instanceId}`; + dsSection.innerHTML = ` +

Datastores

+
+ + + + + + + + + + + + + + + +
NamePathUsedAvailableTotalUsageGC Status
+
`; + detailsContainer.appendChild(dsSection); + + // Populate Datastore Table Body + const dsTableBody = dsSection.querySelector(`#pbs-ds-tbody-${instanceId}`); + if (showDetails && pbsInstance.datastores) { + if (pbsInstance.datastores.length === 0) { + dsTableBody.innerHTML = `No PBS datastores found or accessible.`; + } else { + pbsInstance.datastores.forEach(ds => { + const totalBytes = ds.total || 0; + const usedBytes = ds.used || 0; + const availableBytes = (ds.available !== null && ds.available !== undefined) ? ds.available : (totalBytes > 0 ? totalBytes - usedBytes : 0); + const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0; + const usageColor = getUsageColor(usagePercent); + const usageText = totalBytes > 0 ? `${usagePercent}% (${formatBytes(usedBytes)} of ${formatBytes(totalBytes)})` : 'N/A'; + const gcStatusHtml = getPbsGcStatusText(ds.gcStatus); + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; + row.innerHTML = ` + ${ds.name || 'N/A'} + ${ds.path || 'N/A'} + ${formatBytes(usedBytes)} + ${formatBytes(availableBytes)} + ${totalBytes > 0 ? formatBytes(totalBytes) : 'N/A'} + ${totalBytes > 0 ? createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'} + ${gcStatusHtml} + `; + dsTableBody.appendChild(row); + }); + } + } else if (!showDetails) { + dsTableBody.innerHTML = `${statusText}`; } - } else if (!showDetails) { - // Clear table if not showing details - recentTasksTableBody.innerHTML = `${statusText}`; - } + // --- Task Summaries Section --- + const summariesSection = document.createElement('div'); + summariesSection.id = `pbs-summaries-section-${instanceId}`; + summariesSection.className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'; + + // Helper to create a summary card + const createSummaryCard = (type, title, summaryData) => { + const card = document.createElement('div'); + card.className = 'border border-gray-200 dark:border-gray-700 rounded p-3 bg-gray-100/50 dark:bg-gray-700/50'; + const summary = summaryData?.summary || {}; // Default to empty if data missing + const ok = summary.ok ?? '-'; + const failed = summary.failed ?? '-'; + const total = summary.total ?? '-'; + const lastOk = formatPbsTimestamp(summary.lastOk); + const lastFailed = formatPbsTimestamp(summary.lastFailed); + const failedStyle = (failed > 0) ? 'font-bold text-red-600 dark:text-red-400' : 'text-red-600 dark:text-red-400 font-semibold'; + + card.innerHTML = ` +

${title} (7d)

+
+
OK: ${ok}
+
Failed: ${failed}
+
Total: ${total}
+
Last OK: ${lastOk}
+
Last Fail: ${lastFailed}
+
`; + return card; + }; + + summariesSection.appendChild(createSummaryCard('backup', 'Backups', pbsInstance.backupTasks)); + summariesSection.appendChild(createSummaryCard('verify', 'Verification', pbsInstance.verificationTasks)); + summariesSection.appendChild(createSummaryCard('sync', 'Sync', pbsInstance.syncTasks)); + summariesSection.appendChild(createSummaryCard('prune', 'Prune/GC', pbsInstance.pruneTasks)); + detailsContainer.appendChild(summariesSection); + + // --- Recent Backup Tasks Section --- + const recentTasksSection = document.createElement('div'); + recentTasksSection.id = `pbs-recent-tasks-section-${instanceId}`; + recentTasksSection.innerHTML = ` +

Recent Backup Tasks

+
+ + + + + + + + + + + + + +
GuestStatusStart TimeDurationUPID
+
`; + detailsContainer.appendChild(recentTasksSection); + + // Populate Recent Tasks Table Body + const recentTasksTbody = recentTasksSection.querySelector(`#pbs-recent-tasks-tbody-${instanceId}`); + if (showDetails && pbsInstance.backupTasks && pbsInstance.backupTasks.recentTasks) { + const recentTasks = pbsInstance.backupTasks.recentTasks; + if (recentTasks.length === 0) { + recentTasksTbody.innerHTML = `No recent backup tasks found (last 7 days).`; + } else { + recentTasks.forEach(task => { + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; + row.innerHTML = ` + ${task.id || 'N/A'} + ${getPbsStatusIcon(task.status)} + ${formatPbsTimestamp(task.startTime)} + ${formatDuration(task.duration)} + ${task.upid || 'N/A'} + `; + recentTasksTbody.appendChild(row); + }); + } + } else if (!showDetails) { + recentTasksTbody.innerHTML = `${statusText}`; + } + + // Append details container (conditionally hidden) to the main wrapper + instanceWrapper.appendChild(detailsContainer); + + // Append the wrapper for this instance to the main container + container.appendChild(instanceWrapper); + }); // End forEach pbsInstance } // --- End Update PBS Info Function --- diff --git a/src/public/index.html b/src/public/index.html index 9a177b415..6c6c5c2fd 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -218,100 +218,11 @@