From c62df68d7fab8fa2f4c8bc9bcc9cbd0c85cbf0fa Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Thu, 24 Apr 2025 15:10:57 +0100 Subject: [PATCH] fix(ui): correct sort state key handling in updateSortUI for PBS tables --- .env.example | 137 +++++----- server/index.js | 285 ++++++++++---------- src/public/app.js | 610 ++++++++++++++++++++++++++++-------------- src/public/index.html | 98 +++++++ src/public/output.css | 8 +- 5 files changed, 735 insertions(+), 403 deletions(-) diff --git a/.env.example b/.env.example index 239b207b3..38b0d4d8b 100644 --- a/.env.example +++ b/.env.example @@ -1,83 +1,98 @@ -# 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. +# Example .env file for Pulse configuration +# Copy this file to .env and replace placeholder values with your actual credentials. -# --- Primary Proxmox VE Endpoint --- -# Required connection details for your main Proxmox VE host or cluster. +# --- Proxmox VE Endpoint 1 (Required) --- +# Connection details for your primary Proxmox VE host or cluster endpoint. PROXMOX_HOST=your-proxmox-ip-or-hostname -PROXMOX_TOKEN_ID=your-api-token-id@pam!your-token-name -PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid +PROXMOX_PORT=8006 # Optional, defaults to 8006 if omitted -# Optional: Node name for display (defaults to PROXMOX_HOST if not set) -# PROXMOX_NODE_NAME=MyPrimaryNode +# Authentication: Use *either* API Token (Recommended) *or* User/Password. +# API Token (Recommended): +PROXMOX_TOKEN_ID=your-api-token-id@pam!your-token-name # e.g., root@pam!pulse +PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid # The Secret UUID value -# Optional: Proxmox API port (defaults to 8006 if not set) -# PROXMOX_PORT=8006 +# User/Password (Less Recommended): +# PROXMOX_USERNAME=your-proxmox-username # e.g., root +# PROXMOX_PASSWORD=your-password +# PROXMOX_REALM=pam # Optional, defaults to 'pam' if omitted (e.g., pam, pve, ad) -# Optional: Set to 'false' to disable certificate validation (useful for self-signed certs) -# Defaults to 'true' (validation enabled) if not set. +# Optional: Set a friendly name for this endpoint in logs/UI (defaults to host) +PROXMOX_NODE_NAME=My Primary Proxmox + +# Optional: Allow self-signed certificates (defaults to true if omitted) +# Set to false if you have a valid certificate from a trusted CA. PROXMOX_ALLOW_SELF_SIGNED_CERTS=true -# 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) +# Optional: Enable/Disable this endpoint (defaults to true if omitted) # 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 VE Endpoint 2 (Optional) --- +# Duplicate these variables with a _2 suffix for a second PVE endpoint. +# PROXMOX_HOST_2=second-proxmox-ip-or-hostname # PROXMOX_PORT_2=8006 +# PROXMOX_TOKEN_ID_2=token-id@pam!token-name_2 +# PROXMOX_TOKEN_SECRET_2=token-secret-uuid_2 +# PROXMOX_NODE_NAME_2=My Second Proxmox # PROXMOX_ALLOW_SELF_SIGNED_CERTS_2=true # PROXMOX_ENABLED_2=true +# ... User/Password_2 if needed ... -# PROXMOX_HOST_3=third-proxmox-ip -# PROXMOX_TOKEN_ID_3=user@realm!token_name -# PROXMOX_TOKEN_SECRET_3=secret-uuid -# ... and so on -# --- Primary Proxmox Backup Server (PBS) Endpoint --- -# Required: Set PBS_HOST and EITHER (PBS_USER + PBS_PASSWORD) OR (PBS_TOKEN_ID + PBS_TOKEN_SECRET) +# --- Proxmox VE Endpoint 3, 4, ... (Optional) --- +# Continue the pattern with _3, _4, etc. for more endpoints. +# PROXMOX_HOST_3=... +# PROXMOX_TOKEN_ID_3=... +# PROXMOX_TOKEN_SECRET_3=... +# ... + + +# --- Proxmox Backup Server (PBS) Endpoint 1 (Optional) --- PBS_HOST=your-pbs-ip-or-hostname +PBS_PORT=8007 # Optional, defaults to 8007 if omitted -# 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 +# Authentication: Use *either* API Token *or* User/Password. +# API Token (Recommended): +# PBS_TOKEN_ID=your-pbs-api-token-id@pbs!your-token-name # e.g., pulse-reader@pbs!pulse-token +# PBS_TOKEN_SECRET=your-pbs-api-token-secret-uuid -# 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 +# User/Password (Less Recommended): +PBS_USER=root@pam # e.g., root@pam or backup-user@pbs +PBS_PASSWORD=your-password +PBS_REALM=pbs # Optional, defaults to 'pbs' if using User/Password auth -# Optional: PBS instance name for display (defaults to PBS_HOST if not set) -# PBS_NODE_NAME=MyPrimaryPBS +# Optional: Set a friendly name for this endpoint in logs/UI (defaults to host) +PBS_NODE_NAME=My Primary PBS -# 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. +# Optional: Allow self-signed certificates (defaults to true if omitted) 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 +# --- Proxmox Backup Server (PBS) Endpoint 2 (Optional) --- +# Duplicate these variables with a _2 suffix for a second PBS endpoint. +# PBS_HOST_2=second-pbs-ip-or-hostname +# PBS_PORT_2=8007 +# PBS_TOKEN_ID_2=token-id@pbs!token-name_2 +# PBS_TOKEN_SECRET_2=token-secret-uuid_2 +# PBS_NODE_NAME_2=My Second PBS +# PBS_ALLOW_SELF_SIGNED_CERTS_2=true +# ... User/Password_2 if needed ... + + +# --- Proxmox Backup Server (PBS) Endpoint 3, 4, ... (Optional) --- +# Continue the pattern with _3, _4, etc. for more endpoints. +# PBS_HOST_3=... +# PBS_TOKEN_ID_3=... +# PBS_TOKEN_SECRET_3=... +# ... + + +# Development Settings +ENABLE_HOT_RELOAD=true # Set to false to disable hot reload + +# --- Pulse Interval Configuration (Optional) --- +# Adjust how frequently Pulse fetches data. Lower values mean more real-time data +# but increase load on PVE/PBS hosts. Values are in milliseconds. +# +# PULSE_METRIC_INTERVAL_MS=2000 # Default: 2000 (2 seconds) - Frequency for fetching guest CPU/Mem/IO stats. +# PULSE_DISCOVERY_INTERVAL_MS=30000 # Default: 30000 (30 seconds) - Frequency for fetching node/guest lists and PBS data. \ No newline at end of file diff --git a/server/index.js b/server/index.js index ba4b91b7d..1886dc9fd 100644 --- a/server/index.js +++ b/server/index.js @@ -431,11 +431,12 @@ if (Object.keys(apiClients).length === 0 && pbsConfigs.length === 0) { const DEBUG_METRICS = false; // Set to true to show detailed metrics logs const PORT = 7655; // Using a different port from the main server -// --- Define Update Intervals --- -// How often to fetch dynamic metrics (CPU, Mem, IO) for running guests -const METRIC_UPDATE_INTERVAL = 2000; // Default: 2 seconds -// How often to fetch structural data (node list, guest lists, node status) -const DISCOVERY_UPDATE_INTERVAL = 30000; // Default: 30 seconds +// --- Define Update Intervals (Configurable via Env Vars) --- +const METRIC_UPDATE_INTERVAL = parseInt(process.env.PULSE_METRIC_INTERVAL_MS, 10) || 2000; // Default: 2 seconds +const DISCOVERY_UPDATE_INTERVAL = parseInt(process.env.PULSE_DISCOVERY_INTERVAL_MS, 10) || 30000; // Default: 30 seconds + +console.log(`INFO: Using Metric Update Interval: ${METRIC_UPDATE_INTERVAL}ms`); +console.log(`INFO: Using Discovery Update Interval: ${DISCOVERY_UPDATE_INTERVAL}ms`); // Create Express app const app = express(); @@ -908,28 +909,27 @@ async function fetchDiscoveryData() { // Only proceed if we have a node name if (instanceData.nodeName) { - // Fetch all data concurrently for this instance - const [ - backupTasksResult, - datastoresResult, - verificationTasksResult, - syncTasksResult, - pruneTasksResult - ] = await Promise.all([ - fetchPbsTaskData({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName), + // Fetch datastores and the consolidated task list concurrently + const [datastoresResult, allTasksResult] = await Promise.all([ 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']) + fetchAllPbsTasksForProcessing({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName) // New function call ]); - // Assign results to the instanceData object - instanceData.backupTasks = backupTasksResult; + // Assign datastore results instanceData.datastores = datastoresResult; - instanceData.verificationTasks = verificationTasksResult; - instanceData.syncTasks = syncTasksResult; - instanceData.pruneTasks = pruneTasksResult; - instanceData.status = 'ok'; // Mark as OK if all fetches succeeded + + // Process the single task list for all summaries and details + if (allTasksResult && allTasksResult.tasks) { + const processedTasks = processPbsTasks(allTasksResult.tasks); + instanceData.backupTasks = processedTasks.backupTasks; + instanceData.verificationTasks = processedTasks.verificationTasks; + instanceData.syncTasks = processedTasks.syncTasks; + instanceData.pruneTasks = processedTasks.pruneTasks; + } + + // If tasks failed to fetch (allTasksResult.error is true), summaries will remain default/empty + + instanceData.status = 'ok'; // Mark as OK if datastores fetch succeeded (tasks handled separately) console.log(`INFO: [PBS Discovery - ${instanceName}] Successfully fetched and processed data (Node: ${instanceData.nodeName}).`); } else { console.error(`ERROR: Could not determine node name for PBS instance ${instanceName}, cannot fetch task data.`); @@ -1282,153 +1282,158 @@ async function fetchPbsNodeName(pbsClient) { } // Modified fetchPbsTaskData to include more details and handle backup tasks specifically +/* async function fetchPbsTaskData(pbsClient, nodeName) { - console.log(`INFO: Fetching PBS backup task data for node ${nodeName}...`); + // ... (keep original function body commented out or remove) ... +} +*/ + +// NEW function to fetch the full task list once for processing +async function fetchAllPbsTasksForProcessing(pbsClient, nodeName) { + console.log(`INFO: Fetching all relevant PBS tasks (7 days) for node ${nodeName}...`); if (!nodeName) { - console.warn("WARN: Cannot fetch PBS backup task data without node name."); - return { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }; + console.warn("WARN: Cannot fetch PBS task data without node name."); + return { tasks: null, error: true }; } try { const sinceTimestamp = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000); const response = await pbsClient.client.get(`/nodes/${nodeName}/tasks`, { params: { since: sinceTimestamp, - limit: 200, // Fetch a decent number for analysis + limit: 1000, // Fetch a larger number to cover 7 days of various tasks errors: 1, - // Do not filter by type here, filter after fetching } }); - const allTasks = response.data?.data ?? []; + console.log(`INFO: Fetched ${allTasks.length} tasks from PBS for processing.`); + return { tasks: allTasks, error: false }; + } catch (error) { + console.error(`ERROR: Failed to fetch PBS task list for node ${nodeName}: ${error.message}`); + if (error.response?.status === 401) console.error("ERROR: PBS API authentication failed (401)."); + else if (error.response?.status === 403) console.error("ERROR: PBS API authorization failed (403)."); + return { tasks: null, error: true }; + } +} - // Filter specifically for backup tasks (adjust types if needed based on actual API) - const backupTasks = allTasks.filter(task => - task.worker_type === 'backup' || task.type === 'backup' - ); +// NEW function to process the fetched task list into required structures +function processPbsTasks(allTasks) { + if (!allTasks) return { // Return default structure if tasks are null + backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, + 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 } } + }; - let okCount = 0; - let failedCount = 0; - let lastOkTimestamp = 0; - let lastFailedTimestamp = 0; + const taskResults = { + backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, + verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, + sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, + pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } // Combined prune/gc + }; + + // Define type mappings + const taskTypeMap = { + backup: 'backup', + verify: 'verify', + sync: 'sync', + garbage_collection: 'pruneGc', + prune: 'pruneGc' + }; + + allTasks.forEach(task => { + const taskType = task.worker_type || task.type; + const categoryKey = taskTypeMap[taskType]; + + if (categoryKey) { + const category = taskResults[categoryKey]; + category.list.push(task); // Add raw task for potential future use - const detailedTasks = backupTasks.map(task => { - // Simplify status and count const isOk = task.status === 'OK'; const isFailed = task.status && task.status !== 'OK' && task.status !== 'running'; if (isOk) { - okCount++; - if (task.endtime > lastOkTimestamp) lastOkTimestamp = task.endtime; + category.ok++; + if (task.endtime > category.lastOk) category.lastOk = task.endtime; } else if (isFailed) { - failedCount++; - if (task.endtime > lastFailedTimestamp) lastFailedTimestamp = task.endtime; + category.failed++; + if (task.endtime > category.lastFailed) category.lastFailed = task.endtime; } + } + }); - // Extract relevant details (add more as needed from API response) - return { - upid: task.upid, - node: task.node, - type: task.worker_type || task.type, - id: task.worker_id || task.id, // Guest ID (e.g., 'vm/100') - status: task.status, - startTime: task.starttime, - endTime: task.endtime, - duration: task.endtime && task.starttime ? task.endtime - task.starttime : null, - // Add size/performance details if available in the task object - // size: task.size, // Example placeholder - // performance: task.performance, // Example placeholder - }; - }).sort((a, b) => (b.startTime || 0) - (a.startTime || 0)); // Sort by start time descending + // Helper to create detailed task object + const createDetailedTask = (task) => ({ + upid: task.upid, + node: task.node, + type: task.worker_type || task.type, + id: task.worker_id || task.id, + status: task.status, + startTime: task.starttime, + endTime: task.endtime, + duration: task.endtime && task.starttime ? task.endtime - task.starttime : null, + }); - console.log(`INFO: Processed ${backupTasks.length} PBS backup tasks. OK: ${okCount}, Failed: ${failedCount}`); + // Process and sort recent tasks for each category + const sortTasksDesc = (a, b) => (b.startTime || 0) - (a.startTime || 0); - return { - // Return latest 20 detailed tasks for UI display - recentTasks: detailedTasks.slice(0, 20), + const recentBackupTasks = taskResults.backup.list.map(createDetailedTask).sort(sortTasksDesc).slice(0, 20); + const recentVerifyTasks = taskResults.verify.list.map(createDetailedTask).sort(sortTasksDesc).slice(0, 20); + const recentSyncTasks = taskResults.sync.list.map(createDetailedTask).sort(sortTasksDesc).slice(0, 20); + const recentPruneGcTasks = taskResults.pruneGc.list.map(createDetailedTask).sort(sortTasksDesc).slice(0, 20); + + console.log(`INFO: Processed PBS Tasks - Backup: ${taskResults.backup.list.length} (OK: ${taskResults.backup.ok}, Fail: ${taskResults.backup.failed}), Verify: ${taskResults.verify.list.length} (OK: ${taskResults.verify.ok}, Fail: ${taskResults.verify.failed}), Sync: ${taskResults.sync.list.length} (OK: ${taskResults.sync.ok}, Fail: ${taskResults.sync.failed}), Prune/GC: ${taskResults.pruneGc.list.length} (OK: ${taskResults.pruneGc.ok}, Fail: ${taskResults.pruneGc.failed})`); + + // Return the structured data expected by fetchDiscoveryData + return { + backupTasks: { + recentTasks: recentBackupTasks, summary: { - ok: okCount, - failed: failedCount, - total: backupTasks.length, - lastOk: lastOkTimestamp || null, - lastFailed: lastFailedTimestamp || null, + ok: taskResults.backup.ok, + failed: taskResults.backup.failed, + total: taskResults.backup.list.length, + lastOk: taskResults.backup.lastOk || null, + lastFailed: taskResults.backup.lastFailed || null, } - }; - - } catch (error) { - console.error(`ERROR: Failed to fetch PBS backup task data for node ${nodeName}: ${error.message}`); - // Handle specific errors like 401/403 - if (error.response?.status === 401) console.error("ERROR: PBS API authentication failed (401)."); - else if (error.response?.status === 403) console.error("ERROR: PBS API authorization failed (403)."); - // Return empty data on error - return { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }; - } + }, + verificationTasks: { + recentTasks: recentVerifyTasks, + summary: { + ok: taskResults.verify.ok, + failed: taskResults.verify.failed, + total: taskResults.verify.list.length, + lastOk: taskResults.verify.lastOk || null, + lastFailed: taskResults.verify.lastFailed || null, + } + }, + syncTasks: { + recentTasks: recentSyncTasks, + summary: { + ok: taskResults.sync.ok, + failed: taskResults.sync.failed, + total: taskResults.sync.list.length, + lastOk: taskResults.sync.lastOk || null, + lastFailed: taskResults.sync.lastFailed || null, + } + }, + pruneTasks: { + recentTasks: recentPruneGcTasks, + summary: { + ok: taskResults.pruneGc.ok, + failed: taskResults.pruneGc.failed, + total: taskResults.pruneGc.list.length, + lastOk: taskResults.pruneGc.lastOk || null, + lastFailed: taskResults.pruneGc.lastFailed || null, + } + } + }; } -// NEW Helper function to fetch and summarize tasks by type(s) +// DEPRECATED: Helper function to fetch and summarize tasks by type(s) +/* async function fetchPbsTaskSummaryByType(pbsClient, nodeName, taskTypes) { - const typeString = taskTypes.join('/'); - console.log(`INFO: Fetching PBS ${typeString} task summary for node ${nodeName}...`); - if (!nodeName) { - console.warn(`WARN: Cannot fetch PBS ${typeString} task data without node name.`); - return { summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }; - } - try { - const sinceTimestamp = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000); - const response = await pbsClient.client.get(`/nodes/${nodeName}/tasks`, { - params: { - since: sinceTimestamp, - limit: 500, // Fetch enough to get recent summaries - errors: 1, - // Consider filtering by type if API supports it reliably, otherwise filter below - // typefilter: taskTypes.join(','), // Might work depending on API version - } - }); - - const allTasks = response.data?.data ?? []; - - // Filter for the specified task types - const relevantTasks = allTasks.filter(task => - taskTypes.includes(task.worker_type) || taskTypes.includes(task.type) - ); - - let okCount = 0; - let failedCount = 0; - let lastOkTimestamp = 0; - let lastFailedTimestamp = 0; - - relevantTasks.forEach(task => { - const isOk = task.status === 'OK'; - const isFailed = task.status && task.status !== 'OK' && task.status !== 'running'; - - if (isOk) { - okCount++; - if (task.endtime > lastOkTimestamp) lastOkTimestamp = task.endtime; - } else if (isFailed) { - failedCount++; - if (task.endtime > lastFailedTimestamp) lastFailedTimestamp = task.endtime; - } - }); - - console.log(`INFO: Processed ${relevantTasks.length} PBS ${typeString} tasks. OK: ${okCount}, Failed: ${failedCount}`); - - return { - summary: { - ok: okCount, - failed: failedCount, - total: relevantTasks.length, - lastOk: lastOkTimestamp || null, - lastFailed: lastFailedTimestamp || null, - } - }; - - } catch (error) { - console.error(`ERROR: Failed to fetch PBS ${typeString} task summary for node ${nodeName}: ${error.message}`); - if (error.response?.status === 401) console.error("ERROR: PBS API authentication failed (401)."); - else if (error.response?.status === 403) console.error("ERROR: PBS API authorization failed (403)."); - return { summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }; - } + // ... (keep original function body commented out or remove) ... } - +*/ async function fetchPbsDatastoreData(pbsClient) { // Fetches datastore usage details from PBS using the /status/datastore-usage endpoint diff --git a/src/public/app.js b/src/public/app.js index 37a761037..7f6a0d117 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -129,9 +129,12 @@ document.addEventListener('DOMContentLoaded', function() { const savedSortState = JSON.parse(localStorage.getItem('pulseSortState')) || {}; const sortState = { nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) }, - // vms: { column: null, direction: 'asc', ...savedSortState.vms }, // Removed - // containers: { column: null, direction: 'asc', ...savedSortState.containers }, // Removed - main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) } // Load main, provide default if missing + main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) }, + // Add default sort states for PBS task tables + pbsBackup: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsBackup || {}) }, + pbsVerify: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsVerify || {}) }, + pbsSync: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsSync || {}) }, + pbsPruneGc: { column: 'startTime', direction: 'desc', ...(savedSortState.pbsPruneGc || {}) } }; let groupByNode = true; // Default view let filterGuestType = 'all'; // Default filter @@ -203,16 +206,52 @@ document.addEventListener('DOMContentLoaded', function() { const tableElement = document.getElementById(tableId); if (!tableElement) return; // Guard against missing table - const table = tableId.split('-')[0]; + // ---> CHANGE: Get sort state key from tableId <--- + // const table = tableId.split('-')[0]; // Old way, assumes simple pattern + let tableKey; + if (tableId.startsWith('pbs-')) { + // Extract the specific PBS key (e.g., 'pbsBackup', 'pbsVerify') + // Match pbs-recent--tasks-table- + const match = tableId.match(/pbs-recent-(backup|verify|sync|prunegc)-tasks-table-/); + if (match && match[1]) { + // Construct key like 'pbsBackup', 'pbsVerify', etc. + tableKey = `pbs${match[1].charAt(0).toUpperCase() + match[1].slice(1)}`; + } else { + console.error(`[updateSortUI] Could not extract PBS key from tableId: ${tableId}`); + return; // Cannot proceed without key + } + } else if (tableId.startsWith('nodes-')) { + tableKey = 'nodes'; + } else if (tableId.startsWith('main-')) { + tableKey = 'main'; + } else { + console.error(`[updateSortUI] Unknown table type for tableId: ${tableId}`); + return; // Cannot proceed without key + } + // console.log(`[updateSortUI] Determined tableKey: '${tableKey}' for tableId: '${tableId}'`); // Optional Debug log + // ---> END CHANGE <--- + const headers = tableElement.querySelectorAll('th.sortable'); - const currentSort = sortState[table]; + // ---> CHANGE: Use the derived tableKey <--- + const currentSort = sortState[tableKey]; + // ---> END CHANGE <--- + + // ---> ADD GUARD: Check if currentSort is defined before accessing properties + if (!currentSort) { + console.error(`[updateSortUI] No sort state found for key: '${tableKey}' (derived from tableId: ${tableId})`); + console.log('[updateSortUI] Current sortState:', JSON.stringify(sortState)); // Log the state for debugging + return; // Cannot proceed if sort state doesn't exist + } + // ---> END GUARD <--- headers.forEach(header => { header.classList.remove('bg-blue-50', 'dark:bg-blue-900/20'); const arrow = header.querySelector('.sort-arrow'); if (arrow) arrow.remove(); - if (header === clickedHeader && currentSort.column) { // Only highlight/arrow if a sort is active + // The error occurred here because currentSort was undefined + // Now currentSort is guaranteed to exist due to the guard above. + if (header === clickedHeader && currentSort.column) { // Only highlight/arrow if a sort is active for this key header.classList.add('bg-blue-50', 'dark:bg-blue-900/20'); const arrowSpan = document.createElement('span'); arrowSpan.className = 'sort-arrow ml-1'; @@ -246,6 +285,7 @@ document.addEventListener('DOMContentLoaded', function() { const stateToSave = { nodes: sortState.nodes, main: sortState.main + // Note: PBS sort state is not saved to localStorage currently }; localStorage.setItem('pulseSortState', JSON.stringify(stateToSave)); @@ -367,6 +407,45 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // --- NEW PBS Task Sorting Function --- + function sortPbsTasks(data, column, direction) { + if (!column || !data) return data || []; + const dataToSort = [...data]; + + return dataToSort.sort((a, b) => { + let valueA = a ? a[column] : null; + let valueB = b ? b[column] : null; + + // Handle specific types for comparison + if (column === 'startTime' || column === 'endTime' || column === 'duration') { + valueA = Number(valueA) || 0; + valueB = Number(valueB) || 0; + return direction === 'asc' ? valueA - valueB : valueB - valueA; + } else if (column === 'id') { // Guest ID/Job ID/Datastore + valueA = String(valueA || '').toLowerCase(); + valueB = String(valueB || '').toLowerCase(); + return direction === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); + } else if (column === 'status') { + // Simple status sort (OK > running > Error/Other > null/undefined) + const getStatusValue = (status) => { + if (status === 'OK') return 0; + if (status === 'running') return 1; + if (!status) return 3; + return 2; // Other error statuses + }; + valueA = getStatusValue(a?.status); + valueB = getStatusValue(b?.status); + return direction === 'asc' ? valueA - valueB : valueB - valueA; + } else { + // Default string comparison + valueA = String(valueA || '').toLowerCase(); + valueB = String(valueB || '').toLowerCase(); + return direction === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); + } + }); + } + // --- END NEW PBS Task Sorting Function --- + // --- Data Update/Display Functions --- function updateNodesTable(nodes, skipSorting = false) { // Corrected selector to target the tbody directly by its ID @@ -1370,7 +1449,7 @@ document.addEventListener('DOMContentLoaded', function() { }, 2000); // Update UI every 2 seconds (was 2500) // Fetch storage data periodically (e.g., every 10 seconds) - setInterval(fetchStorageData, 10000); + setInterval(fetchStorageData, 30000); // Changed to 30 seconds fetchStorageData(); // Initial fetch on load // --- End Frontend Render Interval --- @@ -1483,237 +1562,368 @@ document.addEventListener('DOMContentLoaded', function() { } } - // --- Updated Function: Update PBS Info Section --- + // --- NEW Function to Populate a PBS Task Table --- + function populatePbsTaskTable(tbodyId, tasks, sortColumn, sortDirection) { + const tbody = document.getElementById(tbodyId); + if (!tbody) { + console.error(`PBS Task Table Body #${tbodyId} not found!`); + return; + } + tbody.innerHTML = ''; // Clear previous content + + const sortedTasks = sortPbsTasks(tasks, sortColumn, sortDirection); + + if (!sortedTasks || sortedTasks.length === 0) { + tbody.innerHTML = `No recent tasks found (last 7 days).`; + return; + } + + sortedTasks.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'} + `; + tbody.appendChild(row); + }); + } + // --- END NEW Function --- + + // --- Updated Function: Update PBS Info Section (Upsert Logic) --- function updatePbsInfo(pbsArray) { const container = document.getElementById('pbs-instances-container'); if (!container) { console.error("PBS container element #pbs-instances-container not found!"); return; } - container.innerHTML = ''; // Clear previous content - // Log the array being processed - console.log('[updatePbsInfo] Processing PBS array:', pbsArray); + console.log('[updatePbsInfo] Processing PBS array:', pbsArray); // Keep this log - if (!Array.isArray(pbsArray) || pbsArray.length === 0) { - container.innerHTML = '

No PBS instances configured or data available.

'; - return; - } + const currentInstanceIds = new Set(); // Keep track of instances processed in this run - // Create content for each PBS instance + // Helper functions defined here or accessible in scope + 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 || {}; + 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; // Return the element + }; + + const createTaskTableHTML = (tableId, title, idColumnHeader) => ` +

Recent ${title} Tasks

+
+ + + + + + + + + + + + + +
${idColumnHeader}StatusStart TimeDurationUPID
+
`; + + const setupPbsSortListener = (tableId, sortStateKey, pbsInstance) => { // Pass pbsInstance here + const tableElement = document.getElementById(tableId); + if (!tableElement) { + console.error(`PBS Task Table #${tableId} not found for sort listener setup!`); + return; + } + + tableElement.querySelectorAll('th.sortable').forEach(th => { + if (th.dataset.listenerAttached === 'true') return; + + th.addEventListener('click', () => { + const column = th.getAttribute('data-sort'); + if (!column) return; + + const currentSort = sortState[sortStateKey]; + if (currentSort.column === column) { + currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; + } else { + currentSort.column = column; + currentSort.direction = 'asc'; + } + // Get the correct task list based on sortStateKey + let tasks; + switch (sortStateKey) { + case 'pbsBackup': tasks = pbsInstance.backupTasks?.recentTasks; break; + case 'pbsVerify': tasks = pbsInstance.verificationTasks?.recentTasks; break; + case 'pbsSync': tasks = pbsInstance.syncTasks?.recentTasks; break; + case 'pbsPruneGc': tasks = pbsInstance.pruneTasks?.recentTasks; break; + default: tasks = []; + } + + const tbodyId = tableId.replace('-table-', '-tbody-'); + populatePbsTaskTable(tbodyId, tasks, currentSort.column, currentSort.direction); + updateSortUI(tableId, th, sortStateKey); + }); + th.dataset.listenerAttached = 'true'; + }); + + const currentSortConfig = sortState[sortStateKey]; + if (!currentSortConfig || !currentSortConfig.column) { + console.warn(`Initial sort column not found for state key '${sortStateKey}'. Skipping initial sort UI update for table ${tableId}.`); + return; + } + const initialHeader = tableElement.querySelector(`th[data-sort="${currentSortConfig.column}"]`); + if (initialHeader) updateSortUI(tableId, initialHeader, sortStateKey); + }; + + + // --- Create/Update content for each PBS instance --- pbsArray.forEach((pbsInstance, index) => { - const instanceId = pbsInstance.pbsEndpointId || `instance-${index}`; // Use ID from backend or generate one + const instanceId = pbsInstance.pbsEndpointId || `instance-${index}`; const instanceName = pbsInstance.pbsInstanceName || `PBS Instance ${index + 1}`; + const instanceElementId = `pbs-instance-${instanceId}`; + currentInstanceIds.add(instanceElementId); // Track this ID - // --- 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}`; + let instanceWrapper = document.getElementById(instanceElementId); + let detailsContainer, dsTableBody, statusElement; - // --- 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 --- + // 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 + statusText = `Status: OK (${pbsInstance.nodeName || 'Unknown Node'})`; statusColorClass = 'text-green-600 dark:text-green-400'; showDetails = true; break; case 'error': - statusText = `Error connecting. Check Pulse logs.`; + statusText = `Error connecting (${pbsInstance.errorMessage || 'Check Pulse logs.'})`; // Try to show specific error 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; + // 'unconfigured' is handled by the empty array check earlier default: statusText = `Status: ${pbsInstance.status || 'Unknown'}`; - statusColorClass = 'text-gray-600 dark:text-gray-400'; - showDetails = false; break; } - statusElement.textContent = statusText; - statusElement.classList.add(...statusColorClass.split(' ')); - // --- 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}`; + if (instanceWrapper) { + // --- Instance Exists: Update --- + statusElement = instanceWrapper.querySelector(`#pbs-status-${instanceId}`); + detailsContainer = instanceWrapper.querySelector(`#pbs-details-${instanceId}`); - // --- 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}`; - } - - // --- 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); - }); + // Update status text and color + if (statusElement) { + statusElement.textContent = statusText; + statusElement.className = `text-sm ${statusColorClass}`; // Update classes } - } else if (!showDetails) { - recentTasksTbody.innerHTML = `${statusText}`; - } - // Append details container (conditionally hidden) to the main wrapper - instanceWrapper.appendChild(detailsContainer); + if (detailsContainer) { + // Update Datastore Table Body + dsTableBody = detailsContainer.querySelector(`#pbs-ds-tbody-${instanceId}`); + if (dsTableBody) { + dsTableBody.innerHTML = ''; // Clear existing rows + 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 { // Not OK status, show status text in table + dsTableBody.innerHTML = `${statusText}`; + } + } + + // Update Task Summary Cards + const summariesSection = detailsContainer.querySelector(`#pbs-summaries-section-${instanceId}`); + if (summariesSection) { + summariesSection.innerHTML = ''; // Clear existing cards + 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)); + } + + // Update Task Tables + if (showDetails) { + populatePbsTaskTable(`pbs-recent-backup-tasks-tbody-${instanceId}`, pbsInstance.backupTasks?.recentTasks, sortState.pbsBackup.column, sortState.pbsBackup.direction); + populatePbsTaskTable(`pbs-recent-verify-tasks-tbody-${instanceId}`, pbsInstance.verificationTasks?.recentTasks, sortState.pbsVerify.column, sortState.pbsVerify.direction); + populatePbsTaskTable(`pbs-recent-sync-tasks-tbody-${instanceId}`, pbsInstance.syncTasks?.recentTasks, sortState.pbsSync.column, sortState.pbsSync.direction); + populatePbsTaskTable(`pbs-recent-prunegc-tasks-tbody-${instanceId}`, pbsInstance.pruneTasks?.recentTasks, sortState.pbsPruneGc.column, sortState.pbsPruneGc.direction); + // Re-apply/Ensure sort listeners exist + setupPbsSortListener(`pbs-recent-backup-tasks-table-${instanceId}`, 'pbsBackup', pbsInstance); + setupPbsSortListener(`pbs-recent-verify-tasks-table-${instanceId}`, 'pbsVerify', pbsInstance); + setupPbsSortListener(`pbs-recent-sync-tasks-table-${instanceId}`, 'pbsSync', pbsInstance); + setupPbsSortListener(`pbs-recent-prunegc-tasks-table-${instanceId}`, 'pbsPruneGc', pbsInstance); + } else { + // If details are hidden, ensure tables show the status message + const backupTbody = document.getElementById(`pbs-recent-backup-tasks-tbody-${instanceId}`); + if (backupTbody) backupTbody.innerHTML = `${statusText}`; + const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`); + if (verifyTbody) verifyTbody.innerHTML = `${statusText}`; + const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`); + if (syncTbody) syncTbody.innerHTML = `${statusText}`; + const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`); + if (pruneTbody) pruneTbody.innerHTML = `${statusText}`; + } + + // Set visibility of the details container + detailsContainer.classList.toggle('hidden', !showDetails); + } // end if(detailsContainer) + + } else { + // --- Instance Doesn't Exist: Create --- + instanceWrapper = document.createElement('div'); + instanceWrapper.className = 'pbs-instance-section border border-gray-200 dark:border-gray-700 rounded p-4 mb-4 bg-gray-50/30 dark:bg-gray-800/30'; + instanceWrapper.id = instanceElementId; + + // Header + 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; + statusElement = document.createElement('div'); + statusElement.className = `text-sm ${statusColorClass}`; + statusElement.id = `pbs-status-${instanceId}`; + statusElement.textContent = statusText; + headerDiv.appendChild(instanceTitle); + headerDiv.appendChild(statusElement); + instanceWrapper.appendChild(headerDiv); + + // Details Container + detailsContainer = document.createElement('div'); + detailsContainer.className = `pbs-instance-details space-y-4 ${showDetails ? '' : 'hidden'}`; + detailsContainer.id = `pbs-details-${instanceId}`; + + // Datastores Section + const dsSection = document.createElement('div'); + dsSection.id = `pbs-ds-section-${instanceId}`; + dsSection.innerHTML = ` +

Datastores

+
+ + + +
Name Path Used Available Total Usage GC Status
+
`; + detailsContainer.appendChild(dsSection); + + // 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'; + 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); + + // Create Task Sections + const recentBackupTasksSection = document.createElement('div'); + recentBackupTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-backup-tasks-table-${instanceId}`, 'Backup', 'Guest'); + detailsContainer.appendChild(recentBackupTasksSection); + const recentVerifyTasksSection = document.createElement('div'); + recentVerifyTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-verify-tasks-table-${instanceId}`, 'Verification', 'Guest/Group'); + detailsContainer.appendChild(recentVerifyTasksSection); + const recentSyncTasksSection = document.createElement('div'); + recentSyncTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-sync-tasks-table-${instanceId}`, 'Sync', 'Job ID'); + detailsContainer.appendChild(recentSyncTasksSection); + const recentPruneGcTasksSection = document.createElement('div'); + recentPruneGcTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-prunegc-tasks-table-${instanceId}`, 'Prune/GC', 'Datastore/Group'); + detailsContainer.appendChild(recentPruneGcTasksSection); + + instanceWrapper.appendChild(detailsContainer); + container.appendChild(instanceWrapper); // Append new instance to DOM + + // Populate tables and attach listeners AFTER appending + dsTableBody = instanceWrapper.querySelector(`#pbs-ds-tbody-${instanceId}`); // Find the newly created body + if (dsTableBody) { + if (showDetails && pbsInstance.datastores) { + if (pbsInstance.datastores.length === 0) { dsTableBody.innerHTML = `No PBS datastores found or accessible.`; } + else { + // Populate rows + 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 { dsTableBody.innerHTML = `${statusText}`; } + } + + if (showDetails) { + populatePbsTaskTable(`pbs-recent-backup-tasks-tbody-${instanceId}`, pbsInstance.backupTasks?.recentTasks, sortState.pbsBackup.column, sortState.pbsBackup.direction); + populatePbsTaskTable(`pbs-recent-verify-tasks-tbody-${instanceId}`, pbsInstance.verificationTasks?.recentTasks, sortState.pbsVerify.column, sortState.pbsVerify.direction); + populatePbsTaskTable(`pbs-recent-sync-tasks-tbody-${instanceId}`, pbsInstance.syncTasks?.recentTasks, sortState.pbsSync.column, sortState.pbsSync.direction); + populatePbsTaskTable(`pbs-recent-prunegc-tasks-tbody-${instanceId}`, pbsInstance.pruneTasks?.recentTasks, sortState.pbsPruneGc.column, sortState.pbsPruneGc.direction); + + setupPbsSortListener(`pbs-recent-backup-tasks-table-${instanceId}`, 'pbsBackup', pbsInstance); + setupPbsSortListener(`pbs-recent-verify-tasks-table-${instanceId}`, 'pbsVerify', pbsInstance); + setupPbsSortListener(`pbs-recent-sync-tasks-table-${instanceId}`, 'pbsSync', pbsInstance); + setupPbsSortListener(`pbs-recent-prunegc-tasks-table-${instanceId}`, 'pbsPruneGc', pbsInstance); + } else { + // Ensure initial status message is present if details start hidden + const backupTbody = document.getElementById(`pbs-recent-backup-tasks-tbody-${instanceId}`); if (backupTbody) backupTbody.innerHTML = `${statusText}`; + const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`); if (verifyTbody) verifyTbody.innerHTML = `${statusText}`; + const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`); if (syncTbody) syncTbody.innerHTML = `${statusText}`; + const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`); if (pruneTbody) pruneTbody.innerHTML = `${statusText}`; + } + + } // End Upsert Logic - // Append the wrapper for this instance to the main container - container.appendChild(instanceWrapper); }); // End forEach pbsInstance + + // --- Remove Orphaned Instances --- + container.querySelectorAll('.pbs-instance-section').forEach(el => { + if (!currentInstanceIds.has(el.id)) { + console.log(`Removing orphaned PBS instance element: ${el.id}`); + el.remove(); + } + }); + } // --- End Update PBS Info Function --- diff --git a/src/public/index.html b/src/public/index.html index 6c6c5c2fd..2d0f172a0 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -221,6 +221,104 @@
+ +

Loading PBS data...

diff --git a/src/public/output.css b/src/public/output.css index bac5914d8..b630e1ff6 100644 --- a/src/public/output.css +++ b/src/public/output.css @@ -685,6 +685,10 @@ video { margin-right: 0.5rem; } +.mb-4 { + margin-bottom: 1rem; +} + .block { display: block; } @@ -745,8 +749,8 @@ video { height: 100%; } -.max-h-96 { - max-height: 24rem; +.max-h-60 { + max-height: 15rem; } .max-h-\[80vh\] {