Smooth containers table: diff rows instead of full redraw
Release rolling / release (push) Has been cancelled
Release rolling / release (push) Has been cancelled
List refreshes no longer wipe the tbody (which reset CPU/memory to zeros). Rows are patched in place, stats paint via rAF only when values change, and known samples are re-seeded when a row is (re)created.
This commit is contained in:
@@ -633,29 +633,36 @@ function initContainerFiltering() {
|
||||
const clearBtn = document.getElementById('clear-filters');
|
||||
|
||||
if (searchInput) {
|
||||
const rerender = () => {
|
||||
if (!containerFilterState.allContainers.length) return;
|
||||
const tid = containerVirt.topicId || manager.active?.id || '';
|
||||
renderContainers(containerFilterState.allContainers, tid);
|
||||
};
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
containerFilterState.search = e.target.value;
|
||||
if (containerFilterState.allContainers.length > 0) {
|
||||
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
statusFilter.addEventListener('change', (e) => {
|
||||
containerFilterState.status = e.target.value;
|
||||
if (containerFilterState.allContainers.length > 0) {
|
||||
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
|
||||
}
|
||||
if (!containerFilterState.allContainers.length) return;
|
||||
renderContainers(
|
||||
containerFilterState.allContainers,
|
||||
containerVirt.topicId || manager.active?.id || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', (e) => {
|
||||
containerFilterState.sort = e.target.value;
|
||||
if (containerFilterState.allContainers.length > 0) {
|
||||
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
|
||||
}
|
||||
if (!containerFilterState.allContainers.length) return;
|
||||
renderContainers(
|
||||
containerFilterState.allContainers,
|
||||
containerVirt.topicId || manager.active?.id || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -667,9 +674,11 @@ function initContainerFiltering() {
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (statusFilter) statusFilter.value = 'all';
|
||||
if (sortSelect) sortSelect.value = 'name-asc';
|
||||
if (containerFilterState.allContainers.length > 0) {
|
||||
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
|
||||
}
|
||||
if (!containerFilterState.allContainers.length) return;
|
||||
renderContainers(
|
||||
containerFilterState.allContainers,
|
||||
containerVirt.topicId || manager.active?.id || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -818,7 +827,15 @@ function navigateToView(viewName, opts = {}) {
|
||||
loadDashboard();
|
||||
} else if (viewName === 'containers') {
|
||||
if (hasActiveConnection()) {
|
||||
showListSkeleton('container-list', 6);
|
||||
// Keep existing rows visible while refreshing — skeleton caused stats flicker
|
||||
if (!containerFilterState.allContainers.length) {
|
||||
showListSkeleton('container-list', 6);
|
||||
} else {
|
||||
renderContainers(
|
||||
containerFilterState.allContainers,
|
||||
containerVirt.topicId || manager.active?.id || ''
|
||||
);
|
||||
}
|
||||
sendCommand('listContainers');
|
||||
}
|
||||
} else if (viewName === 'images') {
|
||||
@@ -5607,14 +5624,14 @@ function handleRpcMessage(response, conn) {
|
||||
// Delegate handling based on the response type
|
||||
switch (response.type) {
|
||||
case 'allStats':
|
||||
console.log('[INFO] Received aggregated stats for all containers.');
|
||||
response.data.forEach((stats) => updateContainerStats(stats));
|
||||
if (Array.isArray(response.data)) {
|
||||
response.data.forEach((stats) => updateContainerStats(stats));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'containers':
|
||||
console.log('[INFO] Processing container list...');
|
||||
renderContainers(response.data, topicId); // Render containers specific to this topic
|
||||
// Update dashboard stats if on dashboard view
|
||||
// Diff-based render — does not wipe live stats cells
|
||||
renderContainers(response.data, topicId);
|
||||
if (currentView === 'dashboard') {
|
||||
updateDashboardStats(response.data, null, null);
|
||||
}
|
||||
@@ -6777,29 +6794,148 @@ const containerVirt = {
|
||||
overscan: 8,
|
||||
};
|
||||
|
||||
/** Pending stats paints keyed by container id — one rAF flush, no flicker */
|
||||
const pendingStatsMap = new Map();
|
||||
let statsFlushRaf = 0;
|
||||
/** Avoid spamming inspectContainer for the same id */
|
||||
const inspectIpRequested = new Set();
|
||||
|
||||
function containerStatusClass(state) {
|
||||
const stateLower = String(state || 'unknown').toLowerCase();
|
||||
if (stateLower === 'running') return 'status-running';
|
||||
if (stateLower === 'exited' || stateLower === 'stopped') return 'status-exited';
|
||||
if (stateLower === 'created') return 'status-created';
|
||||
if (stateLower === 'restarting') return 'status-restarting';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatCpuDisplay(cpu) {
|
||||
if (cpu == null || Number.isNaN(cpu)) return '—';
|
||||
return `${Number(cpu).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatMemDisplay(memoryBytes) {
|
||||
if (memoryBytes == null || Number.isNaN(memoryBytes)) return '—';
|
||||
return `${(Number(memoryBytes) / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** Apply CPU/memory/IP to a row only when values actually change */
|
||||
function applyStatsToRow(row, stats) {
|
||||
if (!row || !stats) return;
|
||||
const cpuEl = row.querySelector('.cpu .stats-value');
|
||||
const cpuBar = row.querySelector('.cpu-bar');
|
||||
const memoryEl = row.querySelector('.memory .stats-value');
|
||||
const memoryBar = row.querySelector('.memory-bar');
|
||||
const ipEl = row.querySelector('.ip-address');
|
||||
|
||||
const cpuText = formatCpuDisplay(stats.cpu);
|
||||
if (cpuEl && cpuEl.textContent !== cpuText) {
|
||||
cpuEl.textContent = cpuText;
|
||||
}
|
||||
if (cpuBar) {
|
||||
const width = Math.min(100, Math.max(0, Number(stats.cpu) || 0));
|
||||
const w = `${width}%`;
|
||||
if (cpuBar.style.width !== w) cpuBar.style.width = w;
|
||||
}
|
||||
|
||||
const memText = formatMemDisplay(stats.memory);
|
||||
if (memoryEl && memoryEl.textContent !== memText) {
|
||||
memoryEl.textContent = memText;
|
||||
}
|
||||
if (memoryBar) {
|
||||
const maxMemory = 8 * 1024 * 1024 * 1024;
|
||||
const memoryPercent = Math.min(100, ((Number(stats.memory) || 0) / maxMemory) * 100);
|
||||
const w = `${memoryPercent}%`;
|
||||
if (memoryBar.style.width !== w) memoryBar.style.width = w;
|
||||
}
|
||||
|
||||
if (ipEl && stats.ip && stats.ip !== 'No IP Assigned') {
|
||||
if (ipEl.textContent !== stats.ip) ipEl.textContent = stats.ip;
|
||||
}
|
||||
}
|
||||
|
||||
function seedStatsIntoRow(row, containerId) {
|
||||
const s = smoothedStats[containerId];
|
||||
if (s) applyStatsToRow(row, s);
|
||||
}
|
||||
|
||||
function maybeRequestContainerIp(containerId, ipAddress) {
|
||||
if (ipAddress && ipAddress !== 'No IP Assigned') return;
|
||||
if (inspectIpRequested.has(containerId)) return;
|
||||
inspectIpRequested.add(containerId);
|
||||
sendCommand('inspectContainer', { id: containerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only structural fields on an existing row (not live stats).
|
||||
* Preserves CPU/memory DOM values between list refreshes.
|
||||
*/
|
||||
function patchContainerRow(row, container) {
|
||||
const name = container.Names?.[0]?.replace(/^\//, '') || 'Unknown';
|
||||
const image = formatImageName(container.Image || '-');
|
||||
const state = container.State || 'Unknown';
|
||||
const statusClass = containerStatusClass(state);
|
||||
const running = state.toLowerCase() === 'running';
|
||||
const ipFromData = container.ipAddress || null;
|
||||
|
||||
const nameDisplay = row.querySelector('.container-name-display');
|
||||
if (nameDisplay && nameDisplay.textContent !== name) nameDisplay.textContent = name;
|
||||
const nameLink = row.querySelector('.container-name-link');
|
||||
if (nameLink && nameLink.textContent !== name) nameLink.textContent = name;
|
||||
|
||||
// Image is 3rd cell (index 2)
|
||||
const imageTd = row.children[2];
|
||||
if (imageTd && imageTd.textContent !== image) imageTd.textContent = image;
|
||||
|
||||
const badge = row.querySelector('td .badge');
|
||||
if (badge) {
|
||||
if (badge.textContent !== state) badge.textContent = state;
|
||||
const want = `badge ${statusClass}`.trim();
|
||||
if (badge.className !== want) badge.className = want;
|
||||
}
|
||||
|
||||
const startBtn = row.querySelector('.action-start');
|
||||
const stopBtn = row.querySelector('.action-stop');
|
||||
const termBtn = row.querySelector('.action-terminal');
|
||||
const restartBtn = row.querySelector('.action-restart');
|
||||
const killBtn = row.querySelector('.action-kill');
|
||||
const pauseBtn = row.querySelector('.action-pause');
|
||||
const topBtn = row.querySelector('.action-top');
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
if (termBtn) termBtn.disabled = !running;
|
||||
if (restartBtn) restartBtn.disabled = !running;
|
||||
if (killBtn) killBtn.disabled = !running;
|
||||
if (pauseBtn) pauseBtn.disabled = !running;
|
||||
if (topBtn) topBtn.disabled = !running;
|
||||
|
||||
if (ipFromData && ipFromData !== 'No IP Assigned') {
|
||||
const ipEl = row.querySelector('.ip-address');
|
||||
if (ipEl && ipEl.textContent !== ipFromData) ipEl.textContent = ipFromData;
|
||||
}
|
||||
|
||||
row._pdContainer = container;
|
||||
seedStatsIntoRow(row, container.Id);
|
||||
}
|
||||
|
||||
function buildContainerRow(container) {
|
||||
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown';
|
||||
const image = formatImageName(container.Image || '-');
|
||||
const containerId = container.Id;
|
||||
const ipAddress = container.ipAddress || 'No IP Assigned';
|
||||
if (ipAddress === 'No IP Assigned') {
|
||||
sendCommand('inspectContainer', { id: container.Id });
|
||||
}
|
||||
const ipAddress = container.ipAddress || smoothedStats[containerId]?.ip || 'No IP Assigned';
|
||||
maybeRequestContainerIp(containerId, ipAddress);
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.containerId = containerId;
|
||||
row._pdContainer = container;
|
||||
const state = container.State || 'Unknown';
|
||||
const stateLower = state.toLowerCase();
|
||||
const statusClass =
|
||||
stateLower === 'running'
|
||||
? 'status-running'
|
||||
: stateLower === 'exited' || stateLower === 'stopped'
|
||||
? 'status-exited'
|
||||
: stateLower === 'created'
|
||||
? 'status-created'
|
||||
: stateLower === 'restarting'
|
||||
? 'status-restarting'
|
||||
: '';
|
||||
const statusClass = containerStatusClass(state);
|
||||
const prior = smoothedStats[containerId];
|
||||
const cpuText = prior ? formatCpuDisplay(prior.cpu) : '—';
|
||||
const memText = prior ? formatMemDisplay(prior.memory) : '—';
|
||||
const cpuWidth = prior ? Math.min(100, Math.max(0, prior.cpu || 0)) : 0;
|
||||
const maxMemory = 8 * 1024 * 1024 * 1024;
|
||||
const memWidth = prior ? Math.min(100, ((prior.memory || 0) / maxMemory) * 100) : 0;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
@@ -6815,17 +6951,17 @@ function buildContainerRow(container) {
|
||||
<td><span class="badge ${statusClass}">${state}</span></td>
|
||||
<td class="cpu">
|
||||
<div class="stats-container">
|
||||
<span class="stats-value">0.00%</span>
|
||||
<span class="stats-value">${cpuText}</span>
|
||||
<div class="stats-bar-container">
|
||||
<div class="stats-bar cpu-bar" style="width: 0%"></div>
|
||||
<div class="stats-bar cpu-bar" style="width: ${cpuWidth}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="memory">
|
||||
<div class="stats-container">
|
||||
<span class="stats-value">0.00 MB</span>
|
||||
<span class="stats-value">${memText}</span>
|
||||
<div class="stats-bar-container">
|
||||
<div class="stats-bar memory-bar" style="width: 0%"></div>
|
||||
<div class="stats-bar memory-bar" style="width: ${memWidth}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -6928,6 +7064,12 @@ function paintVirtualContainers() {
|
||||
const visible = Math.ceil(viewH / rh) + containerVirt.overscan * 2;
|
||||
const end = Math.min(total, start + visible);
|
||||
|
||||
// Reuse existing row elements by id so scroll does not zero stats
|
||||
const pool = new Map();
|
||||
for (const tr of listElement.querySelectorAll('tr[data-container-id]')) {
|
||||
pool.set(tr.dataset.containerId, tr);
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (start > 0) {
|
||||
const topPad = document.createElement('tr');
|
||||
@@ -6936,7 +7078,15 @@ function paintVirtualContainers() {
|
||||
fragment.appendChild(topPad);
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
fragment.appendChild(buildContainerRow(rows[i]));
|
||||
const c = rows[i];
|
||||
let row = pool.get(c.Id);
|
||||
if (row) {
|
||||
pool.delete(c.Id);
|
||||
patchContainerRow(row, c);
|
||||
} else {
|
||||
row = buildContainerRow(c);
|
||||
}
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
if (end < total) {
|
||||
const botPad = document.createElement('tr');
|
||||
@@ -6944,8 +7094,38 @@ function paintVirtualContainers() {
|
||||
botPad.innerHTML = `<td colspan="8" style="height:${(total - end) * rh}px;padding:0;border:0;"></td>`;
|
||||
fragment.appendChild(botPad);
|
||||
}
|
||||
listElement.innerHTML = '';
|
||||
listElement.appendChild(fragment);
|
||||
listElement.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff-based list update: keep existing <tr> nodes so CPU/memory never flash to 0.
|
||||
*/
|
||||
function reconcileContainerRows(listElement, containers) {
|
||||
const existing = new Map();
|
||||
for (const tr of listElement.querySelectorAll('tr[data-container-id]')) {
|
||||
existing.set(tr.dataset.containerId, tr);
|
||||
}
|
||||
|
||||
const nextIds = new Set(containers.map((c) => c.Id));
|
||||
for (const [id, tr] of existing) {
|
||||
if (!nextIds.has(id)) {
|
||||
tr.remove();
|
||||
existing.delete(id);
|
||||
inspectIpRequested.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const container of containers) {
|
||||
let row = existing.get(container.Id);
|
||||
if (row) {
|
||||
patchContainerRow(row, container);
|
||||
fragment.appendChild(row);
|
||||
} else {
|
||||
fragment.appendChild(buildContainerRow(container));
|
||||
}
|
||||
}
|
||||
listElement.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function bindContainerVirtualScroll() {
|
||||
@@ -6974,23 +7154,39 @@ function bindContainerVirtualScroll() {
|
||||
}
|
||||
|
||||
function renderContainers(containers, topicId) {
|
||||
if (!window.activePeer || !connections[topicId] || window.activePeer !== connections[topicId].peer) {
|
||||
console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.');
|
||||
return;
|
||||
// Prefer active manager connection when filters re-render without a topic
|
||||
const activeId = manager.active?.id || topicId;
|
||||
const conn = connections[topicId] || connections[activeId];
|
||||
if (!window.activePeer || !conn || (window.activePeer !== conn.peer && window.activePeer?.id !== conn.peer?.id)) {
|
||||
// Still allow local filter re-renders when we already have rows on screen
|
||||
if (!topicId && containerFilterState.allContainers.length && containerVirt.topicId) {
|
||||
topicId = containerVirt.topicId;
|
||||
} else if (topicId && connections[topicId]) {
|
||||
// ok
|
||||
} else {
|
||||
console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`);
|
||||
const list = Array.isArray(containers) ? containers : [];
|
||||
containerFilterState.allContainers = list;
|
||||
if (topicId) containerVirt.topicId = topicId;
|
||||
|
||||
const currentContainerIds = new Set(containers.map((c) => c.Id));
|
||||
const currentContainerIds = new Set(list.map((c) => c.Id));
|
||||
Object.keys(smoothedStats).forEach((containerId) => {
|
||||
if (!currentContainerIds.has(containerId)) delete smoothedStats[containerId];
|
||||
if (!currentContainerIds.has(containerId)) {
|
||||
delete smoothedStats[containerId];
|
||||
inspectIpRequested.delete(containerId);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredContainers = filterAndSortContainers(containers);
|
||||
const filteredContainers = filterAndSortContainers(list);
|
||||
const listElement = domCache.containerList || containerList;
|
||||
if (!listElement) return;
|
||||
|
||||
if (!filteredContainers.length) {
|
||||
const hasAny = (containers || []).length > 0;
|
||||
const hasAny = list.length > 0;
|
||||
listElement.innerHTML = emptyTableRow(
|
||||
8,
|
||||
hasAny ? 'No matching containers' : 'No containers yet',
|
||||
@@ -7001,21 +7197,16 @@ function renderContainers(containers, topicId) {
|
||||
}
|
||||
|
||||
containerVirt.rows = filteredContainers;
|
||||
containerVirt.topicId = topicId;
|
||||
bindContainerVirtualScroll();
|
||||
|
||||
// Virtualize only for large lists; small lists render fully for simplicity
|
||||
// Virtualize only for large lists
|
||||
if (filteredContainers.length > 80) {
|
||||
paintVirtualContainers();
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
filteredContainers.forEach((container) => {
|
||||
fragment.appendChild(buildContainerRow(container));
|
||||
});
|
||||
listElement.innerHTML = '';
|
||||
listElement.appendChild(fragment);
|
||||
// Diff update — keep row nodes so live stats never flash to zero
|
||||
reconcileContainerRows(listElement, filteredContainers);
|
||||
}
|
||||
|
||||
|
||||
@@ -7443,84 +7634,61 @@ function addActionListeners(row, container) {
|
||||
|
||||
function updateContainerStats(stats) {
|
||||
if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') {
|
||||
console.error('[ERROR] Invalid stats object:', stats);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DEBUG] Updating stats for container ID: ${stats.id}`);
|
||||
const row =
|
||||
(domCache.containerList || containerList)?.querySelector(
|
||||
`tr[data-container-id="${stats.id}"]`
|
||||
) || null;
|
||||
|
||||
const row = containerList?.querySelector(`tr[data-container-id="${stats.id}"]`);
|
||||
if (row) {
|
||||
// Ensure the IP address is added or retained from existing row
|
||||
const existingIpAddress = row.querySelector('.ip-address')?.textContent || 'No IP Assigned';
|
||||
stats.ip = stats.ip || existingIpAddress;
|
||||
|
||||
const smoothed = smoothStats(stats.id, stats);
|
||||
updateStatsUI(row, smoothed);
|
||||
// Preserve IP from row / prior sample so we never flash "No IP Assigned"
|
||||
const existingIp =
|
||||
row?.querySelector('.ip-address')?.textContent ||
|
||||
smoothedStats[stats.id]?.ip ||
|
||||
null;
|
||||
if (!stats.ip || stats.ip === 'No IP Assigned') {
|
||||
if (existingIp && existingIp !== 'No IP Assigned') stats.ip = existingIp;
|
||||
}
|
||||
|
||||
// Update container details stats if we're on that view
|
||||
if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === stats.id) {
|
||||
|
||||
const smoothed = smoothStats(stats.id, stats);
|
||||
if (row) updateStatsUI(row, smoothed);
|
||||
|
||||
// Detail pane (only when this container is open)
|
||||
if (
|
||||
currentView === 'container-details' &&
|
||||
currentContainerDetails &&
|
||||
currentContainerDetails.Id === stats.id
|
||||
) {
|
||||
const cpuEl = document.getElementById('detail-cpu');
|
||||
const memoryEl = document.getElementById('detail-memory');
|
||||
const smoothed = smoothStats(stats.id, stats);
|
||||
|
||||
if (cpuEl) cpuEl.textContent = `${smoothed.cpu.toFixed(2)}%`;
|
||||
if (memoryEl) memoryEl.textContent = `${(smoothed.memory / (1024 * 1024)).toFixed(2)} MB`;
|
||||
if (cpuEl) {
|
||||
const t = formatCpuDisplay(smoothed.cpu);
|
||||
if (cpuEl.textContent !== t) cpuEl.textContent = t;
|
||||
}
|
||||
if (memoryEl) {
|
||||
const t = formatMemDisplay(smoothed.memory);
|
||||
if (memoryEl.textContent !== t) memoryEl.textContent = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch stats updates
|
||||
let pendingStatsUpdates = [];
|
||||
|
||||
// Debounced stats update function with visualization
|
||||
const debouncedStatsUpdate = debounce((updates) => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const { row, stats } of updates) {
|
||||
const cpuEl = row.querySelector('.cpu .stats-value');
|
||||
const cpuBar = row.querySelector('.cpu-bar');
|
||||
const memoryEl = row.querySelector('.memory .stats-value');
|
||||
const memoryBar = row.querySelector('.memory-bar');
|
||||
const ipEl = row.querySelector('.ip-address');
|
||||
|
||||
if (cpuEl) {
|
||||
const cpuPercent = stats.cpu.toFixed(2) || '0.00';
|
||||
cpuEl.textContent = `${cpuPercent}%`;
|
||||
if (cpuBar) {
|
||||
// Cap at 100% for visualization
|
||||
const width = Math.min(100, parseFloat(cpuPercent));
|
||||
cpuBar.style.width = `${width}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (memoryEl) {
|
||||
const memoryMB = (stats.memory / (1024 * 1024)).toFixed(2) || '0.00';
|
||||
memoryEl.textContent = `${memoryMB} MB`;
|
||||
if (memoryBar) {
|
||||
// Calculate memory percentage (assuming reasonable max of 8GB for visualization)
|
||||
const maxMemory = 8 * 1024 * 1024 * 1024; // 8GB
|
||||
const memoryPercent = Math.min(100, (stats.memory / maxMemory) * 100);
|
||||
memoryBar.style.width = `${memoryPercent}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ipEl) ipEl.textContent = stats.ip;
|
||||
}
|
||||
});
|
||||
}, 100); // Debounce to 100ms
|
||||
function flushPendingStats() {
|
||||
statsFlushRaf = 0;
|
||||
if (!pendingStatsMap.size) return;
|
||||
const batch = [...pendingStatsMap.values()];
|
||||
pendingStatsMap.clear();
|
||||
for (const { row, stats } of batch) {
|
||||
if (row?.isConnected) applyStatsToRow(row, stats);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatsUI(row, stats) {
|
||||
pendingStatsUpdates.push({ row, stats });
|
||||
|
||||
// Trigger debounced update
|
||||
debouncedStatsUpdate(pendingStatsUpdates);
|
||||
|
||||
// Clear pending updates after processing (they're processed in the debounced function)
|
||||
setTimeout(() => {
|
||||
if (pendingStatsUpdates.length > 0) {
|
||||
pendingStatsUpdates = [];
|
||||
}
|
||||
}, 200);
|
||||
if (!row?.dataset?.containerId) return;
|
||||
pendingStatsMap.set(row.dataset.containerId, { row, stats });
|
||||
if (!statsFlushRaf) {
|
||||
statsFlushRaf = requestAnimationFrame(flushPendingStats);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user