Smooth containers table: diff rows instead of full redraw
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:
Raven Scott
2026-07-11 16:21:58 -04:00
parent f3ebcf2894
commit 3f2e456b37
+290 -122
View File
@@ -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);
}
}