mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Style: Consolidate tab header styles and apply stopped style to backup tab
This commit is contained in:
+58
-16
@@ -110,6 +110,7 @@ const cors = require('cors');
|
||||
const { Server } = require('socket.io');
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const { URL } = require('url'); // <--- ADD: Import URL constructor
|
||||
const axiosRetry = require('axios-retry').default; // Import axios-retry
|
||||
|
||||
// Development specific dependencies
|
||||
@@ -140,12 +141,22 @@ function loadPbsConfig(index = null) {
|
||||
const portVar = `PBS_PORT${suffix}`;
|
||||
const selfSignedVar = `PBS_ALLOW_SELF_SIGNED_CERTS${suffix}`;
|
||||
|
||||
const pbsHost = process.env[hostVar];
|
||||
if (!pbsHost) {
|
||||
const pbsHostUrl = process.env[hostVar]; // Rename variable to reflect it's a URL
|
||||
if (!pbsHostUrl) {
|
||||
// No more PBS configs if PBS_HOST is missing
|
||||
return false; // Indicate no more configs found
|
||||
}
|
||||
|
||||
// ---> ADDED: URL Parsing for Hostname Fallback <---\
|
||||
let pbsHostname = pbsHostUrl; // Default to full URL if parsing fails
|
||||
try {
|
||||
const parsedUrl = new URL(pbsHostUrl);
|
||||
pbsHostname = parsedUrl.hostname; // Extract just the hostname
|
||||
} catch (e) {
|
||||
console.warn(`WARN: Could not parse PBS_HOST URL "${pbsHostUrl}". Using full value as fallback name.`);
|
||||
}
|
||||
// ---> END ADDED <---\
|
||||
|
||||
const pbsUser = process.env[userVar];
|
||||
const pbsPassword = process.env[passVar];
|
||||
const pbsTokenId = process.env[tokenIdVar];
|
||||
@@ -157,7 +168,7 @@ function loadPbsConfig(index = null) {
|
||||
// Check User/Password first
|
||||
if (pbsUser && pbsPassword) {
|
||||
const pbsPlaceholders = placeholderValues.filter(p =>
|
||||
pbsHost.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p)
|
||||
pbsHostUrl.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p) // Check against URL
|
||||
);
|
||||
if (pbsPlaceholders.length > 0) {
|
||||
console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (User/Pass). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`);
|
||||
@@ -165,8 +176,8 @@ function loadPbsConfig(index = null) {
|
||||
config = {
|
||||
id: `${idPrefix}_userpass`,
|
||||
authMethod: 'userpass',
|
||||
name: process.env[nodeNameVar] || pbsHost, // User-defined name or host
|
||||
host: pbsHost,
|
||||
name: process.env[nodeNameVar] || pbsHostname, // User-defined name or parsed hostname
|
||||
host: pbsHostUrl, // Keep original full URL here
|
||||
port: process.env[portVar] || '8007',
|
||||
user: pbsUser,
|
||||
password: pbsPassword,
|
||||
@@ -177,11 +188,11 @@ function loadPbsConfig(index = null) {
|
||||
};
|
||||
console.log(`INFO: Found PBS configuration ${index || 'primary'} (User/Password): ${config.name} (${config.host})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check Token second
|
||||
else if (pbsTokenId && pbsTokenSecret) {
|
||||
const pbsPlaceholders = placeholderValues.filter(p =>
|
||||
pbsHost.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p)
|
||||
pbsHostUrl.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p) // Check against URL
|
||||
);
|
||||
if (pbsPlaceholders.length > 0) {
|
||||
console.warn(`WARN: Skipping PBS configuration ${index || 'primary'} (Token). Placeholder values detected for: ${pbsPlaceholders.join(', ')}`);
|
||||
@@ -189,8 +200,8 @@ function loadPbsConfig(index = null) {
|
||||
config = {
|
||||
id: `${idPrefix}_token`,
|
||||
authMethod: 'token',
|
||||
name: process.env[nodeNameVar] || pbsHost,
|
||||
host: pbsHost,
|
||||
name: process.env[nodeNameVar] || pbsHostname,
|
||||
host: pbsHostUrl, // Keep original full URL here
|
||||
port: process.env[portVar] || '8007',
|
||||
tokenId: pbsTokenId,
|
||||
tokenSecret: pbsTokenSecret,
|
||||
@@ -200,7 +211,7 @@ function loadPbsConfig(index = null) {
|
||||
};
|
||||
console.log(`INFO: Found PBS configuration ${index || 'primary'} (API Token): ${config.name} (${config.host})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Warn if host is set but auth is incomplete
|
||||
else {
|
||||
console.warn(`WARN: Partial PBS configuration found for ${hostVar}. Please set either (${userVar} + ${passVar}) or (${tokenIdVar} + ${tokenSecretVar}) along with ${hostVar}.`);
|
||||
@@ -914,14 +925,45 @@ async function fetchDiscoveryData() {
|
||||
|
||||
// Only proceed if we have a node name
|
||||
if (instanceData.nodeName) {
|
||||
// Fetch datastores and the consolidated task list concurrently
|
||||
const [datastoresResult, allTasksResult] = await Promise.all([
|
||||
fetchPbsDatastoreData({ client: pbsClientInstance, config: pbsInstanceConfig }),
|
||||
fetchAllPbsTasksForProcessing({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName) // New function call
|
||||
]);
|
||||
// Fetch datastores first, then snapshots, then tasks
|
||||
const datastoresResult = await fetchPbsDatastoreData({ client: pbsClientInstance, config: pbsInstanceConfig });
|
||||
|
||||
// Fetch snapshots for each datastore
|
||||
const snapshotFetchPromises = (datastoresResult || []).map(async (ds) => {
|
||||
const storeName = ds.name; // Assuming 'name' holds the datastore ID
|
||||
if (!storeName) {
|
||||
console.warn(`WARN: [PBS Discovery - ${instanceName}] Skipping snapshot fetch for datastore with no name:`, ds);
|
||||
ds.snapshots = []; // Ensure snapshots array exists even if skipped
|
||||
ds.snapshotError = 'Missing datastore name';
|
||||
return ds; // Return the datastore object as is
|
||||
}
|
||||
try {
|
||||
console.log(`INFO: [PBS Discovery - ${instanceName}] Fetching snapshots for datastore '${storeName}'...`);
|
||||
const snapshotResponse = await pbsClientInstance.get(`/admin/datastore/${storeName}/snapshots`);
|
||||
ds.snapshots = snapshotResponse.data?.data ?? [];
|
||||
ds.snapshotError = null;
|
||||
// console.log(`INFO: [PBS Discovery - ${instanceName}] Fetched ${ds.snapshots.length} snapshots for datastore ${storeName}.`);
|
||||
} catch (snapshotError) {
|
||||
const status = snapshotError.response?.status ? ` (Status: ${snapshotError.response.status})` : '';
|
||||
console.error(`ERROR: [PBS Discovery - ${instanceName}] Failed to fetch snapshots for datastore ${storeName}${status}: ${snapshotError.message}`);
|
||||
ds.snapshots = []; // Ensure snapshots array exists on error
|
||||
ds.snapshotError = snapshotError.message;
|
||||
// Propagate specific auth errors if needed
|
||||
if (snapshotError.response?.status === 401 || snapshotError.response?.status === 403) {
|
||||
// Optionally re-throw or handle critical permission errors differently
|
||||
}
|
||||
}
|
||||
return ds; // Return the datastore object with snapshots added
|
||||
});
|
||||
|
||||
// Wait for all snapshot fetches for this instance to complete
|
||||
const datastoresWithSnapshots = await Promise.all(snapshotFetchPromises);
|
||||
|
||||
// Fetch tasks after getting datastores and snapshots
|
||||
const allTasksResult = await fetchAllPbsTasksForProcessing({ client: pbsClientInstance, config: pbsInstanceConfig }, instanceData.nodeName); // New function call
|
||||
|
||||
// Assign datastore results
|
||||
instanceData.datastores = datastoresResult;
|
||||
instanceData.datastores = datastoresWithSnapshots; // Use the array that now includes snapshots
|
||||
|
||||
// Process the single task list for all summaries and details
|
||||
if (allTasksResult && allTasksResult.tasks) {
|
||||
|
||||
+459
-88
@@ -91,33 +91,41 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Tab Functionality ---
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
let showTab = 'main'; // Default visible tab
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabId = tab.getAttribute('data-tab');
|
||||
|
||||
// Deactivate all tabs and hide all content
|
||||
tabs.forEach(t => {
|
||||
t.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border', 'border-gray-300', 'dark:border-gray-700', 'border-b-0', '-mb-px');
|
||||
// Remove active classes, add inactive classes
|
||||
t.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white');
|
||||
t.classList.add('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700');
|
||||
});
|
||||
tab.classList.add('active', 'bg-white', 'dark:bg-gray-800', 'border', 'border-gray-300', 'dark:border-gray-700', 'border-b-0', '-mb-px');
|
||||
tabContents.forEach(content => content.classList.add('hidden'));
|
||||
|
||||
// Activate clicked tab and show its content
|
||||
// Add active classes, remove inactive classes
|
||||
tab.classList.add('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white', '-mb-px');
|
||||
tab.classList.remove('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700');
|
||||
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('block');
|
||||
content.classList.add('hidden');
|
||||
if (content.id === tabId) {
|
||||
content.classList.remove('hidden');
|
||||
content.classList.add('block');
|
||||
|
||||
const tabId = tab.getAttribute('data-tab');
|
||||
const activeContent = document.getElementById(tabId);
|
||||
if (activeContent) {
|
||||
activeContent.classList.remove('hidden');
|
||||
// If switching to dashboard, re-apply filter (in case data updated while on another tab)
|
||||
if (tabId === 'main') {
|
||||
applyDashboardFilters();
|
||||
}
|
||||
});
|
||||
|
||||
showTab = tabId; // Update global state
|
||||
// Potentially trigger data refresh if needed for the specific tab
|
||||
// ---> ADDED: Trigger Backups Tab update if switching to it <---
|
||||
if (tabId === 'backups') {
|
||||
updateBackupsTab(); // Call the update function when tab is selected
|
||||
}
|
||||
// ---> END ADDED <---
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- End Tab Switching Logic ---
|
||||
|
||||
// --- Data Storage and State ---
|
||||
let nodesData = [];
|
||||
let vmsData = [];
|
||||
@@ -129,24 +137,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedSortState = JSON.parse(localStorage.getItem('pulseSortState')) || {};
|
||||
const sortState = {
|
||||
nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) },
|
||||
main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) }
|
||||
// ---> REMOVE: PBS sort state <---
|
||||
// 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 || {}) }
|
||||
// ---> END REMOVE <---
|
||||
main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) },
|
||||
backups: { column: 'latestBackupTime', direction: 'desc', ...(savedSortState.backups || {}) }
|
||||
};
|
||||
let groupByNode = true; // Default view
|
||||
let filterGuestType = 'all'; // Default filter
|
||||
let filterGuestType = 'all'; // Restore this state variable
|
||||
const AVERAGING_WINDOW_SIZE = 5;
|
||||
const dashboardHistory = {}; // Re-add this line
|
||||
let filterStatus = 'all'; // New state variable for status filter
|
||||
let initialDataReceived = false; // Flag to control initial rendering
|
||||
let storageData = {}; // Add state for storage data
|
||||
// ---> REMOVE: pbsConfigured flag is less relevant now
|
||||
// let pbsConfigured = false; // Flag to track if PBS is configured
|
||||
// <--- END REMOVE
|
||||
// ---> ADDED: State for Backups Tab Filters <---
|
||||
let backupsFilterHealth = 'all'; // 'ok', 'warning', 'error', 'none'
|
||||
// ---> END RENAMED <---
|
||||
|
||||
// Define initial limit for PBS task tables
|
||||
const INITIAL_PBS_TASK_LIMIT = 5;
|
||||
@@ -219,6 +222,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
derivedKey = 'nodes';
|
||||
} else if (tableId.startsWith('main-')) {
|
||||
derivedKey = 'main';
|
||||
// ---> ADDED: Handle backups table < ---
|
||||
} else if (tableId.startsWith('backups-')) {
|
||||
derivedKey = 'backups';
|
||||
// ---> END ADDED < ---
|
||||
} else {
|
||||
derivedKey = null;
|
||||
}
|
||||
@@ -282,8 +289,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Save updated sort state to localStorage (Only relevant keys)
|
||||
const stateToSave = {
|
||||
nodes: sortState.nodes,
|
||||
main: sortState.main
|
||||
// Note: PBS sort state is not saved to localStorage currently
|
||||
main: sortState.main,
|
||||
backups: sortState.backups
|
||||
};
|
||||
localStorage.setItem('pulseSortState', JSON.stringify(stateToSave));
|
||||
|
||||
@@ -293,6 +300,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
case 'vms': updateVmsTable(vmsData); break;
|
||||
case 'containers': updateContainersTable(containersData); break;
|
||||
case 'main': updateDashboardTable(); break;
|
||||
// ---> ADDED: Trigger update for backups table < ---
|
||||
case 'backups': updateBackupsTab(); break;
|
||||
// ---> END ADDED < ---
|
||||
default: console.error('Unknown table type for sorting:', tableType);
|
||||
}
|
||||
|
||||
@@ -306,6 +316,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// setupTableSorting('vms-table'); // Removed - Table doesn't exist in base HTML
|
||||
// setupTableSorting('containers-table'); // Removed - Table doesn't exist in base HTML
|
||||
setupTableSorting('main-table');
|
||||
setupTableSorting('backups-overview-table'); // Setup sorting for the backups table
|
||||
|
||||
// --- Filtering Logic ---
|
||||
// Grouping Filter
|
||||
@@ -320,11 +331,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Type Filter
|
||||
// Restore the event listener for main dashboard type filter
|
||||
document.querySelectorAll('input[name="type-filter"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
filterGuestType = this.value;
|
||||
updateDashboardTable();
|
||||
filterGuestType = this.value; // Use the restored state variable
|
||||
updateDashboardTable(); // Update the main dashboard table
|
||||
// REMOVED: updateBackupsTab(); // Don't update backups tab from here
|
||||
if (searchInput) searchInput.dispatchEvent(new Event('input')); // Re-apply text filter
|
||||
}
|
||||
});
|
||||
@@ -351,6 +364,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---> ADDED: Event listeners for Backups Tab Filters <---\
|
||||
// Backup Type Filter - REMOVED
|
||||
/*
|
||||
document.querySelectorAll('input[name="backups-type-filter"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
backupsFilterType = this.value;
|
||||
updateBackupsTab(); // Only update the backups tab
|
||||
}
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// ---> MODIFIED: Event listener for Backups Health Filter <---
|
||||
// Backup Status/Age Filter -> Backup Health Filter
|
||||
document.querySelectorAll('input[name="backups-status-filter"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
backupsFilterHealth = this.value; // Update the health filter state
|
||||
updateBackupsTab(); // Only update the backups tab
|
||||
}
|
||||
});
|
||||
});
|
||||
// ---> END MODIFIED <---
|
||||
|
||||
// --- Data Sorting Function ---
|
||||
function sortData(data, column, direction, type) {
|
||||
if (!column || !data) return data || []; // Return empty array if data is null/undefined
|
||||
@@ -363,9 +401,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Use a helper to get comparable values, handling potential missing data
|
||||
const getValue = (item, col) => {
|
||||
if (!item) return type === 'string' ? '' : 0; // Default value based on expected type
|
||||
if (!item) return type === 'string' ? '' : (col === 'latestBackupTime' ? null : 0); // Handle backups time
|
||||
let val = item[col];
|
||||
|
||||
|
||||
// Special Handling for Percentage Columns (Main and Nodes tables)
|
||||
if ((type === 'main' || type === 'nodes') && (col === 'cpu' || col === 'memory' || col === 'disk')) {
|
||||
// Treat N/A string as -1 for sorting purposes
|
||||
@@ -379,10 +417,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle specific column logic if needed
|
||||
if (type === 'main' && col === 'id') val = parseInt(item.vmid || item.id || 0);
|
||||
else if (type === 'nodes' && col === 'id') val = item.node;
|
||||
// ---> ADDED: Handle backup table specific columns < ---
|
||||
else if (type === 'backups') {
|
||||
// Map health status to a sortable value (e.g., Failed=1, Old=2, Stale=3, None=4, OK=5)
|
||||
if (col === 'backupHealthStatus') {
|
||||
switch (item[col]) {
|
||||
case 'failed': return 1;
|
||||
case 'old': return 2;
|
||||
case 'stale': return 3;
|
||||
case 'none': return 4;
|
||||
case 'ok': return 5;
|
||||
default: return 0; // Should not happen
|
||||
}
|
||||
}
|
||||
if (col === 'guestId' || col === 'totalBackups') val = parseInt(item[col] || 0);
|
||||
if (col === 'latestBackupTime') val = item[col]; // Keep as timestamp (number or null)
|
||||
}
|
||||
// ---> END ADDED < ---
|
||||
// ... other specific cases ...
|
||||
|
||||
|
||||
// Fallback for other types or columns
|
||||
return val ?? (type === 'string' ? '' : 0); // Use default if null/undefined
|
||||
return val ?? (type === 'string' ? '' : (col === 'latestBackupTime' ? null : 0)); // Use default if null/undefined
|
||||
};
|
||||
|
||||
// Handle specific sorting logic for nodes
|
||||
@@ -409,20 +464,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
valueB = getValue(b, column);
|
||||
}
|
||||
|
||||
// Determine type for comparison (Now should favor number for percentage/uptime/loadavg columns)
|
||||
const compareType = (typeof valueA === 'number' && typeof valueB === 'number') ? 'number' : 'string';
|
||||
// Determine type for comparison (Now should favor number for percentage/uptime/loadavg/timestamp columns)
|
||||
// ---> MODIFIED: Handle null timestamps for backups < ---
|
||||
const compareType = (typeof valueA === 'number' && typeof valueB === 'number') || (column === 'latestBackupTime' && (typeof valueA === 'number' || valueA === null) && (typeof valueB === 'number' || valueB === null))
|
||||
? 'number'
|
||||
: 'string';
|
||||
|
||||
// Comparison logic
|
||||
if (compareType === 'string') {
|
||||
valueA = String(valueA).toLowerCase();
|
||||
valueB = String(valueB).toLowerCase();
|
||||
valueA = String(valueA ?? '').toLowerCase(); // Handle potential null/undefined
|
||||
valueB = String(valueB ?? '').toLowerCase(); // Handle potential null/undefined
|
||||
return direction === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
|
||||
} else {
|
||||
// Ensure numeric comparison
|
||||
} else { // Numeric comparison (including timestamps)
|
||||
// Treat null timestamps as very old (or very new if sorting asc)
|
||||
if (valueA === null && valueB === null) return 0;
|
||||
if (valueA === null) return direction === 'asc' ? -1 : 1;
|
||||
if (valueB === null) return direction === 'asc' ? 1 : -1;
|
||||
|
||||
// Ensure numeric comparison for non-null numbers
|
||||
valueA = parseFloat(valueA) || 0;
|
||||
valueB = parseFloat(valueB) || 0;
|
||||
return direction === 'asc' ? valueA - valueB : valueB - valueA;
|
||||
}
|
||||
// ---> END MODIFIED < ---
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1115,34 +1179,41 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
mainTableBody.innerHTML = ''; // Clear
|
||||
|
||||
const currentSearchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
||||
const searchTerms = currentSearchTerm.split(',').map(term => term.trim()).filter(term => term);
|
||||
|
||||
// Apply type filter
|
||||
const typeFilteredData = (dashboardData || []).filter(guest =>
|
||||
filterGuestType === 'all' || (guest.type && guest.type.toLowerCase() === filterGuestType)
|
||||
);
|
||||
// ---> REVISED FILTERING LOGIC <---
|
||||
// 1. Start with the raw dashboardData
|
||||
let dataToProcess = dashboardData || [];
|
||||
|
||||
// Apply status filter
|
||||
const statusFilteredData = typeFilteredData.filter(guest =>
|
||||
filterStatus === 'all' || guest.status === filterStatus
|
||||
);
|
||||
// 2. Apply sorting first (using the current state)
|
||||
let sortedData = sortData(dataToProcess, sortState.main.column, sortState.main.direction, 'main');
|
||||
|
||||
// Apply sorting
|
||||
const sortedData = sortData(statusFilteredData, sortState.main.column, sortState.main.direction, 'main');
|
||||
|
||||
// Apply search filter
|
||||
const searchTerms = (searchInput ? searchInput.value.toLowerCase().split(',').map(term => term.trim()).filter(term => term) : []);
|
||||
|
||||
// Filter data based on search terms, type filter, and status filter
|
||||
// 3. Apply all filters (type, status, search) in one pass
|
||||
let filteredData = sortedData.filter(item => {
|
||||
const typeMatch = (filterGuestType === 'all' || (item.type && item.type.toLowerCase() === filterGuestType));
|
||||
const statusMatch = (filterStatus === 'all' || item.status === filterStatus);
|
||||
const nameMatch = searchTerms.length === 0 || searchTerms.some(term =>
|
||||
(item.name?.toLowerCase() || '').includes(term) ||
|
||||
(item.node?.toLowerCase() || '').includes(term) || // Allow searching node name
|
||||
(item.id?.toString() || '').includes(term) // Allow searching ID
|
||||
);
|
||||
return typeMatch && statusMatch && nameMatch; // Combine all filters
|
||||
// Check Status Filter
|
||||
const statusMatch = (filterStatus === 'all' || item.status === filterStatus);
|
||||
if (!statusMatch) return false; // Early exit if status doesn't match
|
||||
|
||||
// Check Type Filter
|
||||
const typeMatch = (filterGuestType === 'all') ||
|
||||
(filterGuestType === 'vm' && item.type === 'VM') ||
|
||||
(filterGuestType === 'ct' && item.type === 'CT');
|
||||
if (!typeMatch) return false; // Early exit if type doesn't match
|
||||
|
||||
// Check Search Filter (only if terms exist)
|
||||
if (searchTerms.length > 0) {
|
||||
const nameMatch = searchTerms.some(term =>
|
||||
(item.name?.toLowerCase() || '').includes(term) ||
|
||||
(item.node?.toLowerCase() || '').includes(term) ||
|
||||
(item.id?.toString() || '').includes(term)
|
||||
);
|
||||
if (!nameMatch) return false; // Early exit if search doesn't match
|
||||
}
|
||||
|
||||
// If all checks passed, include the item
|
||||
return true;
|
||||
});
|
||||
// ---> END REVISED FILTERING LOGIC <---
|
||||
|
||||
// Group data if needed
|
||||
const nodeGroups = {};
|
||||
@@ -1183,9 +1254,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle empty table states
|
||||
if (visibleCount === 0) {
|
||||
const filterText = currentSearchTerm ? ` match filter "${currentSearchTerm}"` : '';
|
||||
const typeText = filterGuestType !== 'all' ? filterGuestType.toUpperCase() + 's' : 'guests';
|
||||
const statusText = filterStatus !== 'all' ? ` (${filterStatus})` : ''; // Add status to the message
|
||||
mainTableBody.innerHTML = `<tr><td colspan="11" class="p-4 text-center text-gray-500">No ${typeText}${statusText}${filterText} found</td></tr>`;
|
||||
mainTableBody.innerHTML = `<tr><td colspan="11" class="p-4 text-center text-gray-500">No guests${statusText}${filterText} found</td></tr>`;
|
||||
}
|
||||
|
||||
// Update Status Text
|
||||
@@ -1193,7 +1263,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const statusBaseText = `Updated: ${new Date().toLocaleTimeString()}`;
|
||||
let statusFilterText = currentSearchTerm ? ` | Filter: "${currentSearchTerm}"` : '';
|
||||
let statusCountText = ` | Showing ${visibleCount}`;
|
||||
if (filterGuestType !== 'all') statusCountText += ` ${filterGuestType.toUpperCase()}s`;
|
||||
if (filterStatus !== 'all') statusCountText += ` (${filterStatus})`; // Add status to the count text
|
||||
statusCountText += ` guests`;
|
||||
if (groupByNode && visibleNodes.size > 0) statusCountText += ` across ${visibleNodes.size} nodes`;
|
||||
@@ -1205,27 +1274,45 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// console.log('[createGuestRow] Received guest data:', guest);
|
||||
const row = document.createElement('tr');
|
||||
// Add more prominent hover background, shadow, lift effect, and transition
|
||||
row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px ${guest.status === 'stopped' ? 'opacity-600' : ''}`;
|
||||
// Also add opacity and grayscale for stopped guests
|
||||
row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px ${guest.status === 'stopped' ? 'opacity-60 grayscale' : ''}`;
|
||||
row.setAttribute('data-name', guest.name.toLowerCase());
|
||||
row.setAttribute('data-type', guest.type.toLowerCase());
|
||||
row.setAttribute('data-node', guest.node.toLowerCase());
|
||||
row.setAttribute('data-id', guest.id);
|
||||
|
||||
const cpuPercent = Math.round(guest.cpu * 100);
|
||||
const memoryPercent = guest.memory;
|
||||
const diskPercent = guest.disk;
|
||||
// --- Calculate values only if guest is running ---
|
||||
let cpuBarHTML = '-';
|
||||
let memoryBarHTML = '-';
|
||||
let diskBarHTML = '-';
|
||||
let diskReadFormatted = '-';
|
||||
let diskWriteFormatted = '-';
|
||||
let netInFormatted = '-';
|
||||
let netOutFormatted = '-';
|
||||
|
||||
const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`;
|
||||
const memoryTooltipText = guest.memoryTotal ? `${formatBytesInt(guest.memoryCurrent)} / ${formatBytesInt(guest.memoryTotal)} (${memoryPercent}%)` : `${memoryPercent}%`;
|
||||
const diskTooltipText = guest.diskTotal ? `${formatBytesInt(guest.diskCurrent)} / ${formatBytesInt(guest.diskTotal)} (${diskPercent}%)` : `${diskPercent}%`;
|
||||
|
||||
const cpuColorClass = getUsageColor(cpuPercent);
|
||||
const memColorClass = getUsageColor(memoryPercent);
|
||||
const diskColorClass = getUsageColor(diskPercent);
|
||||
|
||||
const cpuBarHTML = createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass);
|
||||
const memoryBarHTML = createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass);
|
||||
const diskBarHTML = createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass);
|
||||
if (guest.status === 'running') {
|
||||
const cpuPercent = Math.round(guest.cpu * 100);
|
||||
const memoryPercent = guest.memory;
|
||||
const diskPercent = guest.disk;
|
||||
|
||||
const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`;
|
||||
const memoryTooltipText = guest.memoryTotal ? `${formatBytesInt(guest.memoryCurrent)} / ${formatBytesInt(guest.memoryTotal)} (${memoryPercent}%)` : `${memoryPercent}%`;
|
||||
const diskTooltipText = guest.diskTotal ? `${formatBytesInt(guest.diskCurrent)} / ${formatBytesInt(guest.diskTotal)} (${diskPercent}%)` : `${diskPercent}%`;
|
||||
|
||||
const cpuColorClass = getUsageColor(cpuPercent);
|
||||
const memColorClass = getUsageColor(memoryPercent);
|
||||
const diskColorClass = getUsageColor(diskPercent);
|
||||
|
||||
cpuBarHTML = createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass);
|
||||
memoryBarHTML = createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass);
|
||||
diskBarHTML = createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass);
|
||||
|
||||
diskReadFormatted = formatSpeedInt(guest.diskread);
|
||||
diskWriteFormatted = formatSpeedInt(guest.diskwrite);
|
||||
netInFormatted = formatSpeedInt(guest.netin);
|
||||
netOutFormatted = formatSpeedInt(guest.netout);
|
||||
}
|
||||
// --- End calculation block ---
|
||||
|
||||
const typeIconClass = guest.type === 'VM'
|
||||
? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 font-medium'
|
||||
@@ -1236,14 +1323,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<td class="p-1 px-2 whitespace-nowrap truncate" title="${guest.name}">${guest.name}</td>
|
||||
<td class="p-1 px-1 text-center">${typeIcon}</td>
|
||||
<td class="p-1 px-2 text-center">${guest.id}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap">${formatUptime(guest.uptime)}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap">${guest.status === 'stopped' ? '-' : formatUptime(guest.uptime)}</td>
|
||||
<td class="p-1 px-2">${cpuBarHTML}</td>
|
||||
<td class="p-1 px-2">${memoryBarHTML}</td>
|
||||
<td class="p-1 px-2">${diskBarHTML}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.diskread)}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.diskwrite)}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.netin)}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${formatSpeedInt(guest.netout)}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${diskReadFormatted}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${diskWriteFormatted}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${netInFormatted}</td>
|
||||
<td class="p-1 px-2 text-right whitespace-nowrap">${netOutFormatted}</td>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
@@ -1362,7 +1449,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const filterAllRadio = document.getElementById('filter-all');
|
||||
if(filterAllRadio) filterAllRadio.checked = true;
|
||||
filterGuestType = 'all';
|
||||
// REMOVED: filterGuestType = 'all';
|
||||
|
||||
const statusAllRadio = document.getElementById('filter-status-all');
|
||||
if(statusAllRadio) statusAllRadio.checked = true;
|
||||
@@ -1370,6 +1457,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
updateDashboardTable();
|
||||
if (searchInput) searchInput.blur(); // Blur search input after reset
|
||||
|
||||
// ---> ADDED: Reset Backups Tab Filters <---\
|
||||
// const backupTypeAllRadio = document.getElementById('backups-filter-type-all'); // REMOVED
|
||||
// if(backupTypeAllRadio) backupTypeAllRadio.checked = true; // REMOVED
|
||||
// backupsFilterType = 'all'; // REMOVED
|
||||
|
||||
// ---> MODIFIED: Reset Backups Health Filter <---
|
||||
const backupStatusAllRadio = document.getElementById('backups-filter-status-all');
|
||||
if(backupStatusAllRadio) backupStatusAllRadio.checked = true;
|
||||
backupsFilterHealth = 'all';
|
||||
// ---> END MODIFIED <---
|
||||
|
||||
// Reset Type filter for main dashboard
|
||||
const typeAllRadio = document.getElementById('filter-all');
|
||||
if(typeAllRadio) typeAllRadio.checked = true;
|
||||
filterGuestType = 'all';
|
||||
}
|
||||
|
||||
// --- Reset Filters/Sort Listener ---
|
||||
@@ -1432,6 +1535,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// ---> CHANGE: Pass pbsDataArray
|
||||
// updatePbsInfo(pbsData);
|
||||
updatePbsInfo(pbsDataArray);
|
||||
// ---> ADDED: Call backup tab update
|
||||
updateBackupsTab();
|
||||
// ---> END ADDED
|
||||
// <--- END CHANGE
|
||||
}
|
||||
|
||||
@@ -2092,4 +2198,269 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
// --- End PBS Task Table Toggle Listener ---
|
||||
|
||||
// --- NEW: Backups Tab Logic ---
|
||||
function updateBackupsTab() {
|
||||
// console.log("[Backups Tab] Updating..."); // Debug log
|
||||
const container = document.getElementById('backups-overview-container');
|
||||
const tableContainer = document.getElementById('backups-table-container');
|
||||
const tableBody = document.getElementById('backups-overview-tbody');
|
||||
const loadingMsg = document.getElementById('backups-loading-message');
|
||||
const noDataMsg = document.getElementById('backups-no-data-message');
|
||||
|
||||
if (!container || !tableContainer || !tableBody || !loadingMsg || !noDataMsg) {
|
||||
console.error("UI elements for Backups tab not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine VMs and Containers into a single list of guests
|
||||
const allGuests = [
|
||||
...(vmsData || []),
|
||||
...(containersData || [])
|
||||
];
|
||||
// console.log("[Backups Tab DEBUG] All Guests:", JSON.stringify(allGuests.slice(0, 5))); // Log first 5 guests
|
||||
|
||||
// Check if we have guest data and PBS data (even if empty array)
|
||||
if (!initialDataReceived || !pbsDataArray) {
|
||||
// Still loading initial data
|
||||
loadingMsg.classList.remove('hidden');
|
||||
tableContainer.classList.add('hidden');
|
||||
noDataMsg.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (allGuests.length === 0) {
|
||||
// No guests found from PVE
|
||||
loadingMsg.classList.add('hidden');
|
||||
tableContainer.classList.add('hidden');
|
||||
noDataMsg.textContent = "No Proxmox guests (VMs/Containers) found.";
|
||||
noDataMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---> REWRITTEN: Data Processing Logic for Backup Health <---
|
||||
const backupStatusByGuest = [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60);
|
||||
const threeDaysAgo = now - (3 * 24 * 60 * 60);
|
||||
|
||||
// 1. Combine all recent backup tasks from all PBS instances
|
||||
const allRecentBackupTasks = (pbsDataArray || []).flatMap(pbs =>
|
||||
(pbs.backupTasks?.recentTasks || []).map(task => ({ // Ensure recentTasks exists
|
||||
...task,
|
||||
// Attempt to extract guest ID and type from the task ID (e.g., "vm/101", "ct/102")
|
||||
guestId: task.id?.split('/')[1] || null,
|
||||
guestTypePbs: task.id?.split('/')[0] || null, // vm or ct
|
||||
pbsInstanceName: pbs.pbsInstanceName // Add instance name for reference
|
||||
}))
|
||||
);
|
||||
|
||||
// 2. Flatten snapshots (as before, for total count and latest timestamp fallback)
|
||||
const allSnapshots = (pbsDataArray || []).flatMap(pbsInstance =>
|
||||
(pbsInstance.datastores || []).flatMap(ds =>
|
||||
(ds.snapshots || []).map(snap => ({
|
||||
...snap,
|
||||
pbsInstanceName: pbsInstance.pbsInstanceName,
|
||||
datastoreName: ds.name,
|
||||
backupType: snap['backup-type'],
|
||||
backupVMID: snap['backup-id']
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// 3. Process each PVE guest
|
||||
allGuests.forEach(guest => {
|
||||
const guestId = String(guest.vmid);
|
||||
const guestTypePve = guest.type === 'qemu' ? 'vm' : 'ct';
|
||||
|
||||
// Find snapshots for this guest
|
||||
const guestSnapshots = allSnapshots.filter(snap =>
|
||||
String(snap.backupVMID) === guestId && snap.backupType === guestTypePve
|
||||
);
|
||||
const totalBackups = guestSnapshots.length;
|
||||
const latestSnapshot = guestSnapshots.reduce((latest, snap) => {
|
||||
return (!latest || (snap['backup-time'] && snap['backup-time'] > latest['backup-time'])) ? snap : latest;
|
||||
}, null);
|
||||
const latestSnapshotTime = latestSnapshot ? latestSnapshot['backup-time'] : null;
|
||||
|
||||
// Find the latest backup task for this guest
|
||||
const guestTasks = allRecentBackupTasks.filter(task =>
|
||||
task.guestId === guestId && task.guestTypePbs === guestTypePve
|
||||
);
|
||||
const latestTask = guestTasks.reduce((latest, task) => {
|
||||
return (!latest || (task.startTime && task.startTime > latest.startTime)) ? task : latest;
|
||||
}, null);
|
||||
|
||||
// Determine Backup Health Status
|
||||
let healthStatus = 'none';
|
||||
let displayTimestamp = latestSnapshotTime; // Default to snapshot time
|
||||
|
||||
if (latestTask) {
|
||||
displayTimestamp = latestTask.startTime; // Prefer task start time if available
|
||||
if (latestTask.status === 'OK') {
|
||||
if (latestTask.startTime >= threeDaysAgo) {
|
||||
healthStatus = 'ok';
|
||||
} else if (latestTask.startTime >= sevenDaysAgo) {
|
||||
healthStatus = 'stale'; // Successful but 3-7 days old
|
||||
} else {
|
||||
healthStatus = 'old'; // Successful but > 7 days old
|
||||
}
|
||||
} else {
|
||||
// Any non-OK status for the latest task marks it as failed
|
||||
healthStatus = 'failed';
|
||||
}
|
||||
} else if (latestSnapshotTime) {
|
||||
// No recent tasks found, rely on snapshot age
|
||||
if (latestSnapshotTime >= threeDaysAgo) {
|
||||
healthStatus = 'stale'; // Treat as stale if no recent task, even if snapshot < 3d
|
||||
} else if (latestSnapshotTime >= sevenDaysAgo) {
|
||||
healthStatus = 'stale'; // Also stale if 3-7d old snapshot, no task
|
||||
} else {
|
||||
healthStatus = 'old'; // Treat as old if snapshot > 7d, no task
|
||||
}
|
||||
} else {
|
||||
// No tasks and no snapshots
|
||||
healthStatus = 'none';
|
||||
displayTimestamp = null; // Ensure no timestamp shown
|
||||
}
|
||||
|
||||
backupStatusByGuest.push({
|
||||
guestName: guest.name || `Guest ${guest.vmid}`,
|
||||
guestId: guest.vmid,
|
||||
guestType: guest.type === 'qemu' ? 'VM' : 'LXC',
|
||||
node: guest.node,
|
||||
guestPveStatus: guest.status, // Add the PVE status here
|
||||
latestBackupTime: displayTimestamp, // Use determined display timestamp
|
||||
pbsInstanceName: latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'N/A',
|
||||
datastoreName: latestSnapshot?.datastoreName || 'N/A', // Only snapshots know datastore
|
||||
totalBackups: totalBackups,
|
||||
backupHealthStatus: healthStatus // Store the calculated health
|
||||
});
|
||||
});
|
||||
// ---> END REWRITTEN Data Processing Logic <---
|
||||
|
||||
// console.log("[Backups Tab] Processed Guest Status with Health:", backupStatusByGuest); // Debug log
|
||||
|
||||
// ---> MODIFIED: Filter the data based on health status <---
|
||||
const filteredBackupStatus = backupStatusByGuest.filter(item => {
|
||||
// Check Health Filter
|
||||
const healthMatch = (backupsFilterHealth === 'all') ||
|
||||
(backupsFilterHealth === 'ok' && (item.backupHealthStatus === 'ok' || item.backupHealthStatus === 'stale')) || // OK includes Stale for filtering
|
||||
(backupsFilterHealth === 'warning' && (item.backupHealthStatus === 'old')) || // Warning = Old
|
||||
(backupsFilterHealth === 'error' && item.backupHealthStatus === 'failed') || // Error = Failed
|
||||
(backupsFilterHealth === 'none' && item.backupHealthStatus === 'none');
|
||||
if (!healthMatch) return false;
|
||||
|
||||
// If all checks pass, include the item
|
||||
return true;
|
||||
});
|
||||
// ---> END MODIFIED <---
|
||||
|
||||
// ---> MODIFIED: Sort the *filtered* data <---
|
||||
const sortedBackupStatus = sortData(filteredBackupStatus, sortState.backups.column, sortState.backups.direction, 'backups');
|
||||
// ---> END MODIFIED <---
|
||||
|
||||
// Populate the table
|
||||
tableBody.innerHTML = ''; // Clear previous content
|
||||
// ---> MODIFIED: Use filtered and sorted data <---
|
||||
if (sortedBackupStatus.length > 0) {
|
||||
sortedBackupStatus.forEach(guestStatus => {
|
||||
// ---> END MODIFIED < ---
|
||||
const row = tableBody.insertRow();
|
||||
// ---> MODIFIED: Add dashboard hover/transition classes < ---
|
||||
// Conditionally add opacity and grayscale if the guest PVE status is 'stopped'
|
||||
row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px ${guestStatus.guestPveStatus === 'stopped' ? 'opacity-60 grayscale' : ''}`;
|
||||
|
||||
const latestBackupFormatted = guestStatus.latestBackupTime
|
||||
? formatPbsTimestamp(guestStatus.latestBackupTime)
|
||||
: '<span class="text-gray-400">No backups found</span>';
|
||||
|
||||
// Determine the color class based on the latest backup time
|
||||
// ---> ADDED: Determine color/icon based on HEALTH status <---
|
||||
let healthIndicator = '';
|
||||
switch (guestStatus.backupHealthStatus) {
|
||||
case 'ok':
|
||||
healthIndicator = '<span class="text-green-600 dark:text-green-400" title="OK">●</span>';
|
||||
break;
|
||||
case 'stale':
|
||||
healthIndicator = '<span class="text-yellow-600 dark:text-yellow-400" title="Stale">●</span>';
|
||||
break;
|
||||
case 'failed':
|
||||
healthIndicator = '<span class="text-red-600 dark:text-red-400 font-bold" title="Failed">✖</span>';
|
||||
break;
|
||||
case 'old':
|
||||
healthIndicator = '<span class="text-orange-600 dark:text-orange-400" title="Old">●</span>';
|
||||
break;
|
||||
case 'none':
|
||||
healthIndicator = '<span class="text-gray-400 dark:text-gray-500" title="None">-</span>';
|
||||
break;
|
||||
}
|
||||
// ---> END ADDED <---
|
||||
|
||||
// --- Generate Type Icon ---
|
||||
const typeIconClass = guestStatus.guestType === 'VM'
|
||||
? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 font-medium'
|
||||
: 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-1.5 py-0.5 font-medium';
|
||||
const typeIcon = `<span class="type-icon inline-block rounded text-xs align-middle ${typeIconClass}">${guestStatus.guestType}</span>`;
|
||||
// --- End Type Icon ---
|
||||
|
||||
// ---> MODIFIED: Standardize padding, alignment, and text styles, ADD health indicator < ---
|
||||
row.innerHTML = `
|
||||
<td class="p-1 px-2 whitespace-nowrap text-center">${healthIndicator}</td> <!-- Health Status -->
|
||||
<td class="p-1 px-2 whitespace-nowrap font-medium text-gray-900 dark:text-gray-100" title="${guestStatus.guestName}">${guestStatus.guestName}</td>
|
||||
<td class="p-1 px-2 text-center text-gray-500 dark:text-gray-400">${guestStatus.guestId}</td>
|
||||
<td class="p-1 px-2 text-center">${typeIcon}</td> <!-- Use icon -->
|
||||
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.node}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${latestBackupFormatted}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.pbsInstanceName}</td>
|
||||
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.datastoreName}</td>
|
||||
<td class="p-1 px-2 text-center text-gray-500 dark:text-gray-400">${guestStatus.totalBackups}</td>
|
||||
`;
|
||||
// ---> END MODIFIED < ---
|
||||
});
|
||||
|
||||
loadingMsg.classList.add('hidden');
|
||||
noDataMsg.classList.add('hidden');
|
||||
tableContainer.classList.remove('hidden'); // Show the table
|
||||
} else {
|
||||
// This case should technically be covered by the initial checks, but added for safety
|
||||
loadingMsg.classList.add('hidden');
|
||||
tableContainer.classList.add('hidden');
|
||||
// ---> MODIFIED: Clearer message if no backups/guests OR if filters cause empty state <---
|
||||
let emptyMessage = "No backup information found for any guests.";
|
||||
if (backupStatusByGuest.length === 0) { // Check original data length before filtering
|
||||
if (allGuests.length === 0) {
|
||||
emptyMessage = "No Proxmox guests (VMs/Containers) found.";
|
||||
} else {
|
||||
emptyMessage = "No backup information found for any guests.";
|
||||
}
|
||||
}
|
||||
// ---> MODIFIED: Use filtered data length for empty message check and improve wording <---
|
||||
else if (filteredBackupStatus.length === 0) { // Check filtered data length
|
||||
const typeFilterText = backupsFilterType === 'all' ? '' : `Type: ${backupsFilterType.toUpperCase()}`;
|
||||
// ---> MODIFIED: Map health filter value to labels for message <---
|
||||
// let statusFilterLabel = '';
|
||||
// switch (backupsFilterHealth) { ... }
|
||||
// const statusFilterText = backupsFilterHealth === 'all' ? '' : `Status: ${statusFilterLabel}`;
|
||||
const filtersApplied = [typeFilterText].filter(Boolean).join(', '); // Only include type filter
|
||||
|
||||
if (filtersApplied) {
|
||||
emptyMessage = `No guests found matching the selected filters (${filtersApplied}).`;
|
||||
} else {
|
||||
// This case shouldn't normally happen if backupStatusByGuest was not empty, but good to have a fallback
|
||||
emptyMessage = "No guests with backup information found.";
|
||||
}
|
||||
}
|
||||
// ---> END MODIFIED < ---
|
||||
noDataMsg.textContent = emptyMessage;
|
||||
// ---> END MODIFIED < ---
|
||||
noDataMsg.classList.remove('hidden');
|
||||
}
|
||||
// ---> ADDED: Update sort UI for backups table < ---
|
||||
const backupsSortColumn = sortState.backups.column;
|
||||
const backupsHeader = document.querySelector(`#backups-overview-table th[data-sort="${backupsSortColumn}"]`);
|
||||
updateSortUI('backups-overview-table', backupsHeader);
|
||||
// ---> END ADDED < ---
|
||||
}
|
||||
// --- END: Backups Tab Logic ---
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
+53
-1
@@ -80,6 +80,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block align-middle"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>
|
||||
<span>Storage</span>
|
||||
</div>
|
||||
<!-- Add Backups Tab -->
|
||||
<div class="tab px-3 py-1.5 cursor-pointer bg-gray-100 dark:bg-gray-700/50 border-transparent text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-t flex items-center gap-1.5" data-tab="backups">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block align-middle"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><path d="m9 12 2 2 4-4"></path></svg> <!-- Shield with check -->
|
||||
<span>Backups</span>
|
||||
</div>
|
||||
<!-- Add PBS Tab -->
|
||||
<div class="tab px-3 py-1.5 cursor-pointer bg-gray-100 dark:bg-gray-700/50 border-transparent text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-t flex items-center gap-1.5" data-tab="pbs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block align-middle"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||
@@ -219,7 +224,7 @@
|
||||
|
||||
<!-- PBS Tab Content -->
|
||||
<div id="pbs" class="tab-content hidden bg-white dark:bg-gray-800 p-4 border border-t-0 border-gray-300 dark:border-gray-700 rounded-b-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200">Proxmox Backup Server Status</h2>
|
||||
<h2 class="text-lg font-medium mb-2 text-gray-800 dark:text-gray-200">Proxmox Backup Server Status</h2>
|
||||
|
||||
<div id="pbs-instances-container" class="space-y-6">
|
||||
<p id="pbs-loading-message" class="text-gray-500 dark:text-gray-400">Loading PBS data...</p>
|
||||
@@ -229,11 +234,58 @@
|
||||
</div>
|
||||
<!-- End PBS Tab Content -->
|
||||
|
||||
<!-- Backups Tab Content -->
|
||||
<div id="backups" class="tab-content hidden bg-white dark:bg-gray-800 p-4 border border-t-0 border-gray-300 dark:border-gray-700 rounded-b-lg shadow-sm">
|
||||
<h2 class="text-lg font-medium mb-2 text-gray-800 dark:text-gray-200">Guest Backup Status</h2>
|
||||
|
||||
<div id="backups-overview-container" class="space-y-4">
|
||||
<p id="backups-loading-message" class="text-gray-500 dark:text-gray-400 p-4 text-center">Loading backup status overview...</p>
|
||||
|
||||
<!-- Backups Filter Bar REMOVED -->
|
||||
|
||||
<div id="backups-table-container" class="hidden space-y-2">
|
||||
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
|
||||
<table id="backups-overview-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="text-xs text-gray-700 dark:text-gray-300 uppercase bg-gray-100 dark:bg-gray-700/50 sticky top-0">
|
||||
<tr>
|
||||
<!-- ADDED: Health Status Header -->
|
||||
<th scope="col" class="sortable p-1 px-2 text-center font-semibold cursor-pointer select-none whitespace-nowrap w-[30px]" data-sort="backupHealthStatus"></th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="guestName">Guest Name</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-center font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="guestId">ID</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-center font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="guestType">Type</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="node">Node</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="latestBackupTime">Latest Backup</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="pbsInstanceName">PBS Instance</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-left font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="datastoreName">Datastore</th>
|
||||
<th scope="col" class="sortable p-1 px-2 text-center font-semibold cursor-pointer select-none whitespace-nowrap" data-sort="totalBackups"># Backups</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backups-overview-tbody" class="divide-y divide-gray-200 dark:divide-gray-700"><!-- Populated by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p id="backups-no-data-message" class="hidden text-gray-500 dark:text-gray-400 p-4 text-center">No Proxmox guests found or no PBS backup data available.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- End Backups Tab Content -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Custom Tooltip Element - Styled with Tailwind -->
|
||||
<div id="custom-tooltip" class="custom-tooltip absolute hidden z-50 px-2 py-1 text-xs text-white bg-gray-900/90 rounded shadow-lg pointer-events-none transition-opacity duration-100"></div>
|
||||
|
||||
<!-- Guest Snapshot Details Modal -->
|
||||
<div id="snapshot-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black bg-opacity-50"><div class="modal-content bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-3xl max-h-[80vh] flex flex-col"><div class="modal-header flex justify-between items-center border-b border-gray-300 dark:border-gray-700 pb-3 mb-4"><h3 id="snapshot-modal-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100">Snapshot Details</h3>
|
||||
<button id="snapshot-modal-close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||
</div>
|
||||
<div id="snapshot-modal-body" class="overflow-y-auto flex-grow"><p>Loading snapshots...</p>
|
||||
<!-- Snapshot table will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Modal -->
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/app.js" defer></script>
|
||||
<footer class="text-center text-xs text-gray-500 py-4">
|
||||
|
||||
Reference in New Issue
Block a user