mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Update data fetching, metrics, and UI components
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user