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) {
|
||||
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 = `
|
||||
<td>
|
||||
@@ -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)
|
||||
|
||||
+183
-66
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+74
-23
@@ -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%;
|
||||
|
||||
+13
-10
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user