Fix CPU and memory usage display in containers table
Release rolling / release (push) Successful in 9m3s
Release rolling / release (push) Successful in 9m3s
Parse Docker stats streams as NDJSON so samples actually land, ship memory limit for accurate bars, keep CPU/Memory columns visible with a compact stacked meter, and match stats updates to table rows reliably.
This commit is contained in:
@@ -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) {
|
function smoothStats(containerId, newStats, smoothingFactor = 0.2) {
|
||||||
if (!smoothedStats[containerId]) {
|
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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cpu = Number(newStats.cpu);
|
||||||
|
const memory = Number(newStats.memory);
|
||||||
|
if (Number.isFinite(cpu)) {
|
||||||
smoothedStats[containerId].cpu =
|
smoothedStats[containerId].cpu =
|
||||||
smoothedStats[containerId].cpu * (1 - smoothingFactor) +
|
smoothedStats[containerId].cpu * (1 - smoothingFactor) + cpu * smoothingFactor;
|
||||||
newStats.cpu * smoothingFactor;
|
}
|
||||||
|
if (Number.isFinite(memory)) {
|
||||||
smoothedStats[containerId].memory =
|
smoothedStats[containerId].memory =
|
||||||
smoothedStats[containerId].memory * (1 - smoothingFactor) +
|
smoothedStats[containerId].memory * (1 - smoothingFactor) + memory * 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;
|
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
|
// Store historical data for charts
|
||||||
if (!historicalStats[containerId]) {
|
if (!historicalStats[containerId]) {
|
||||||
@@ -7291,43 +7303,72 @@ function containerStatusClass(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCpuDisplay(cpu) {
|
function formatCpuDisplay(cpu) {
|
||||||
if (cpu == null || Number.isNaN(cpu)) return '—';
|
if (cpu == null || Number.isNaN(Number(cpu))) return '—';
|
||||||
return `${Number(cpu).toFixed(2)}%`;
|
const n = Number(cpu);
|
||||||
|
if (n < 0.01 && n > 0) return '<0.01%';
|
||||||
|
return `${n.toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMemDisplay(memoryBytes) {
|
function formatMemDisplay(memoryBytes) {
|
||||||
if (memoryBytes == null || Number.isNaN(memoryBytes)) return '—';
|
if (memoryBytes == null || Number.isNaN(Number(memoryBytes))) return '—';
|
||||||
return `${(Number(memoryBytes) / (1024 * 1024)).toFixed(2)} MB`;
|
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 */
|
/** Apply CPU/memory/IP to a row only when values actually change */
|
||||||
function applyStatsToRow(row, stats) {
|
function applyStatsToRow(row, stats) {
|
||||||
if (!row || !stats) return;
|
if (!row || !stats) return;
|
||||||
const cpuEl = row.querySelector('.cpu .stats-value');
|
const cpuEl = row.querySelector('.cpu .stats-value') || row.querySelector('td.cpu .stats-value');
|
||||||
const cpuBar = row.querySelector('.cpu-bar');
|
const cpuBar = row.querySelector('.cpu-bar') || row.querySelector('td.cpu .stats-bar');
|
||||||
const memoryEl = row.querySelector('.memory .stats-value');
|
const memoryEl =
|
||||||
const memoryBar = row.querySelector('.memory-bar');
|
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 ipEl = row.querySelector('.ip-address');
|
||||||
|
|
||||||
const cpuText = formatCpuDisplay(stats.cpu);
|
const cpuText = formatCpuDisplay(stats.cpu);
|
||||||
if (cpuEl && cpuEl.textContent !== cpuText) {
|
if (cpuEl && cpuEl.textContent !== cpuText) {
|
||||||
cpuEl.textContent = cpuText;
|
cpuEl.textContent = cpuText;
|
||||||
|
cpuEl.title = cpuText;
|
||||||
}
|
}
|
||||||
if (cpuBar) {
|
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 width = Math.min(100, Math.max(0, Number(stats.cpu) || 0));
|
||||||
const w = `${width}%`;
|
const w = `${width}%`;
|
||||||
if (cpuBar.style.width !== w) cpuBar.style.width = w;
|
if (cpuBar.style.width !== w) cpuBar.style.width = w;
|
||||||
|
cpuBar.setAttribute('aria-valuenow', String(Math.round(width)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const memText = formatMemDisplay(stats.memory);
|
const memText = formatMemDisplay(stats.memory);
|
||||||
if (memoryEl && memoryEl.textContent !== memText) {
|
if (memoryEl && memoryEl.textContent !== memText) {
|
||||||
memoryEl.textContent = memText;
|
memoryEl.textContent = memText;
|
||||||
|
const lim = stats.memoryLimit ? ` / ${formatMemDisplay(stats.memoryLimit)}` : '';
|
||||||
|
memoryEl.title = `${memText}${lim}`;
|
||||||
}
|
}
|
||||||
if (memoryBar) {
|
if (memoryBar) {
|
||||||
const maxMemory = 8 * 1024 * 1024 * 1024;
|
const memoryPercent = memoryBarPercent(stats.memory, stats.memoryLimit);
|
||||||
const memoryPercent = Math.min(100, ((Number(stats.memory) || 0) / maxMemory) * 100);
|
|
||||||
const w = `${memoryPercent}%`;
|
const w = `${memoryPercent}%`;
|
||||||
if (memoryBar.style.width !== w) memoryBar.style.width = w;
|
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') {
|
if (ipEl && stats.ip && stats.ip !== 'No IP Assigned') {
|
||||||
@@ -7415,8 +7456,9 @@ function buildContainerRow(container) {
|
|||||||
const cpuText = prior ? formatCpuDisplay(prior.cpu) : '—';
|
const cpuText = prior ? formatCpuDisplay(prior.cpu) : '—';
|
||||||
const memText = prior ? formatMemDisplay(prior.memory) : '—';
|
const memText = prior ? formatMemDisplay(prior.memory) : '—';
|
||||||
const cpuWidth = prior ? Math.min(100, Math.max(0, prior.cpu || 0)) : 0;
|
const cpuWidth = prior ? Math.min(100, Math.max(0, prior.cpu || 0)) : 0;
|
||||||
const maxMemory = 8 * 1024 * 1024 * 1024;
|
const memWidth = prior
|
||||||
const memWidth = prior ? Math.min(100, ((prior.memory || 0) / maxMemory) * 100) : 0;
|
? memoryBarPercent(prior.memory, prior.memoryLimit)
|
||||||
|
: 0;
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>
|
<td>
|
||||||
@@ -8219,26 +8261,59 @@ function addActionListeners(row, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function updateContainerStats(stats) {
|
function findContainerRowById(containerId) {
|
||||||
if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') {
|
const list = domCache.containerList || containerList;
|
||||||
return;
|
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 =
|
function updateContainerStats(stats) {
|
||||||
(domCache.containerList || containerList)?.querySelector(
|
if (!stats || !stats.id) return;
|
||||||
`tr[data-container-id="${stats.id}"]`
|
// Coerce numeric fields (encodings may deliver strings)
|
||||||
) || null;
|
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"
|
// Preserve IP from row / prior sample so we never flash "No IP Assigned"
|
||||||
const existingIp =
|
const existingIp =
|
||||||
row?.querySelector('.ip-address')?.textContent ||
|
row?.querySelector('.ip-address')?.textContent ||
|
||||||
smoothedStats[stats.id]?.ip ||
|
smoothedStats[normalized.id]?.ip ||
|
||||||
null;
|
null;
|
||||||
if (!stats.ip || stats.ip === 'No IP Assigned') {
|
if (!normalized.ip || normalized.ip === 'No IP Assigned') {
|
||||||
if (existingIp && existingIp !== 'No IP Assigned') stats.ip = existingIp;
|
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);
|
if (row) updateStatsUI(row, smoothed);
|
||||||
|
|
||||||
// Detail pane (only when this container is open)
|
// Detail pane (only when this container is open)
|
||||||
|
|||||||
+186
-69
@@ -1,5 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Container stats collection and broadcast.
|
* 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 { docker, extractIpAddress } from './docker.js'
|
||||||
import { peers } from '../core/peer-registry.js'
|
import { peers } from '../core/peer-registry.js'
|
||||||
@@ -17,35 +21,135 @@ const containerActivity = new Map()
|
|||||||
|
|
||||||
let intervalHandle = null
|
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) {
|
function calculateCPUPercent(stats) {
|
||||||
try {
|
try {
|
||||||
if (
|
const cpu = stats?.cpu_stats
|
||||||
!stats?.cpu_stats?.cpu_usage ||
|
const precpu = stats?.precpu_stats
|
||||||
!stats?.precpu_stats?.cpu_usage
|
if (!cpu?.cpu_usage || !precpu?.cpu_usage) return 0
|
||||||
) {
|
|
||||||
return 0.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 =
|
if (!cpuCount) cpuCount = 1
|
||||||
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage
|
|
||||||
const systemDelta =
|
if (systemDelta > 0 && cpuDelta >= 0) {
|
||||||
stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage
|
const pct = (cpuDelta / systemDelta) * cpuCount * 100.0
|
||||||
let cpuCount = 1
|
// Clamp absurd spikes from clock glitches
|
||||||
if (stats.cpu_stats.online_cpus) {
|
if (!Number.isFinite(pct) || pct < 0) return 0
|
||||||
cpuCount = stats.cpu_stats.online_cpus
|
return Math.min(pct, cpuCount * 100)
|
||||||
} else if (Array.isArray(stats.cpu_stats.cpu_usage.percpu_usage)) {
|
|
||||||
cpuCount = stats.cpu_stats.cpu_usage.percpu_usage.length
|
|
||||||
}
|
}
|
||||||
if (systemDelta > 0 && cpuDelta > 0) {
|
return 0
|
||||||
return (cpuDelta / systemDelta) * cpuCount * 100.0
|
|
||||||
}
|
|
||||||
return 0.0
|
|
||||||
} catch {
|
} 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) {
|
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) {
|
async function initializeContainerStats(containerInfo) {
|
||||||
@@ -60,56 +164,28 @@ async function initializeContainerStats(containerInfo) {
|
|||||||
|
|
||||||
const statsData = {
|
const statsData = {
|
||||||
id: containerInfo.Id,
|
id: containerInfo.Id,
|
||||||
name: containerInfo.Names[0]?.replace(/^\//, '') || 'Unknown',
|
name: containerInfo.Names?.[0]?.replace(/^\//, '') || 'Unknown',
|
||||||
cpu: 0,
|
cpu: 0,
|
||||||
memory: 0,
|
memory: 0,
|
||||||
|
memoryLimit: 0,
|
||||||
ip: ipAddress,
|
ip: ipAddress,
|
||||||
stream: null,
|
stream: null,
|
||||||
|
updatedAt: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Only running containers have meaningful live stats streams
|
||||||
const statsStream = await container.stats({ stream: true })
|
const state = String(containerInfo.State || '').toLowerCase()
|
||||||
statsData.stream = statsStream
|
if (state === 'running') {
|
||||||
statsStream.on('data', (data) => {
|
attachStatsStream(statsData, container)
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statsData
|
return statsData
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectContainerStats() {
|
function destroyStatsEntry(id) {
|
||||||
const currentContainers = await docker.listContainers({ all: true })
|
|
||||||
const currentIds = currentContainers.map((c) => c.Id)
|
|
||||||
|
|
||||||
for (const containerInfo of currentContainers) {
|
|
||||||
if (!containerStats[containerInfo.Id]) {
|
|
||||||
try {
|
|
||||||
containerStats[containerInfo.Id] = await initializeContainerStats(containerInfo)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to init stats', { id: containerInfo.Id, error: err.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of Object.keys(containerStats)) {
|
|
||||||
if (!currentIds.includes(id)) {
|
|
||||||
const statsData = containerStats[id]
|
const statsData = containerStats[id]
|
||||||
if (statsData?.stream) {
|
if (!statsData) return
|
||||||
|
if (statsData.stream) {
|
||||||
try {
|
try {
|
||||||
statsData.stream.destroy()
|
statsData.stream.destroy()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,6 +193,51 @@ async function collectContainerStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete containerStats[id]
|
delete containerStats[id]
|
||||||
|
statsCache.delete(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 (!allIds.has(id)) {
|
||||||
|
destroyStatsEntry(id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!runningIds.has(id) && containerStats[id]?.stream) {
|
||||||
|
try {
|
||||||
|
containerStats[id].stream.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
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 = {
|
const statsObj = {
|
||||||
id: statsData.id,
|
id: statsData.id,
|
||||||
name: statsData.name,
|
name: statsData.name,
|
||||||
cpu: statsData.cpu,
|
cpu: Number(statsData.cpu) || 0,
|
||||||
memory: statsData.memory,
|
memory: Number(statsData.memory) || 0,
|
||||||
|
memoryLimit: Number(statsData.memoryLimit) || 0,
|
||||||
ip: statsData.ip,
|
ip: statsData.ip,
|
||||||
}
|
}
|
||||||
statsCache.set(containerId, { data: statsObj, timestamp: now })
|
statsCache.set(containerId, { data: statsObj, timestamp: now })
|
||||||
aggregatedStats.push(statsObj)
|
aggregatedStats.push(statsObj)
|
||||||
recordSample(containerId, { cpu: statsData.cpu, memory: statsData.memory })
|
recordSample(containerId, { cpu: statsObj.cpu, memory: statsObj.memory })
|
||||||
}
|
}
|
||||||
pruneMissing(Object.keys(containerStats))
|
pruneMissing(Object.keys(containerStats))
|
||||||
|
|
||||||
|
// Always broadcast when we have containers — even zeros so UI can show 0.00% not "—"
|
||||||
if (aggregatedStats.length > 0) {
|
if (aggregatedStats.length > 0) {
|
||||||
peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats })
|
peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats })
|
||||||
lastBroadcast = now
|
lastBroadcast = now
|
||||||
@@ -200,13 +323,7 @@ export function stopStatsBroadcast() {
|
|||||||
clearInterval(intervalHandle)
|
clearInterval(intervalHandle)
|
||||||
intervalHandle = null
|
intervalHandle = null
|
||||||
}
|
}
|
||||||
for (const statsData of Object.values(containerStats)) {
|
for (const id of Object.keys(containerStats)) {
|
||||||
if (statsData?.stream) {
|
destroyStatsEntry(id)
|
||||||
try {
|
|
||||||
statsData.stream.destroy()
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-23
@@ -1948,11 +1948,12 @@ textarea::placeholder {
|
|||||||
#containers-view table td:nth-child(4) {
|
#containers-view table td:nth-child(4) {
|
||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
}
|
}
|
||||||
|
/* CPU + Memory — always visible; compact stacked meter */
|
||||||
#containers-view table th:nth-child(5),
|
#containers-view table th:nth-child(5),
|
||||||
#containers-view table td:nth-child(5),
|
#containers-view table td:nth-child(5),
|
||||||
#containers-view table th:nth-child(6),
|
#containers-view table th:nth-child(6),
|
||||||
#containers-view table td: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 th:nth-child(7),
|
||||||
#containers-view table td:nth-child(7) {
|
#containers-view table td:nth-child(7) {
|
||||||
@@ -1963,6 +1964,11 @@ textarea::placeholder {
|
|||||||
width: 10.5rem;
|
width: 10.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#containers-view table td.cpu,
|
||||||
|
#containers-view table td.memory {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.container-name-display,
|
.container-name-display,
|
||||||
.container-name-link {
|
.container-name-link {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1979,22 +1985,58 @@ textarea::placeholder {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact vertical stats meter — fits fixed table columns without clipping */
|
||||||
.stats-container {
|
.stats-container {
|
||||||
min-width: 0;
|
display: flex !important;
|
||||||
max-width: 100%;
|
flex-direction: column !important;
|
||||||
gap: 4px;
|
align-items: stretch !important;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-container .stats-value {
|
.stats-container .stats-value,
|
||||||
font-size: 11.5px;
|
.stats-value {
|
||||||
|
min-width: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 650;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar-container {
|
.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;
|
min-width: 0;
|
||||||
width: 100%;
|
border-radius: 999px;
|
||||||
max-width: 100%;
|
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 */
|
/* Images / networks / volumes / stacks — flexible cols, truncate names */
|
||||||
@@ -2025,29 +2067,42 @@ textarea::placeholder {
|
|||||||
width: 22%;
|
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) {
|
@media (max-width: 1280px) {
|
||||||
#containers-view table th:nth-child(7),
|
#containers-view table th:nth-child(7),
|
||||||
#containers-view table td:nth-child(7) {
|
#containers-view table td:nth-child(7) {
|
||||||
/* IP */
|
/* IP */
|
||||||
display: none;
|
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 th:nth-child(3),
|
||||||
#containers-view table td:nth-child(3) {
|
#containers-view table td:nth-child(3) {
|
||||||
width: 22%;
|
width: 20%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
#containers-view table th:nth-child(6),
|
|
||||||
#containers-view table td:nth-child(6) {
|
|
||||||
/* Memory */
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.table td,
|
.table td,
|
||||||
.table th {
|
.table th {
|
||||||
padding: 8px 8px;
|
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 th:nth-child(8),
|
||||||
#containers-view table td:nth-child(8) {
|
#containers-view table td:nth-child(8) {
|
||||||
width: 9.5rem;
|
width: 9.5rem;
|
||||||
@@ -2056,14 +2111,10 @@ textarea::placeholder {
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
#containers-view table th:nth-child(5),
|
#containers-view table th:nth-child(5),
|
||||||
#containers-view table td:nth-child(5) {
|
#containers-view table td:nth-child(5),
|
||||||
/* CPU */
|
#containers-view table th:nth-child(6),
|
||||||
display: none;
|
#containers-view table td:nth-child(6) {
|
||||||
}
|
width: 5.75rem;
|
||||||
#containers-view table th:nth-child(3),
|
|
||||||
#containers-view table td:nth-child(3) {
|
|
||||||
/* Image */
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
.view-toolbar .row > [class*='col-'] {
|
.view-toolbar .row > [class*='col-'] {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
|
|||||||
+13
-10
@@ -2181,28 +2181,31 @@
|
|||||||
border-left-color: var(--accent-danger);
|
border-left-color: var(--accent-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats visualization */
|
/* Stats visualization — compact column layout (overridden further in modern.css) */
|
||||||
.stats-container {
|
.stats-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
align-items: stretch;
|
||||||
min-width: 140px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-value {
|
.stats-value {
|
||||||
min-width: 55px;
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar-container {
|
.stats-bar-container {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
height: 10px;
|
height: 6px;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 80px;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user