fix(ui): correct sort state key handling in updateSortUI for PBS tables

This commit is contained in:
courtmanr@gmail.com
2025-04-24 15:10:57 +01:00
parent 463e5f39b0
commit c62df68d7f
5 changed files with 735 additions and 403 deletions
+76 -61
View File
@@ -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
# --- 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.
+145 -140
View File
@@ -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
+410 -200
View File
@@ -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-<type>-tasks-table-<instanceId>
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">No recent tasks found (last 7 days).</td></tr>`;
return;
}
sortedTasks.forEach(task => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50';
row.innerHTML = `
<td class="px-4 py-2 whitespace-nowrap text-gray-600 dark:text-gray-300">${task.id || 'N/A'}</td>
<td class="px-4 py-2 text-center">${getPbsStatusIcon(task.status)}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${formatPbsTimestamp(task.startTime)}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${formatDuration(task.duration)}</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500 font-mono truncate" title="${task.upid || 'N/A'}">${task.upid || 'N/A'}</td>
`;
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 = '<p class="text-gray-500 dark:text-gray-400">No PBS instances configured or data available.</p>';
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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">${title} (7d)</h4>
<div class="space-y-1 text-sm">
<div><span class="font-medium text-gray-800 dark:text-gray-200">OK:</span> <span class="ml-1 text-green-600 dark:text-green-400 font-semibold">${ok}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Failed:</span> <span class="ml-1 ${failedStyle}">${failed}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Total:</span> <span class="ml-1 text-gray-700 dark:text-gray-300 font-semibold">${total}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last OK:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastOk}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last Fail:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastFailed}</span></div>
</div>`;
return card; // Return the element
};
const createTaskTableHTML = (tableId, title, idColumnHeader) => `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Recent ${title} Tasks</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded max-h-60 overflow-y-auto">
<table id="${tableId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700/50 sticky top-0">
<tr>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="id">${idColumnHeader}</th>
<th scope="col" class="sortable px-4 py-2 text-center font-semibold cursor-pointer select-none" data-sort="status">Status</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="startTime">Start Time</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="duration">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="${tableId.replace('-table-', '-tbody-')}" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Populated by JS -->
</tbody>
</table>
</div>`;
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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Datastores</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
<table id="pbs-ds-table-${instanceId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700/50">
<tr>
<th scope="col" class="px-4 py-2 text-left font-semibold">Name</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Path</th>
<th scope="col" class="px-4 py-2 text-right font-semibold">Used</th>
<th scope="col" class="px-4 py-2 text-right font-semibold">Available</th>
<th scope="col" class="px-4 py-2 text-right font-semibold">Total</th>
<th scope="col" class="px-4 py-2 text-center font-semibold">Usage</th>
<th scope="col" class="px-4 py-2 text-center font-semibold">GC Status</th>
</tr>
</thead>
<tbody id="pbs-ds-tbody-${instanceId}" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- JS fills this -->
</tbody>
</table>
</div>`;
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 = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">No PBS datastores found or accessible.</td></tr>`;
} 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 = `
<td class="px-4 py-2 whitespace-nowrap">${ds.name || 'N/A'}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${ds.path || 'N/A'}</td>
<td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(usedBytes)}</td>
<td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(availableBytes)}</td>
<td class="px-4 py-2 text-right whitespace-nowrap">${totalBytes > 0 ? formatBytes(totalBytes) : 'N/A'}</td>
<td class="px-4 py-2 text-center min-w-[150px]">${totalBytes > 0 ? createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'}</td>
<td class="px-4 py-2 text-center whitespace-nowrap">${gcStatusHtml}</td>
`;
dsTableBody.appendChild(row);
});
}
} else if (!showDetails) {
dsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
// --- 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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">${title} (7d)</h4>
<div class="space-y-1 text-sm">
<div><span class="font-medium text-gray-800 dark:text-gray-200">OK:</span> <span class="ml-1 text-green-600 dark:text-green-400 font-semibold">${ok}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Failed:</span> <span class="ml-1 ${failedStyle}">${failed}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Total:</span> <span class="ml-1 text-gray-700 dark:text-gray-300 font-semibold">${total}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last OK:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastOk}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last Fail:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastFailed}</span></div>
</div>`;
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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Recent Backup Tasks</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded max-h-96 overflow-y-auto">
<table id="pbs-recent-tasks-table-${instanceId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700/50 sticky top-0">
<tr>
<th scope="col" class="px-4 py-2 text-left font-semibold">Guest</th>
<th scope="col" class="px-4 py-2 text-center font-semibold">Status</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Start Time</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="pbs-recent-tasks-tbody-${instanceId}" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- JS fills this -->
</tbody>
</table>
</div>`;
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">No recent backup tasks found (last 7 days).</td></tr>`;
} else {
recentTasks.forEach(task => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50';
row.innerHTML = `
<td class="px-4 py-2 whitespace-nowrap text-gray-600 dark:text-gray-300">${task.id || 'N/A'}</td>
<td class="px-4 py-2 text-center">${getPbsStatusIcon(task.status)}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${formatPbsTimestamp(task.startTime)}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${formatDuration(task.duration)}</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500 font-mono truncate" title="${task.upid || 'N/A'}">${task.upid || 'N/A'}</td>
`;
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
// 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 = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">No PBS datastores found or accessible.</td></tr>`;
} 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 = `<td class="px-4 py-2 whitespace-nowrap">${ds.name || 'N/A'}</td> <td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${ds.path || 'N/A'}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(usedBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(availableBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${totalBytes > 0 ? formatBytes(totalBytes) : 'N/A'}</td> <td class="px-4 py-2 text-center min-w-[150px]">${totalBytes > 0 ? createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'}</td> <td class="px-4 py-2 text-center whitespace-nowrap">${gcStatusHtml}</td>`;
dsTableBody.appendChild(row);
});
}
} else { // Not OK status, show status text in table
dsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
}
// 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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`);
if (verifyTbody) verifyTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`);
if (syncTbody) syncTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`);
if (pruneTbody) pruneTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
// 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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Datastores</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
<table id="pbs-ds-table-${instanceId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700/50"> <tr> <th scope="col" class="px-4 py-2 text-left font-semibold">Name</th> <th scope="col" class="px-4 py-2 text-left font-semibold">Path</th> <th scope="col" class="px-4 py-2 text-right font-semibold">Used</th> <th scope="col" class="px-4 py-2 text-right font-semibold">Available</th> <th scope="col" class="px-4 py-2 text-right font-semibold">Total</th> <th scope="col" class="px-4 py-2 text-center font-semibold">Usage</th> <th scope="col" class="px-4 py-2 text-center font-semibold">GC Status</th> </tr> </thead>
<tbody id="pbs-ds-tbody-${instanceId}" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>`;
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 = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">No PBS datastores found or accessible.</td></tr>`; }
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 = `<td class="px-4 py-2 whitespace-nowrap">${ds.name || 'N/A'}</td> <td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${ds.path || 'N/A'}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(usedBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${formatBytes(availableBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${totalBytes > 0 ? formatBytes(totalBytes) : 'N/A'}</td> <td class="px-4 py-2 text-center min-w-[150px]">${totalBytes > 0 ? createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'}</td> <td class="px-4 py-2 text-center whitespace-nowrap">${gcStatusHtml}</td>`;
dsTableBody.appendChild(row);
});
}
} else { dsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`; }
}
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`); if (verifyTbody) verifyTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`); if (syncTbody) syncTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`); if (pruneTbody) pruneTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
} // 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 ---
+98
View File
@@ -221,6 +221,104 @@
<!-- Container for dynamically generated PBS instance data -->
<div id="pbs-instances-container" class="space-y-6">
<!-- Content is generated dynamically by app.js -->
<!-- Example structure generated by JS:
<div class="pbs-instance-section ...">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-semibold ...">PBS Instance Name</h3>
<div id="pbs-status-instance-X" class="text-sm ...">Status: OK (pbs-node-name)</div>
</div>
<div id="pbs-details-instance-X" class="pbs-instance-details space-y-4">
<div>
<h4 class="text-md font-semibold mb-2 ...">Datastores</h4>
<div class="overflow-x-auto ...">
<table id="pbs-ds-table-instance-X" class="min-w-full ...">
<thead> ... </thead>
<tbody id="pbs-ds-tbody-instance-X"> ... </tbody>
</table>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
Summary Cards...
</div>
<div>
<h4 class="text-md font-semibold mb-2 ...">Recent Backup Tasks</h4>
<div class="overflow-x-auto ... max-h-60 ...">
<table id="pbs-recent-backup-tasks-table-instance-X" class="min-w-full ...">
<thead class="text-xs ... sticky top-0">
<tr>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="id">Guest</th>
<th scope="col" class="sortable px-4 py-2 text-center font-semibold cursor-pointer select-none" data-sort="status">Status</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="startTime">Start Time</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="duration">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="pbs-recent-backup-tasks-tbody-instance-X"> ... </tbody>
</table>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2 ...">Recent Verification Tasks</h4>
<div class="overflow-x-auto ... max-h-60 ...">
<table id="pbs-recent-verify-tasks-table-instance-X" class="min-w-full ...">
<thead class="text-xs ... sticky top-0">
<tr>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="id">Guest/Group</th>
<th scope="col" class="sortable px-4 py-2 text-center font-semibold cursor-pointer select-none" data-sort="status">Status</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="startTime">Start Time</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="duration">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="pbs-recent-verify-tasks-tbody-instance-X"></tbody>
</table>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2 ...">Recent Sync Tasks</h4>
<div class="overflow-x-auto ... max-h-60 ...">
<table id="pbs-recent-sync-tasks-table-instance-X" class="min-w-full ...">
<thead class="text-xs ... sticky top-0">
<tr>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="id">Job ID</th>
<th scope="col" class="sortable px-4 py-2 text-center font-semibold cursor-pointer select-none" data-sort="status">Status</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="startTime">Start Time</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="duration">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="pbs-recent-sync-tasks-tbody-instance-X"></tbody>
</table>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2 ...">Recent Prune/GC Tasks</h4>
<div class="overflow-x-auto ... max-h-60 ...">
<table id="pbs-recent-prunegc-tasks-table-instance-X" class="min-w-full ...">
<thead class="text-xs ... sticky top-0">
<tr>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="id">Datastore/Group</th>
<th scope="col" class="sortable px-4 py-2 text-center font-semibold cursor-pointer select-none" data-sort="status">Status</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="startTime">Start Time</th>
<th scope="col" class="sortable px-4 py-2 text-left font-semibold cursor-pointer select-none" data-sort="duration">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="pbs-recent-prunegc-tasks-tbody-instance-X"></tbody>
</table>
</div>
</div>
</div>
</div>
-->
<!-- Loading message while JS populates -->
<p class="text-gray-500 dark:text-gray-400">Loading PBS data...</p>
</div>
+6 -2
View File
@@ -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\] {