mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
fix(ui): correct sort state key handling in updateSortUI for PBS tables
This commit is contained in:
+76
-61
@@ -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
@@ -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
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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\] {
|
||||
|
||||
Reference in New Issue
Block a user