Update data fetching, metrics, and UI components

This commit is contained in:
courtmanr@gmail.com
2025-05-26 18:25:59 +01:00
parent f8252e42cc
commit 46df0dab97
7 changed files with 449 additions and 59 deletions
+8 -2
View File
@@ -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 = [];
+143 -40
View File
@@ -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
+20 -7
View File
@@ -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);
});
}
+2 -1
View File
@@ -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.');
}
+4 -2
View File
@@ -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.');
}
+259 -7
View File
@@ -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 = `<span class="text-orange-500">${formattedUptime}</span>`;
} 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 ?
`<div class="metric-text">${diskReadFormatted}</div><div class="metric-chart">${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'diskread')}</div>` :
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 ?
`<div class="metric-text">${diskWriteFormatted}</div><div class="metric-chart">${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'diskwrite')}</div>` :
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 ?
`<div class="metric-text">${netInFormatted}</div><div class="metric-chart">${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'netin')}</div>` :
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 ?
`<div class="metric-text">${netOutFormatted}</div><div class="metric-chart">${PulseApp.charts.createSparklineHTML(guest.uniqueId, 'netout')}</div>` :
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()}`);
+13
View File
@@ -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') {