diff --git a/app.js b/app.js index 2729d74..1433440 100644 --- a/app.js +++ b/app.js @@ -263,19 +263,31 @@ const MAX_HISTORY_POINTS = 60; // Keep last 60 data points (5 minutes at 5s inte function smoothStats(containerId, newStats, smoothingFactor = 0.2) { if (!smoothedStats[containerId]) { - smoothedStats[containerId] = { cpu: 0, memory: 0, ip: newStats.ip || 'No IP Assigned' }; + smoothedStats[containerId] = { + cpu: 0, + memory: 0, + memoryLimit: 0, + ip: newStats.ip || 'No IP Assigned', + }; } - smoothedStats[containerId].cpu = - smoothedStats[containerId].cpu * (1 - smoothingFactor) + - newStats.cpu * smoothingFactor; + const cpu = Number(newStats.cpu); + const memory = Number(newStats.memory); + if (Number.isFinite(cpu)) { + smoothedStats[containerId].cpu = + smoothedStats[containerId].cpu * (1 - smoothingFactor) + cpu * smoothingFactor; + } + if (Number.isFinite(memory)) { + smoothedStats[containerId].memory = + smoothedStats[containerId].memory * (1 - smoothingFactor) + memory * smoothingFactor; + } - smoothedStats[containerId].memory = - smoothedStats[containerId].memory * (1 - smoothingFactor) + - newStats.memory * smoothingFactor; - - // Preserve the latest IP address + // Preserve the latest IP address and memory limit (for bar scale) smoothedStats[containerId].ip = newStats.ip || smoothedStats[containerId].ip; + const lim = Number(newStats.memoryLimit); + if (Number.isFinite(lim) && lim > 0) { + smoothedStats[containerId].memoryLimit = lim; + } // Store historical data for charts if (!historicalStats[containerId]) { @@ -7291,43 +7303,72 @@ function containerStatusClass(state) { } function formatCpuDisplay(cpu) { - if (cpu == null || Number.isNaN(cpu)) return '—'; - return `${Number(cpu).toFixed(2)}%`; + if (cpu == null || Number.isNaN(Number(cpu))) return '—'; + const n = Number(cpu); + if (n < 0.01 && n > 0) return '<0.01%'; + return `${n.toFixed(2)}%`; } function formatMemDisplay(memoryBytes) { - if (memoryBytes == null || Number.isNaN(memoryBytes)) return '—'; - return `${(Number(memoryBytes) / (1024 * 1024)).toFixed(2)} MB`; + if (memoryBytes == null || Number.isNaN(Number(memoryBytes))) return '—'; + const bytes = Number(memoryBytes); + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + const digits = i === 0 ? 0 : v >= 10 ? 1 : 2; + return `${v.toFixed(digits)} ${units[i]}`; +} + +/** Memory bar % — prefer container limit from Engine stats when present */ +function memoryBarPercent(usageBytes, limitBytes) { + const usage = Number(usageBytes) || 0; + let limit = Number(limitBytes) || 0; + // Docker often reports host total RAM as limit when unlimited — still OK for bar + if (limit <= 0) limit = 8 * 1024 * 1024 * 1024; + // If "limit" is huge host RAM and usage is tiny, bar still scales correctly + return Math.min(100, Math.max(0, (usage / limit) * 100)); } /** 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 cpuEl = row.querySelector('.cpu .stats-value') || row.querySelector('td.cpu .stats-value'); + const cpuBar = row.querySelector('.cpu-bar') || row.querySelector('td.cpu .stats-bar'); + const memoryEl = + row.querySelector('.memory .stats-value') || row.querySelector('td.memory .stats-value'); + const memoryBar = + row.querySelector('.memory-bar') || row.querySelector('td.memory .stats-bar'); const ipEl = row.querySelector('.ip-address'); const cpuText = formatCpuDisplay(stats.cpu); if (cpuEl && cpuEl.textContent !== cpuText) { cpuEl.textContent = cpuText; + cpuEl.title = cpuText; } if (cpuBar) { + // Bar is % of one full core-equivalent up to 100% (cap display) const width = Math.min(100, Math.max(0, Number(stats.cpu) || 0)); const w = `${width}%`; if (cpuBar.style.width !== w) cpuBar.style.width = w; + cpuBar.setAttribute('aria-valuenow', String(Math.round(width))); } const memText = formatMemDisplay(stats.memory); if (memoryEl && memoryEl.textContent !== memText) { memoryEl.textContent = memText; + const lim = stats.memoryLimit ? ` / ${formatMemDisplay(stats.memoryLimit)}` : ''; + memoryEl.title = `${memText}${lim}`; } if (memoryBar) { - const maxMemory = 8 * 1024 * 1024 * 1024; - const memoryPercent = Math.min(100, ((Number(stats.memory) || 0) / maxMemory) * 100); + const memoryPercent = memoryBarPercent(stats.memory, stats.memoryLimit); const w = `${memoryPercent}%`; if (memoryBar.style.width !== w) memoryBar.style.width = w; + memoryBar.setAttribute('aria-valuenow', String(Math.round(memoryPercent))); } if (ipEl && stats.ip && stats.ip !== 'No IP Assigned') { @@ -7415,8 +7456,9 @@ function buildContainerRow(container) { 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; + const memWidth = prior + ? memoryBarPercent(prior.memory, prior.memoryLimit) + : 0; row.innerHTML = ` @@ -8219,26 +8261,59 @@ function addActionListeners(row, container) { } -function updateContainerStats(stats) { - if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') { - return; +function findContainerRowById(containerId) { + const list = domCache.containerList || containerList; + if (!list || !containerId) return null; + const id = String(containerId); + // Exact attribute match (escape for CSS selectors when available) + try { + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + const row = list.querySelector(`tr[data-container-id="${esc}"]`); + if (row) return row; + } catch { + // fall through to scan } + // Prefix match (short ids / truncated attrs) + const short = id.slice(0, 12); + for (const tr of list.querySelectorAll('tr[data-container-id]')) { + const rid = tr.dataset.containerId || ''; + if (rid === id || rid.startsWith(short) || id.startsWith(rid.slice(0, 12))) { + return tr; + } + } + return null; +} - const row = - (domCache.containerList || containerList)?.querySelector( - `tr[data-container-id="${stats.id}"]` - ) || null; +function updateContainerStats(stats) { + if (!stats || !stats.id) return; + // Coerce numeric fields (encodings may deliver strings) + const cpu = Number(stats.cpu); + const memory = Number(stats.memory); + if (!Number.isFinite(cpu) || !Number.isFinite(memory)) return; + + const normalized = { + ...stats, + id: stats.id, + cpu, + memory, + memoryLimit: Number(stats.memoryLimit) || smoothedStats[stats.id]?.memoryLimit || 0, + ip: stats.ip, + }; + + const row = findContainerRowById(normalized.id); // Preserve IP from row / prior sample so we never flash "No IP Assigned" const existingIp = row?.querySelector('.ip-address')?.textContent || - smoothedStats[stats.id]?.ip || + smoothedStats[normalized.id]?.ip || null; - if (!stats.ip || stats.ip === 'No IP Assigned') { - if (existingIp && existingIp !== 'No IP Assigned') stats.ip = existingIp; + if (!normalized.ip || normalized.ip === 'No IP Assigned') { + if (existingIp && existingIp !== 'No IP Assigned') normalized.ip = existingIp; } - const smoothed = smoothStats(stats.id, stats); + const smoothed = smoothStats(normalized.id, normalized); + // Keep limit for bar scaling + if (normalized.memoryLimit) smoothed.memoryLimit = normalized.memoryLimit; if (row) updateStatsUI(row, smoothed); // Detail pane (only when this container is open) diff --git a/server/services/stats.js b/server/services/stats.js index bdda4d5..298db67 100644 --- a/server/services/stats.js +++ b/server/services/stats.js @@ -1,5 +1,9 @@ /** * Container stats collection and broadcast. + * + * Docker stats streams are NDJSON and chunks may contain partial lines or + * multiple JSON objects — we buffer and parse line-by-line so CPU/memory + * actually update (JSON.parse on raw chunks often fails silently). */ import { docker, extractIpAddress } from './docker.js' import { peers } from '../core/peer-registry.js' @@ -17,35 +21,135 @@ const containerActivity = new Map() let intervalHandle = null +/** + * Docker Engine CPU % (same idea as `docker stats`). + * First sample often has empty precpu_stats → 0 until the next tick. + */ function calculateCPUPercent(stats) { try { - if ( - !stats?.cpu_stats?.cpu_usage || - !stats?.precpu_stats?.cpu_usage - ) { - return 0.0 + const cpu = stats?.cpu_stats + const precpu = stats?.precpu_stats + if (!cpu?.cpu_usage || !precpu?.cpu_usage) return 0 + + const cpuDelta = (cpu.cpu_usage.total_usage || 0) - (precpu.cpu_usage.total_usage || 0) + const systemDelta = (cpu.system_cpu_usage || 0) - (precpu.system_cpu_usage || 0) + + let cpuCount = cpu.online_cpus || 0 + if (!cpuCount && Array.isArray(cpu.cpu_usage.percpu_usage)) { + cpuCount = cpu.cpu_usage.percpu_usage.length } - const cpuDelta = - stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage - const systemDelta = - stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage - let cpuCount = 1 - if (stats.cpu_stats.online_cpus) { - cpuCount = stats.cpu_stats.online_cpus - } else if (Array.isArray(stats.cpu_stats.cpu_usage.percpu_usage)) { - cpuCount = stats.cpu_stats.cpu_usage.percpu_usage.length + if (!cpuCount) cpuCount = 1 + + if (systemDelta > 0 && cpuDelta >= 0) { + const pct = (cpuDelta / systemDelta) * cpuCount * 100.0 + // Clamp absurd spikes from clock glitches + if (!Number.isFinite(pct) || pct < 0) return 0 + return Math.min(pct, cpuCount * 100) } - if (systemDelta > 0 && cpuDelta > 0) { - return (cpuDelta / systemDelta) * cpuCount * 100.0 - } - return 0.0 + return 0 } catch { - return 0.0 + return 0 + } +} + +/** + * Working-set style memory (closer to `docker stats` MEM USAGE). + */ +function calculateMemoryUsage(stats) { + try { + const mem = stats?.memory_stats + if (!mem) return { usage: 0, limit: 0 } + const usage = Number(mem.usage) || 0 + const limit = Number(mem.limit) || 0 + const s = mem.stats || {} + let working = usage + if (s.inactive_file != null) { + // cgroup v2 + working = Math.max(0, usage - Number(s.inactive_file)) + } else if (s.total_inactive_file != null) { + working = Math.max(0, usage - Number(s.total_inactive_file)) + } else if (s.cache != null) { + // cgroup v1 + working = Math.max(0, usage - Number(s.cache)) + } + return { usage: working, limit } + } catch { + return { usage: 0, limit: 0 } } } function isContainerActive(statsData) { - return statsData.cpu > 1.0 || statsData.memory > 1024 * 1024 + return statsData.cpu > 0.5 || statsData.memory > 512 * 1024 +} + +/** + * Attach a stats stream with NDJSON line buffering. + * @param {object} statsData + * @param {import('dockerode').Container} container + */ +function attachStatsStream(statsData, container) { + let buf = '' + + const onChunk = (chunk) => { + try { + buf += chunk.toString('utf8') + // Docker may send one or more JSON objects per chunk, newline-delimited + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim() + buf = buf.slice(nl + 1) + if (!line) continue + try { + const sample = JSON.parse(line) + statsData.cpu = calculateCPUPercent(sample) + const mem = calculateMemoryUsage(sample) + statsData.memory = mem.usage + statsData.memoryLimit = mem.limit + statsData.updatedAt = Date.now() + } catch { + // incomplete / bad line — drop + } + } + // Also try parse if buffer is a complete single JSON object without trailing NL yet + if (buf.length > 2 && buf.startsWith('{')) { + try { + const sample = JSON.parse(buf) + buf = '' + statsData.cpu = calculateCPUPercent(sample) + const mem = calculateMemoryUsage(sample) + statsData.memory = mem.usage + statsData.memoryLimit = mem.limit + statsData.updatedAt = Date.now() + } catch { + // wait for more data + if (buf.length > 2_000_000) buf = '' // safety + } + } + } catch (err) { + logger.debug('stats chunk parse failed', { id: statsData.id, error: err.message }) + } + } + + container + .stats({ stream: true }) + .then((statsStream) => { + statsData.stream = statsStream + statsStream.on('data', onChunk) + statsStream.on('error', (err) => { + logger.error('Stats stream error', { id: statsData.id, error: err.message }) + statsData.stream = null + }) + statsStream.on('close', () => { + statsData.stream = null + }) + statsStream.on('end', () => { + statsData.stream = null + }) + }) + .catch((err) => { + logger.error('Failed to start stats stream', { id: statsData.id, error: err.message }) + statsData.stream = null + }) } async function initializeContainerStats(containerInfo) { @@ -60,63 +164,80 @@ async function initializeContainerStats(containerInfo) { const statsData = { id: containerInfo.Id, - name: containerInfo.Names[0]?.replace(/^\//, '') || 'Unknown', + name: containerInfo.Names?.[0]?.replace(/^\//, '') || 'Unknown', cpu: 0, memory: 0, + memoryLimit: 0, ip: ipAddress, stream: null, + updatedAt: 0, } - try { - const statsStream = await container.stats({ stream: true }) - statsData.stream = statsStream - statsStream.on('data', (data) => { - try { - const stats = JSON.parse(data.toString()) - statsData.cpu = calculateCPUPercent(stats) - statsData.memory = stats.memory_stats?.usage || 0 - } catch { - // ignore parse errors - } - }) - statsStream.on('error', (err) => { - logger.error('Stats stream error', { id: containerInfo.Id, error: err.message }) - }) - statsStream.on('close', () => { - statsData.stream = null - }) - } catch (err) { - logger.error('Failed to start stats stream', { id: containerInfo.Id, error: err.message }) + // Only running containers have meaningful live stats streams + const state = String(containerInfo.State || '').toLowerCase() + if (state === 'running') { + attachStatsStream(statsData, container) } return statsData } -async function collectContainerStats() { - const currentContainers = await docker.listContainers({ all: true }) - const currentIds = currentContainers.map((c) => c.Id) +function destroyStatsEntry(id) { + const statsData = containerStats[id] + if (!statsData) return + if (statsData.stream) { + try { + statsData.stream.destroy() + } catch { + // ignore + } + } + delete containerStats[id] + statsCache.delete(id) +} - for (const containerInfo of currentContainers) { - if (!containerStats[containerInfo.Id]) { +async function collectContainerStats() { + // Running only for streams; we still list all so stopped ids are cleaned up + const running = await docker.listContainers({ all: false }) + const all = await docker.listContainers({ all: true }) + const runningIds = new Set(running.map((c) => c.Id)) + const allIds = new Set(all.map((c) => c.Id)) + + for (const containerInfo of running) { + const existing = containerStats[containerInfo.Id] + if (!existing) { try { containerStats[containerInfo.Id] = await initializeContainerStats(containerInfo) } catch (err) { logger.error('Failed to init stats', { id: containerInfo.Id, error: err.message }) } + } else if (!existing.stream) { + // Was stopped / stream died — reattach + try { + attachStatsStream(existing, docker.getContainer(containerInfo.Id)) + } catch (err) { + logger.debug('reattach stats failed', { id: containerInfo.Id, error: err.message }) + } } } + // Drop stats for containers that no longer exist, or stop streams for exited ones for (const id of Object.keys(containerStats)) { - if (!currentIds.includes(id)) { - const statsData = containerStats[id] - if (statsData?.stream) { - try { - statsData.stream.destroy() - } catch { - // ignore - } + if (!allIds.has(id)) { + destroyStatsEntry(id) + continue + } + if (!runningIds.has(id) && containerStats[id]?.stream) { + try { + containerStats[id].stream.destroy() + } catch { + // ignore } - delete containerStats[id] + containerStats[id].stream = null + containerStats[id].cpu = 0 + // keep last memory sample or zero for stopped + containerStats[id].cpu = 0 + containerStats[id].memory = 0 } } } @@ -155,16 +276,18 @@ export function startStatsBroadcast() { const statsObj = { id: statsData.id, name: statsData.name, - cpu: statsData.cpu, - memory: statsData.memory, + cpu: Number(statsData.cpu) || 0, + memory: Number(statsData.memory) || 0, + memoryLimit: Number(statsData.memoryLimit) || 0, ip: statsData.ip, } statsCache.set(containerId, { data: statsObj, timestamp: now }) aggregatedStats.push(statsObj) - recordSample(containerId, { cpu: statsData.cpu, memory: statsData.memory }) + recordSample(containerId, { cpu: statsObj.cpu, memory: statsObj.memory }) } pruneMissing(Object.keys(containerStats)) + // Always broadcast when we have containers — even zeros so UI can show 0.00% not "—" if (aggregatedStats.length > 0) { peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats }) lastBroadcast = now @@ -200,13 +323,7 @@ export function stopStatsBroadcast() { clearInterval(intervalHandle) intervalHandle = null } - for (const statsData of Object.values(containerStats)) { - if (statsData?.stream) { - try { - statsData.stream.destroy() - } catch { - // ignore - } - } + for (const id of Object.keys(containerStats)) { + destroyStatsEntry(id) } } diff --git a/ui/modern.css b/ui/modern.css index 8379bbf..3881231 100644 --- a/ui/modern.css +++ b/ui/modern.css @@ -1948,11 +1948,12 @@ textarea::placeholder { #containers-view table td:nth-child(4) { width: 7.5rem; } +/* CPU + Memory — always visible; compact stacked meter */ #containers-view table th:nth-child(5), #containers-view table td:nth-child(5), #containers-view table th:nth-child(6), #containers-view table td:nth-child(6) { - width: 9rem; + width: 7.5rem; } #containers-view table th:nth-child(7), #containers-view table td:nth-child(7) { @@ -1963,6 +1964,11 @@ textarea::placeholder { width: 10.5rem; } +#containers-view table td.cpu, +#containers-view table td.memory { + overflow: visible; +} + .container-name-display, .container-name-link { display: block; @@ -1979,22 +1985,58 @@ textarea::placeholder { white-space: nowrap; } +/* Compact vertical stats meter — fits fixed table columns without clipping */ .stats-container { - min-width: 0; - max-width: 100%; - gap: 4px; + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + justify-content: center; + gap: 4px !important; + min-width: 0 !important; + max-width: 100% !important; + width: 100%; } -.stats-container .stats-value { - font-size: 11.5px; +.stats-container .stats-value, +.stats-value { + min-width: 0 !important; + width: 100%; + font-size: 12px !important; + font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; + color: var(--text-primary); } .stats-bar-container { + flex: 0 0 auto !important; + min-width: 0 !important; + width: 100% !important; + max-width: 100% !important; + height: 6px !important; + background: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; + position: relative; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.35); +} + +.stats-bar { + height: 100%; min-width: 0; - width: 100%; - max-width: 100%; + border-radius: 999px; + transition: width 0.35s ease; +} + +.cpu-bar { + background: linear-gradient(90deg, var(--accent-success), var(--accent-warning), var(--accent-danger)); +} + +.memory-bar { + background: linear-gradient(90deg, var(--accent-info), var(--accent-warning), var(--accent-danger)); } /* Images / networks / volumes / stacks — flexible cols, truncate names */ @@ -2025,29 +2067,42 @@ textarea::placeholder { width: 22%; } -/* Collapse secondary columns on narrower viewports */ +/* + * Collapse secondary columns on narrower viewports. + * Keep CPU (5) and Memory (6) visible — only drop Image / IP first. + */ @media (max-width: 1280px) { #containers-view table th:nth-child(7), #containers-view table td:nth-child(7) { /* IP */ display: none; } + #containers-view table th:nth-child(2), + #containers-view table td:nth-child(2) { + width: 18%; + } #containers-view table th:nth-child(3), #containers-view table td:nth-child(3) { - width: 22%; + width: 20%; } } @media (max-width: 1100px) { - #containers-view table th:nth-child(6), - #containers-view table td:nth-child(6) { - /* Memory */ - display: none; - } .table td, .table th { padding: 8px 8px; } + #containers-view table th:nth-child(3), + #containers-view table td:nth-child(3) { + /* Image */ + display: none; + } + #containers-view table th:nth-child(5), + #containers-view table td:nth-child(5), + #containers-view table th:nth-child(6), + #containers-view table td:nth-child(6) { + width: 6.5rem; + } #containers-view table th:nth-child(8), #containers-view table td:nth-child(8) { width: 9.5rem; @@ -2056,14 +2111,10 @@ textarea::placeholder { @media (max-width: 900px) { #containers-view table th:nth-child(5), - #containers-view table td:nth-child(5) { - /* CPU */ - display: none; - } - #containers-view table th:nth-child(3), - #containers-view table td:nth-child(3) { - /* Image */ - display: none; + #containers-view table td:nth-child(5), + #containers-view table th:nth-child(6), + #containers-view table td:nth-child(6) { + width: 5.75rem; } .view-toolbar .row > [class*='col-'] { flex: 1 1 100%; diff --git a/ui/styles.css b/ui/styles.css index 1bfb67d..8978aa9 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -2181,28 +2181,31 @@ border-left-color: var(--accent-danger); } - /* Stats visualization */ + /* Stats visualization — compact column layout (overridden further in modern.css) */ .stats-container { display: flex; - align-items: center; - gap: var(--spacing-sm); - min-width: 140px; + flex-direction: column; + align-items: stretch; + gap: 4px; + min-width: 0; + width: 100%; } .stats-value { - min-width: 55px; + min-width: 0; font-weight: 600; - font-size: 13px; + font-size: 12px; color: var(--text-primary); } .stats-bar-container { - flex: 1; - height: 10px; + flex: 0 0 auto; + height: 6px; background: var(--bg-tertiary); - border-radius: var(--border-radius-sm); + border-radius: 999px; overflow: hidden; - min-width: 80px; + min-width: 0; + width: 100%; position: relative; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); }