From 46df0dab97ced44d9cf4b71694e75fde9c60d6ae Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Mon, 26 May 2025 18:25:59 +0100 Subject: [PATCH] Update data fetching, metrics, and UI components --- server/dataFetcher.js | 10 +- server/metricsHistory.js | 183 ++++++++++++++++++----- src/public/js/charts.js | 27 +++- src/public/js/ui/backups.js | 3 +- src/public/js/ui/common.js | 6 +- src/public/js/ui/dashboard.js | 266 +++++++++++++++++++++++++++++++++- src/public/js/utils.js | 13 ++ 7 files changed, 449 insertions(+), 59 deletions(-) diff --git a/server/dataFetcher.js b/server/dataFetcher.js index b1827c53b..aeebe6201 100644 --- a/server/dataFetcher.js +++ b/server/dataFetcher.js @@ -1,4 +1,8 @@ const { processPbsTasks } = require('./pbsUtils'); // Assuming pbsUtils.js exists or will be created +const pLimit = require('p-limit'); + +// Create a global limiter for API requests (5 concurrent requests per endpoint) +const requestLimiter = pLimit(5); // Helper function to fetch data and handle common errors/warnings async function fetchNodeResource(apiClient, endpointId, nodeName, resourcePath, resourceName, expectArray = false, transformFn = null) { @@ -114,8 +118,10 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) { return { nodes: [], vms: [], containers: [] }; } - // Pass the correct endpointId to fetchDataForNode - const guestPromises = nodes.map(node => fetchDataForNode(apiClientInstance, endpointId, node.node)); + // Pass the correct endpointId to fetchDataForNode with concurrency limiting + const guestPromises = nodes.map(node => + requestLimiter(() => fetchDataForNode(apiClientInstance, endpointId, node.node)) + ); const guestResults = await Promise.allSettled(guestPromises); let endpointVms = []; diff --git a/server/metricsHistory.js b/server/metricsHistory.js index 7dc23935f..149185453 100644 --- a/server/metricsHistory.js +++ b/server/metricsHistory.js @@ -4,17 +4,49 @@ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Clean up every 5 minutes class MetricsHistory { constructor() { - this.guestMetrics = new Map(); // guestId -> { dataPoints: [], lastCleanup: timestamp } + this.guestMetrics = new Map(); // guestId -> { dataPoints: CircularBuffer, lastCleanup: timestamp } this.startCleanupTimer(); } + // Circular buffer implementation for efficient memory usage + createCircularBuffer(maxSize) { + return { + buffer: new Array(maxSize), + size: 0, + head: 0, + maxSize: maxSize, + push(item) { + this.buffer[this.head] = item; + this.head = (this.head + 1) % this.maxSize; + if (this.size < this.maxSize) this.size++; + }, + toArray() { + if (this.size === 0) return []; + if (this.size < this.maxSize) { + return this.buffer.slice(0, this.size); + } + // Return items in chronological order + const tail = this.head; + return [...this.buffer.slice(tail), ...this.buffer.slice(0, tail)] + .filter(item => item !== undefined); + }, + filter(fn) { + return this.toArray().filter(fn); + }, + get length() { + return this.size; + } + }; + } + addMetricData(guestId, currentMetrics) { const timestamp = Date.now(); if (!this.guestMetrics.has(guestId)) { this.guestMetrics.set(guestId, { - dataPoints: [], - lastValues: null // For rate calculation + dataPoints: this.createCircularBuffer(MAX_DATA_POINTS), + lastValues: null, // For rate calculation + lastDataPoint: null // For compression }); } @@ -35,24 +67,56 @@ class MetricsHistory { } } - // Store data point with both current values and calculated rates + // Compress data by only storing changed values for cumulative metrics + const lastDataPoint = guestHistory.lastDataPoint; const dataPoint = { timestamp, cpu: currentMetrics?.cpu || 0, mem: currentMetrics?.mem || 0, - disk: currentMetrics?.disk || 0, - diskread: currentMetrics?.diskread || 0, - diskwrite: currentMetrics?.diskwrite || 0, - netin: currentMetrics?.netin || 0, - netout: currentMetrics?.netout || 0, - // Guest memory if available - guest_mem_actual_used_bytes: currentMetrics?.guest_mem_actual_used_bytes, - guest_mem_total_bytes: currentMetrics?.guest_mem_total_bytes, - // Calculated rates - ...rates + disk: currentMetrics?.disk || 0 }; + + // Only store cumulative values if they changed (saves memory) + if (!lastDataPoint || lastDataPoint.diskread !== (currentMetrics?.diskread || 0)) { + dataPoint.diskread = currentMetrics?.diskread || 0; + } + if (!lastDataPoint || lastDataPoint.diskwrite !== (currentMetrics?.diskwrite || 0)) { + dataPoint.diskwrite = currentMetrics?.diskwrite || 0; + } + if (!lastDataPoint || lastDataPoint.netin !== (currentMetrics?.netin || 0)) { + dataPoint.netin = currentMetrics?.netin || 0; + } + if (!lastDataPoint || lastDataPoint.netout !== (currentMetrics?.netout || 0)) { + dataPoint.netout = currentMetrics?.netout || 0; + } + + // Guest memory if available and changed + if (currentMetrics?.guest_mem_actual_used_bytes !== undefined && + (!lastDataPoint || lastDataPoint.guest_mem_actual_used_bytes !== currentMetrics.guest_mem_actual_used_bytes)) { + dataPoint.guest_mem_actual_used_bytes = currentMetrics.guest_mem_actual_used_bytes; + } + if (currentMetrics?.guest_mem_total_bytes !== undefined && + (!lastDataPoint || lastDataPoint.guest_mem_total_bytes !== currentMetrics.guest_mem_total_bytes)) { + dataPoint.guest_mem_total_bytes = currentMetrics.guest_mem_total_bytes; + } + + // Calculated rates + if (rates) { + Object.assign(dataPoint, rates); + } - guestHistory.dataPoints.push(dataPoint); + // Don't store if values haven't changed significantly (within 0.1% for CPU/mem) + if (lastDataPoint && + Math.abs(dataPoint.cpu - lastDataPoint.cpu) < 0.001 && + Math.abs(dataPoint.mem - lastDataPoint.mem) < 0.001 && + dataPoint.disk === lastDataPoint.disk && + !rates) { + // Update timestamp of last data point instead of adding new one + lastDataPoint.timestamp = timestamp; + } else { + guestHistory.dataPoints.push(dataPoint); + guestHistory.lastDataPoint = { ...dataPoint }; // Store full copy for comparison + } // Update last values for next rate calculation guestHistory.lastValues = { @@ -62,14 +126,6 @@ class MetricsHistory { netin: currentMetrics?.netin || 0, netout: currentMetrics?.netout || 0 }; - - // Trim to max data points - if (guestHistory.dataPoints.length > MAX_DATA_POINTS) { - guestHistory.dataPoints = guestHistory.dataPoints.slice(-MAX_DATA_POINTS); - } - - // Remove old data points - this.cleanupOldData(guestHistory); } calculateRate(currentValue, previousValue, timeDiffSeconds) { @@ -93,12 +149,27 @@ class MetricsHistory { const guestHistory = this.guestMetrics.get(guestId); const cutoffTime = Date.now() - HISTORY_RETENTION_MS; - return guestHistory.dataPoints - .filter(point => point.timestamp >= cutoffTime) - .map(point => ({ - timestamp: point.timestamp, - value: this.getMetricValue(point, metric) - })) + const dataPoints = guestHistory.dataPoints.filter(point => point && point.timestamp >= cutoffTime); + + // Reconstruct full data points from compressed storage + let lastCompletePoint = null; + return dataPoints + .map(point => { + // Fill in missing cumulative values from last complete point + const fullPoint = { ...point }; + if (lastCompletePoint) { + fullPoint.diskread = point.diskread !== undefined ? point.diskread : lastCompletePoint.diskread; + fullPoint.diskwrite = point.diskwrite !== undefined ? point.diskwrite : lastCompletePoint.diskwrite; + fullPoint.netin = point.netin !== undefined ? point.netin : lastCompletePoint.netin; + fullPoint.netout = point.netout !== undefined ? point.netout : lastCompletePoint.netout; + } + lastCompletePoint = fullPoint; + + return { + timestamp: fullPoint.timestamp, + value: this.getMetricValue(fullPoint, metric) + }; + }) .filter(point => point.value !== null && point.value !== undefined); } @@ -163,7 +234,7 @@ class MetricsHistory { for (const [guestId, guestHistory] of this.guestMetrics) { const validDataPoints = guestHistory.dataPoints - .filter(point => point.timestamp >= cutoffTime); + .filter(point => point && point.timestamp >= cutoffTime); if (validDataPoints.length > 0) { const guestInfo = guestInfoMap ? guestInfoMap[guestId] : null; @@ -183,20 +254,36 @@ class MetricsHistory { } extractMetricSeriesWithContext(dataPoints, metric, guestInfo = null) { + // Reconstruct full data points from compressed storage + let lastCompletePoint = null; return dataPoints - .map(point => ({ - timestamp: point.timestamp, - value: this.getMetricValueWithContext(point, metric, guestInfo) - })) + .map(point => { + // Fill in missing cumulative values from last complete point + const fullPoint = { ...point }; + if (lastCompletePoint) { + fullPoint.diskread = point.diskread !== undefined ? point.diskread : lastCompletePoint.diskread; + fullPoint.diskwrite = point.diskwrite !== undefined ? point.diskwrite : lastCompletePoint.diskwrite; + fullPoint.netin = point.netin !== undefined ? point.netin : lastCompletePoint.netin; + fullPoint.netout = point.netout !== undefined ? point.netout : lastCompletePoint.netout; + fullPoint.guest_mem_actual_used_bytes = point.guest_mem_actual_used_bytes !== undefined ? + point.guest_mem_actual_used_bytes : lastCompletePoint.guest_mem_actual_used_bytes; + fullPoint.guest_mem_total_bytes = point.guest_mem_total_bytes !== undefined ? + point.guest_mem_total_bytes : lastCompletePoint.guest_mem_total_bytes; + } + lastCompletePoint = fullPoint; + + return { + timestamp: fullPoint.timestamp, + value: this.getMetricValueWithContext(fullPoint, metric, guestInfo) + }; + }) .filter(point => point.value !== null && point.value !== undefined); } cleanupOldData(guestHistory) { + // Circular buffer handles cleanup automatically, just check timestamps + // This method is now mainly for compatibility if (!guestHistory) return; - - const cutoffTime = Date.now() - HISTORY_RETENTION_MS; - guestHistory.dataPoints = guestHistory.dataPoints - .filter(point => point.timestamp >= cutoffTime); } startCleanupTimer() { @@ -207,7 +294,10 @@ class MetricsHistory { this.cleanupOldData(guestHistory); // Remove guests with no recent data - if (guestHistory.dataPoints.length === 0) { + const recentData = guestHistory.dataPoints.filter( + point => point && point.timestamp >= cutoffTime + ); + if (recentData.length === 0) { this.guestMetrics.delete(guestId); } } @@ -222,9 +312,22 @@ class MetricsHistory { return { totalGuests: this.guestMetrics.size, totalDataPoints: Array.from(this.guestMetrics.values()) - .reduce((sum, guest) => sum + guest.dataPoints.length, 0) + .reduce((sum, guest) => sum + guest.dataPoints.length, 0), + estimatedMemoryUsage: this.estimateMemoryUsage() }; } + + estimateMemoryUsage() { + // Rough estimation of memory usage in bytes + let totalBytes = 0; + for (const [guestId, guestHistory] of this.guestMetrics) { + // Estimate ~100 bytes per data point (compressed) + totalBytes += guestHistory.dataPoints.length * 100; + // Add overhead for maps and structures + totalBytes += 1024; + } + return totalBytes; + } } // Singleton instance diff --git a/src/public/js/charts.js b/src/public/js/charts.js index 4e8452562..ed3b99602 100644 --- a/src/public/js/charts.js +++ b/src/public/js/charts.js @@ -119,6 +119,10 @@ PulseApp.charts = (() => { let chartDataCache = null; let lastChartFetch = 0; const CHART_FETCH_INTERVAL = 5000; // More responsive: every 5 seconds + + // Processing cache to avoid redundant downsampling + let processedDataCache = new Map(); // Key: `${guestId}-${metric}-${chartType}`, Value: processed data + let lastProcessedTimestamp = 0; function formatValue(value, metric) { if (metric === 'cpu' || metric === 'memory' || metric === 'disk') { @@ -526,18 +530,27 @@ PulseApp.charts = (() => { const guestData = chartDataCache[guestId]; - // Render usage charts (only if containers exist) + // Batch DOM checks for efficiency + const metricsToRender = []; + + // Check which charts exist in DOM ['cpu', 'memory', 'disk'].forEach(metric => { - const chartId = `chart-${guestId}-${metric}`; - const data = guestData[metric]; - createOrUpdateChart(chartId, data, metric, 'mini'); + if (document.getElementById(`chart-${guestId}-${metric}`)) { + metricsToRender.push({ metric, type: 'mini' }); + } }); - - // Render I/O sparklines (only if containers exist) + ['diskread', 'diskwrite', 'netin', 'netout'].forEach(metric => { + if (document.getElementById(`chart-${guestId}-${metric}`)) { + metricsToRender.push({ metric, type: 'sparkline' }); + } + }); + + // Render only existing charts + metricsToRender.forEach(({ metric, type }) => { const chartId = `chart-${guestId}-${metric}`; const data = guestData[metric]; - createOrUpdateChart(chartId, data, metric, 'sparkline'); + createOrUpdateChart(chartId, data, metric, type); }); } diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js index 9a231fe78..1eebfe09e 100644 --- a/src/public/js/ui/backups.js +++ b/src/public/js/ui/backups.js @@ -11,7 +11,8 @@ PulseApp.ui.backups = (() => { backupsTabContent = document.getElementById('backups'); if (backupsSearchInput) { - backupsSearchInput.addEventListener('input', updateBackupsTab); + const debouncedUpdate = debounce(updateBackupsTab, 300); + backupsSearchInput.addEventListener('input', debouncedUpdate); } else { console.warn('Element #backups-search not found - backups text filtering disabled.'); } diff --git a/src/public/js/ui/common.js b/src/public/js/ui/common.js index dc287295a..c1a99c71d 100644 --- a/src/public/js/ui/common.js +++ b/src/public/js/ui/common.js @@ -94,12 +94,14 @@ PulseApp.ui.common = (() => { }); if (searchInput) { - searchInput.addEventListener('input', function() { + const debouncedUpdate = debounce(function() { PulseApp.ui.dashboard.updateDashboardTable(); if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') { PulseApp.ui.thresholds.updateLogControlsVisibility(); } - }); + }, 300); + + searchInput.addEventListener('input', debouncedUpdate); } else { console.warn('Element #dashboard-search not found - text filtering disabled.'); } diff --git a/src/public/js/ui/dashboard.js b/src/public/js/ui/dashboard.js index 00caa87d1..5aacdeb0e 100644 --- a/src/public/js/ui/dashboard.js +++ b/src/public/js/ui/dashboard.js @@ -353,6 +353,240 @@ PulseApp.ui.dashboard = (() => { return { visibleCount, visibleNodes }; } + // Incremental table update using DOM diffing + function _updateTableIncremental(tableBody, sortedData, createRowFn, groupByNode) { + const existingRows = new Map(); + const nodeHeaders = new Map(); + let visibleCount = 0; + let visibleNodes = new Set(); + + // Build maps of existing rows and node headers + Array.from(tableBody.children).forEach(row => { + if (row.classList.contains('node-header')) { + const nodeText = row.querySelector('td').textContent.trim(); + nodeHeaders.set(nodeText, row); + } else { + const guestId = row.getAttribute('data-id'); + if (guestId) { + existingRows.set(guestId, row); + } + } + }); + + if (groupByNode) { + // Group data by node + const nodeGroups = {}; + sortedData.forEach(guest => { + const nodeName = guest.node || 'Unknown Node'; + if (!nodeGroups[nodeName]) nodeGroups[nodeName] = []; + nodeGroups[nodeName].push(guest); + }); + + // Process each node group + let currentIndex = 0; + Object.keys(nodeGroups).sort().forEach(nodeName => { + visibleNodes.add(nodeName.toLowerCase()); + + // Handle node header + let nodeHeader = nodeHeaders.get(nodeName); + if (!nodeHeader) { + // Create new node header + nodeHeader = document.createElement('tr'); + nodeHeader.className = 'node-header bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs'; + nodeHeader.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(nodeName, 11, 'td'); + } + + // Move or insert node header at correct position + if (tableBody.children[currentIndex] !== nodeHeader) { + tableBody.insertBefore(nodeHeader, tableBody.children[currentIndex] || null); + } + currentIndex++; + + // Process guests in this node group + nodeGroups[nodeName].forEach(guest => { + let guestRow = existingRows.get(guest.id); + if (guestRow) { + // Update existing row + _updateGuestRow(guestRow, guest); + existingRows.delete(guest.id); + } else { + // Create new row + guestRow = createRowFn(guest); + } + + if (guestRow) { + // Move or insert at correct position + if (tableBody.children[currentIndex] !== guestRow) { + tableBody.insertBefore(guestRow, tableBody.children[currentIndex] || null); + } + currentIndex++; + visibleCount++; + } + }); + }); + + // Remove unused node headers + nodeHeaders.forEach((header, nodeName) => { + if (!nodeGroups[nodeName] && header.parentNode) { + header.remove(); + } + }); + } else { + // Non-grouped update + sortedData.forEach((guest, index) => { + visibleNodes.add((guest.node || 'Unknown Node').toLowerCase()); + let guestRow = existingRows.get(guest.id); + + if (guestRow) { + // Update existing row + _updateGuestRow(guestRow, guest); + existingRows.delete(guest.id); + } else { + // Create new row + guestRow = createRowFn(guest); + } + + if (guestRow) { + // Move or insert at correct position + if (tableBody.children[index] !== guestRow) { + tableBody.insertBefore(guestRow, tableBody.children[index] || null); + } + visibleCount++; + } + }); + } + + // Remove any remaining unused rows + existingRows.forEach(row => { + if (row.parentNode) { + row.remove(); + } + }); + + // Remove extra rows at the end + while (tableBody.children.length > (groupByNode ? visibleCount + visibleNodes.size : visibleCount)) { + tableBody.lastChild.remove(); + } + + return { visibleCount, visibleNodes }; + } + + // Update an existing guest row with new data + function _updateGuestRow(row, guest) { + // Update data attributes + row.setAttribute('data-name', guest.name.toLowerCase()); + row.setAttribute('data-type', guest.type.toLowerCase()); + row.setAttribute('data-node', guest.node.toLowerCase()); + + // Update class for stopped state + if (guest.status === STATUS_STOPPED) { + row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 opacity-60 grayscale'; + } else { + row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'; + } + + // Update specific cells that might have changed + const cells = row.querySelectorAll('td'); + 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 + const nameCell = cells[0]; + if (nameCell.textContent !== guest.name) { + nameCell.textContent = guest.name; + nameCell.title = guest.name; + } + + // Update uptime (cell 3) + const uptimeCell = cells[3]; + let newUptimeHTML = '-'; + if (guest.status === STATUS_RUNNING) { + const formattedUptime = PulseApp.utils.formatUptime(guest.uptime); + if (guest.uptime < 3600) { // Less than 1 hour + newUptimeHTML = `${formattedUptime}`; + } else { + newUptimeHTML = formattedUptime; + } + } + if (uptimeCell.innerHTML !== newUptimeHTML) { + uptimeCell.innerHTML = newUptimeHTML; + } + + // Update CPU (cell 4) + const cpuCell = cells[4]; + const newCpuHTML = _createCpuBarHtml(guest); + if (cpuCell.innerHTML !== newCpuHTML) { + cpuCell.innerHTML = newCpuHTML; + } + + // Update Memory (cell 5) + const memCell = cells[5]; + const newMemHTML = _createMemoryBarHtml(guest); + if (memCell.innerHTML !== newMemHTML) { + memCell.innerHTML = newMemHTML; + } + + // Update Disk (cell 6) + const diskCell = cells[6]; + const newDiskHTML = _createDiskBarHtml(guest); + if (diskCell.innerHTML !== newDiskHTML) { + diskCell.innerHTML = newDiskHTML; + } + + // Update I/O cells (7-10) if running + if (guest.status === STATUS_RUNNING) { + // Disk Read (cell 7) + const diskReadCell = cells[7]; + const diskReadFormatted = PulseApp.utils.formatSpeedWithStyling(guest.diskread, 0); + const newDiskReadHTML = PulseApp.charts ? + `
${diskReadFormatted}
${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'diskread')}
` : + diskReadFormatted; + if (diskReadCell.innerHTML !== newDiskReadHTML) { + diskReadCell.innerHTML = newDiskReadHTML; + } + + // Disk Write (cell 8) + const diskWriteCell = cells[8]; + const diskWriteFormatted = PulseApp.utils.formatSpeedWithStyling(guest.diskwrite, 0); + const newDiskWriteHTML = PulseApp.charts ? + `
${diskWriteFormatted}
${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'diskwrite')}
` : + diskWriteFormatted; + if (diskWriteCell.innerHTML !== newDiskWriteHTML) { + diskWriteCell.innerHTML = newDiskWriteHTML; + } + + // Net In (cell 9) + const netInCell = cells[9]; + const netInFormatted = PulseApp.utils.formatSpeedWithStyling(guest.netin, 0); + const newNetInHTML = PulseApp.charts ? + `
${netInFormatted}
${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'netin')}
` : + netInFormatted; + if (netInCell.innerHTML !== newNetInHTML) { + netInCell.innerHTML = newNetInHTML; + } + + // Net Out (cell 10) + if (cells[10]) { + const netOutCell = cells[10]; + const netOutFormatted = PulseApp.utils.formatSpeedWithStyling(guest.netout, 0); + const newNetOutHTML = PulseApp.charts ? + `
${netOutFormatted}
${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'netout')}
` : + netOutFormatted; + if (netOutCell.innerHTML !== newNetOutHTML) { + netOutCell.innerHTML = newNetOutHTML; + } + } + } else { + // Set I/O cells to '-' if not running + [7, 8, 9, 10].forEach(index => { + if (cells[index] && cells[index].innerHTML !== '-') { + cells[index].innerHTML = '-'; + } + }); + } + } + } + function _updateDashboardStatusMessage(statusElement, visibleCount, visibleNodes, groupByNode, filterGuestType, filterStatus, searchInput, thresholdState) { if (!statusElement) return; const textSearchTerms = searchInput ? searchInput.value.toLowerCase().split(',').map(term => term.trim()).filter(term => term) : []; @@ -380,6 +614,10 @@ PulseApp.ui.dashboard = (() => { } + // Cache for previous table data to enable DOM diffing + let previousTableData = null; + let previousGroupByNode = null; + function updateDashboardTable() { if (!tableBodyEl || !statusElementEl) { console.error('Dashboard table body or status element not found/initialized!'); @@ -401,16 +639,30 @@ PulseApp.ui.dashboard = (() => { let visibleCount = 0; let visibleNodes = new Set(); - if (groupByNode) { - const groupRenderResult = _renderGroupedByNode(tableBodyEl, sortedData, createGuestRow); - visibleCount = groupRenderResult.visibleCount; - visibleNodes = groupRenderResult.visibleNodes; + // Check if we need a full rebuild (grouping mode changed or first render) + const needsFullRebuild = previousGroupByNode !== groupByNode || previousTableData === null; + + if (needsFullRebuild) { + // Full rebuild + if (groupByNode) { + const groupRenderResult = _renderGroupedByNode(tableBodyEl, sortedData, createGuestRow); + visibleCount = groupRenderResult.visibleCount; + visibleNodes = groupRenderResult.visibleNodes; + } else { + PulseApp.utils.renderTableBody(tableBodyEl, sortedData, createGuestRow, "No matching guests found.", 11); + visibleCount = sortedData.length; + sortedData.forEach(guest => visibleNodes.add((guest.node || 'Unknown Node').toLowerCase())); + } + previousGroupByNode = groupByNode; } else { - PulseApp.utils.renderTableBody(tableBodyEl, sortedData, createGuestRow, "No matching guests found.", 11); - visibleCount = sortedData.length; - sortedData.forEach(guest => visibleNodes.add((guest.node || 'Unknown Node').toLowerCase())); + // Incremental update using DOM diffing + const result = _updateTableIncremental(tableBodyEl, sortedData, createGuestRow, groupByNode); + visibleCount = result.visibleCount; + visibleNodes = result.visibleNodes; } + previousTableData = sortedData; + if (visibleCount === 0 && tableBodyEl) { let filterDescription = []; if (filterGuestType !== FILTER_ALL) filterDescription.push(`Type: ${filterGuestType.toUpperCase()}`); diff --git a/src/public/js/utils.js b/src/public/js/utils.js index 07ea39798..9d7bae278 100644 --- a/src/public/js/utils.js +++ b/src/public/js/utils.js @@ -1,4 +1,17 @@ PulseApp.utils = (() => { + // Debounce function to limit function calls + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + function getUsageColor(percentage, metric = 'generic') { // Progress bars use traditional green/yellow/red with metric-specific thresholds if (metric === 'cpu') {