Fix CPU and memory usage display in containers table
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:
Raven Scott
2026-07-11 17:26:49 -04:00
parent c54a254c61
commit ac8419029d
4 changed files with 377 additions and 131 deletions
+105 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }