@@ -531,6 +534,8 @@
+
+
@@ -538,6 +543,9 @@
+
+
+
diff --git a/src/public/js/tabs.js b/src/public/js/tabs.js
index 0beb18fd1..3ab7b8cee 100644
--- a/src/public/js/tabs.js
+++ b/src/public/js/tabs.js
@@ -7,6 +7,7 @@ PulseApp.ui.tabs = (() => {
let nestedTabContentContainer = null;
let mainTabsContainer = null;
let logSessionArea = null;
+ let loadedTabs = new Set();
function init() {
tabs = Array.from(document.querySelectorAll('.tab'));
@@ -40,19 +41,40 @@ PulseApp.ui.tabs = (() => {
}
});
}
+
+ // Mark main tab as loaded since it's shown by default
+ loadedTabs.add('main');
}
function activateMainTab(clickedTab, tabId) {
if (clickedTab.classList.contains('pointer-events-none')) return;
- tabs.forEach(t => styleMainTab(t, false)); // Use the helper
- tabContents.forEach(content => content.classList.add('hidden'));
+ tabs.forEach(t => {
+ styleMainTab(t, false);
+ // Update ARIA attributes
+ t.setAttribute('aria-selected', 'false');
+ t.setAttribute('tabindex', '-1');
+ });
+ tabContents.forEach(content => {
+ content.classList.add('hidden');
+ content.setAttribute('aria-hidden', 'true');
+ });
- styleMainTab(clickedTab, true); // Use the helper
+ styleMainTab(clickedTab, true);
+ // Update ARIA attributes for active tab
+ clickedTab.setAttribute('aria-selected', 'true');
+ clickedTab.setAttribute('tabindex', '0');
const activeContent = document.getElementById(tabId);
if (activeContent) {
activeContent.classList.remove('hidden');
+ activeContent.setAttribute('aria-hidden', 'false');
+
+ // Lazy load tab content
+ if (!loadedTabs.has(tabId)) {
+ loadTabContent(tabId);
+ loadedTabs.add(tabId);
+ }
if (tabId === 'main') {
activateNestedTab('nested-tab-dashboard');
@@ -70,6 +92,18 @@ PulseApp.ui.tabs = (() => {
console.warn('[Tabs] PulseApp.ui.backups not available for updateBackupsTab')
}
}
+
+ if (tabId === 'storage') {
+ if (PulseApp.ui && PulseApp.ui.storage) {
+ PulseApp.ui.storage.updateStorageTab();
+ }
+ }
+
+ if (tabId === 'pbs') {
+ if (PulseApp.ui && PulseApp.ui.pbs) {
+ PulseApp.ui.pbs.updatePBSTab();
+ }
+ }
}
}
@@ -349,9 +383,38 @@ PulseApp.ui.tabs = (() => {
}
}
+ function loadTabContent(tabId) {
+ // Load tab-specific data only when the tab is first accessed
+ switch(tabId) {
+ case 'main':
+ // Main tab is loaded by default
+ break;
+ case 'storage':
+ if (PulseApp.ui && PulseApp.ui.storage) {
+ console.log(`[Tabs] Lazy loading ${tabId} content`);
+ // Storage data is already fetched, just update the UI
+ }
+ break;
+ case 'backups':
+ if (PulseApp.ui && PulseApp.ui.backups) {
+ console.log(`[Tabs] Lazy loading ${tabId} content`);
+ // Backups data is fetched on demand in updateBackupsTab
+ }
+ break;
+ case 'pbs':
+ if (PulseApp.ui && PulseApp.ui.pbs) {
+ console.log(`[Tabs] Lazy loading ${tabId} content`);
+ // PBS data is already fetched, just update the UI
+ }
+ break;
+ }
+ }
+
return {
init,
activateNestedTab,
- updateTabAvailability
+ updateTabAvailability,
+ addLogTab,
+ removeLogTabAndContent
};
})();
diff --git a/src/public/js/tooltips.js b/src/public/js/tooltips.js
index 11c6014ee..e8b6dbac4 100644
--- a/src/public/js/tooltips.js
+++ b/src/public/js/tooltips.js
@@ -27,9 +27,20 @@ PulseApp.tooltips = (() => {
}
function handleMouseOver(event) {
- const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger');
+ const target = event.target.closest('[data-tooltip], .metric-tooltip-trigger, .storage-tooltip-trigger, .truncate');
if (target) {
- const tooltipText = target.getAttribute('data-tooltip');
+ let tooltipText = target.getAttribute('data-tooltip');
+
+ // Auto-generate tooltip for truncated text
+ if (!tooltipText && target.classList.contains('truncate')) {
+ const fullText = target.textContent.trim();
+ const title = target.getAttribute('title');
+ // Only show tooltip if text is actually truncated
+ if ((title && title !== fullText) || target.scrollWidth > target.clientWidth) {
+ tooltipText = title || fullText;
+ }
+ }
+
if (tooltipText && tooltipElement) {
tooltipElement.textContent = tooltipText;
positionTooltip(event);
diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js
index f2db8b4be..17879e967 100644
--- a/src/public/js/ui/backups.js
+++ b/src/public/js/ui/backups.js
@@ -293,6 +293,7 @@ PulseApp.ui.backups = (() => {
const loadingMsg = document.getElementById('backups-loading-message');
const noDataMsg = document.getElementById('backups-no-data-message');
const statusTextElement = document.getElementById('backups-status-text');
+ const pbsSummaryElement = document.getElementById('pbs-instances-summary');
if (!tableContainer || !tableBody || !loadingMsg || !noDataMsg || !statusTextElement) {
console.error("UI elements for Backups tab not found!");
@@ -321,6 +322,45 @@ PulseApp.ui.backups = (() => {
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
+ // Calculate PBS instances summary - only show if multiple PBS instances
+ const pbsDataArray = PulseApp.state.get('pbsDataArray') || [];
+ const pbsSummaryDismissed = PulseApp.state.get('pbsSummaryDismissed') || false;
+
+ if (pbsSummaryElement) {
+ if (pbsDataArray.length > 1 && !pbsSummaryDismissed) {
+ const pbsSummary = pbsDataArray.map(pbs => {
+ const backupCount = (pbs.datastores || []).reduce((total, ds) =>
+ total + (ds.snapshots ? ds.snapshots.length : 0), 0);
+ return `${pbs.pbsInstanceName}: ${backupCount} backups`;
+ }).join(' | ');
+
+ pbsSummaryElement.innerHTML = `
+
+
+ PBS Instances (${pbsDataArray.length}): ${pbsSummary}
+ • Showing aggregated backup data from all instances
+
+
+
+ `;
+ pbsSummaryElement.classList.remove('hidden');
+
+ // Add dismiss handler
+ const dismissBtn = document.getElementById('dismiss-pbs-summary');
+ if (dismissBtn) {
+ dismissBtn.addEventListener('click', () => {
+ pbsSummaryElement.classList.add('hidden');
+ PulseApp.state.set('pbsSummaryDismissed', true);
+ PulseApp.state.saveFilterState();
+ });
+ }
+ } else {
+ pbsSummaryElement.classList.add('hidden');
+ }
+ }
+
const sortStateBackups = PulseApp.state.getSortState('backups');
const sortedBackupStatus = PulseApp.utils.sortData(filteredBackupStatus, sortStateBackups.column, sortStateBackups.direction, 'backups');
diff --git a/src/public/js/ui/common.js b/src/public/js/ui/common.js
index 14f470d37..2e8f757fc 100644
--- a/src/public/js/ui/common.js
+++ b/src/public/js/ui/common.js
@@ -247,7 +247,12 @@ PulseApp.ui.common = (() => {
const tableType = tableTypeMatch[1];
tableElement.querySelectorAll('th.sortable').forEach(th => {
- th.addEventListener('click', () => {
+ // Make sortable headers keyboard accessible
+ th.setAttribute('tabindex', '0');
+ th.setAttribute('role', 'button');
+ th.setAttribute('aria-label', `Sort by ${th.textContent.trim()}`);
+
+ const handleSort = () => {
const column = th.getAttribute('data-sort');
if (!column) return;
@@ -271,6 +276,14 @@ PulseApp.ui.common = (() => {
}
updateSortUI(tableId, th);
+ };
+
+ th.addEventListener('click', handleSort);
+ th.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleSort();
+ }
});
});
}
@@ -297,7 +310,7 @@ PulseApp.ui.common = (() => {
}
function generateNodeGroupHeaderCellHTML(text, colspan, cellTag = 'td') {
- const cellClasses = 'py-0.5 px-2 bg-gray-200 dark:bg-gray-700 subtle-stripes-light dark:subtle-stripes-dark text-left font-medium text-sm text-gray-700 dark:text-gray-300';
+ const cellClasses = 'py-0.5 px-2 bg-gray-200 dark:bg-gray-700 subtle-stripes-light dark:subtle-stripes-dark text-left font-medium text-xs sm:text-sm text-gray-700 dark:text-gray-300';
return `<${cellTag} colspan="${colspan}" class="${cellClasses}">${text}${cellTag}>`;
}
diff --git a/src/public/js/ui/dashboard.js b/src/public/js/ui/dashboard.js
index 8941ba5c6..d42c2cdf7 100644
--- a/src/public/js/ui/dashboard.js
+++ b/src/public/js/ui/dashboard.js
@@ -23,6 +23,8 @@ PulseApp.ui.dashboard = (() => {
let guestMetricDragSnapshot = {}; // To store metrics during slider drag
let tableBodyEl = null;
let statusElementEl = null;
+ let virtualScroller = null;
+ const VIRTUAL_SCROLL_THRESHOLD = 100; // Use virtual scrolling for >100 items
function init() {
searchInput = document.getElementById('dashboard-search');
@@ -490,12 +492,23 @@ PulseApp.ui.dashboard = (() => {
if (cells.length >= 10) {
// Cell order: name(0), type(1), id(2), uptime(3), cpu(4), memory(5), disk(6), diskread(7), diskwrite(8), netin(9), netout(10)
- // Update name (cell 0) if changed
+ // Update name (cell 0)
const nameCell = cells[0];
+
if (nameCell.textContent !== guest.name) {
nameCell.textContent = guest.name;
nameCell.title = guest.name;
}
+
+ // Ensure ID cell (2) has responsive classes
+ if (cells[2]) {
+ cells[2].className = 'p-1 px-2 hidden sm:table-cell';
+ }
+
+ // Ensure uptime cell (3) has responsive classes
+ if (cells[3]) {
+ cells[3].className = 'p-1 px-2 whitespace-nowrap hidden md:table-cell';
+ }
// Update uptime (cell 3)
const uptimeCell = cells[3];
@@ -533,6 +546,13 @@ PulseApp.ui.dashboard = (() => {
diskCell.innerHTML = newDiskHTML;
}
+ // Ensure I/O cells (7-10) have responsive classes
+ [7, 8, 9, 10].forEach(index => {
+ if (cells[index]) {
+ cells[index].className = 'p-1 px-2 hidden lg:table-cell';
+ }
+ });
+
// Update I/O cells (7-10) if running
if (guest.status === STATUS_RUNNING) {
// Disk Read (cell 7)
@@ -624,6 +644,14 @@ PulseApp.ui.dashboard = (() => {
return;
}
+ // Show loading skeleton if no data yet
+ const currentData = PulseApp.state.get('dashboardData');
+ if (!currentData || currentData.length === 0) {
+ if (PulseApp.ui.loadingSkeletons && tableBodyEl) {
+ PulseApp.ui.loadingSkeletons.showTableSkeleton(tableBodyEl.closest('table'), 5, 11);
+ }
+ }
+
refreshDashboardData();
const dashboardData = PulseApp.state.get('dashboardData') || [];
@@ -642,8 +670,45 @@ PulseApp.ui.dashboard = (() => {
// Check if we need a full rebuild (grouping mode changed or first render)
const needsFullRebuild = previousGroupByNode !== groupByNode || previousTableData === null;
- if (needsFullRebuild) {
- // Full rebuild
+ // Destroy existing virtual scroller if switching modes or data size changes significantly
+ if (virtualScroller && (groupByNode || sortedData.length <= VIRTUAL_SCROLL_THRESHOLD)) {
+ virtualScroller.destroy();
+ virtualScroller = null;
+ // Restore normal table structure
+ const tableContainer = document.querySelector('.table-container');
+ if (tableContainer) {
+ tableContainer.style.height = '';
+ tableContainer.innerHTML = '';
+ tableBodyEl = document.querySelector('#main-table tbody');
+ }
+ }
+
+ // Use virtual scrolling for large datasets when not grouped
+ if (!groupByNode && sortedData.length > VIRTUAL_SCROLL_THRESHOLD && PulseApp.virtualScroll) {
+ const tableContainer = document.querySelector('.table-container');
+ if (tableContainer && !virtualScroller) {
+ // Set fixed height for virtual scroll container
+ tableContainer.style.height = '600px';
+ virtualScroller = PulseApp.virtualScroll.createVirtualScroller(
+ tableContainer,
+ sortedData,
+ (guest) => {
+ const row = createGuestRow(guest);
+ // Remove hover effects for virtual rows
+ if (row) {
+ row.style.borderBottom = '1px solid rgb(229 231 235)';
+ row.classList.remove('hover:bg-gray-50', 'dark:hover:bg-gray-700/50');
+ }
+ return row;
+ }
+ );
+ } else if (virtualScroller) {
+ virtualScroller.updateItems(sortedData);
+ }
+ visibleCount = sortedData.length;
+ sortedData.forEach(guest => visibleNodes.add((guest.node || 'Unknown Node').toLowerCase()));
+ } else if (needsFullRebuild) {
+ // Full rebuild for normal rendering
if (groupByNode) {
const groupRenderResult = _renderGroupedByNode(tableBodyEl, sortedData, createGuestRow);
visibleCount = groupRenderResult.visibleCount;
@@ -664,23 +729,29 @@ PulseApp.ui.dashboard = (() => {
previousTableData = sortedData;
if (visibleCount === 0 && tableBodyEl) {
- let filterDescription = [];
- if (filterGuestType !== FILTER_ALL) filterDescription.push(`Type: ${filterGuestType.toUpperCase()}`);
- if (filterStatus !== FILTER_ALL) filterDescription.push(`Status: ${filterStatus}`);
const textSearchTerms = searchInput ? searchInput.value.toLowerCase().split(',').map(term => term.trim()).filter(term => term) : [];
- if (textSearchTerms.length > 0) filterDescription.push(`Search: "${textSearchTerms.join(', ')}"`);
-
const activeThresholds = Object.entries(thresholdState).filter(([_, state]) => state.value > 0);
- if (activeThresholds.length > 0) {
- const thresholdTexts = activeThresholds.map(([key, state]) => {
- return `${PulseApp.utils.getReadableThresholdName(key)}>=${PulseApp.utils.formatThresholdValue(key, state.value)}`;
- });
- filterDescription.push(`Thresholds: ${thresholdTexts.join(', ')}`);
+ const thresholdTexts = activeThresholds.map(([key, state]) => {
+ return `${PulseApp.utils.getReadableThresholdName(key)}>=${PulseApp.utils.formatThresholdValue(key, state.value)}`;
+ });
+
+ const hasFilters = filterGuestType !== FILTER_ALL || filterStatus !== FILTER_ALL || textSearchTerms.length > 0 || activeThresholds.length > 0;
+
+ if (PulseApp.ui.emptyStates) {
+ const context = {
+ filterType: filterGuestType,
+ filterStatus: filterStatus,
+ searchTerms: textSearchTerms,
+ thresholds: thresholdTexts
+ };
+
+ const emptyType = hasFilters ? 'no-results' : 'no-guests';
+ tableBodyEl.innerHTML = PulseApp.ui.emptyStates.createTableEmptyState(emptyType, context, 11);
+ } else {
+ // Fallback to simple message
+ let message = hasFilters ? "No guests match the current filters." : "No guests found.";
+ tableBodyEl.innerHTML = `| ${message} |
`;
}
- let message = "No guests match the current filters";
- if (filterDescription.length > 0) message += ` (${filterDescription.join('; ')})`;
- message += ".";
- tableBodyEl.innerHTML = `| ${message} |
`;
}
_updateDashboardStatusMessage(statusElementEl, visibleCount, visibleNodes, groupByNode, filterGuestType, filterStatus, searchInput, thresholdState);
@@ -831,15 +902,15 @@ PulseApp.ui.dashboard = (() => {
row.innerHTML = `
${guest.name} |
${typeIcon} |
- ${guest.id} |
- ${uptimeDisplay} |
+ ${guest.id} |
+ ${uptimeDisplay} |
${cpuBarHTML} |
${memoryBarHTML} |
${diskBarHTML} |
- ${diskReadCell} |
- ${diskWriteCell} |
- ${netInCell} |
- ${netOutCell} |
+ ${diskReadCell} |
+ ${diskWriteCell} |
+ ${netInCell} |
+ ${netOutCell} |
`;
return row;
}
diff --git a/src/public/js/ui/nodes.js b/src/public/js/ui/nodes.js
index 567624911..8b00eb6fd 100644
--- a/src/public/js/ui/nodes.js
+++ b/src/public/js/ui/nodes.js
@@ -130,12 +130,18 @@ PulseApp.ui.nodes = (() => {
console.error('Critical element #node-summary-cards-container not found for node summary cards update!');
return;
}
- container.innerHTML = ''; // Clear previous content
-
+
+ // Show loading skeletons if no data yet
if (!nodes || nodes.length === 0) {
+ if (PulseApp.ui.loadingSkeletons) {
+ PulseApp.ui.loadingSkeletons.showNodeCardsSkeleton(container, 3);
+ return;
+ }
container.innerHTML = 'No node data available for summary.
';
return;
}
+
+ container.innerHTML = ''; // Clear previous content
const numNodes = nodes.length;
diff --git a/src/public/js/ui/storage.js b/src/public/js/ui/storage.js
index 97788f7fb..313cbcf73 100644
--- a/src/public/js/ui/storage.js
+++ b/src/public/js/ui/storage.js
@@ -121,7 +121,11 @@ PulseApp.ui.storage = (() => {
const nodes = PulseApp.state.get('nodesData') || [];
if (!Array.isArray(nodes) || nodes.length === 0) {
- contentDiv.innerHTML = 'No node or storage data available.
';
+ if (PulseApp.ui.emptyStates) {
+ contentDiv.innerHTML = PulseApp.ui.emptyStates.createEmptyState('no-storage');
+ } else {
+ contentDiv.innerHTML = 'No node or storage data available.
';
+ }
return;
}
@@ -137,7 +141,11 @@ PulseApp.ui.storage = (() => {
const nodeKeys = Object.keys(storageByNode);
if (nodeKeys.length === 0) {
- contentDiv.innerHTML = 'No storage data found associated with nodes.
';
+ if (PulseApp.ui.emptyStates) {
+ contentDiv.innerHTML = PulseApp.ui.emptyStates.createEmptyState('no-storage');
+ } else {
+ contentDiv.innerHTML = 'No storage data found associated with nodes.
';
+ }
return;
}
@@ -173,7 +181,11 @@ PulseApp.ui.storage = (() => {
if (nodeStorageData.length === 0) {
const noDataRow = document.createElement('tr');
- noDataRow.innerHTML = `No storage configured or found for this node. | `;
+ if (PulseApp.ui.emptyStates) {
+ noDataRow.innerHTML = `${PulseApp.ui.emptyStates.createEmptyState('no-storage')} | `;
+ } else {
+ noDataRow.innerHTML = `No storage configured or found for this node. | `;
+ }
tbody.appendChild(noDataRow);
return;
}