Add advanced container stats tab with timeframes and I/O charts.
Release rolling / release (push) Has been cancelled

Expand server history samples with network/block rates and longer
retention, and rebuild the details Stats tab with live KPIs, 1m–30m
windows, auto-scale, pause, CSV export, and CPU/memory/net/disk charts.
This commit is contained in:
Raven Scott
2026-07-13 18:13:27 -04:00
parent 270b38af15
commit b2e04caabc
8 changed files with 1216 additions and 262 deletions
+98 -12
View File
@@ -78,6 +78,67 @@ function calculateMemoryUsage(stats) {
}
}
function sumNetworkBytes(stats) {
let rx = 0
let tx = 0
const nets = stats?.networks
if (!nets || typeof nets !== 'object') return { rx, tx }
for (const n of Object.values(nets)) {
rx += Number(n?.rx_bytes) || 0
tx += Number(n?.tx_bytes) || 0
}
return { rx, tx }
}
function sumBlkioBytes(stats) {
let read = 0
let write = 0
const arr =
stats?.blkio_stats?.io_service_bytes_recursive ||
stats?.blkio_stats?.io_service_bytes ||
[]
if (!Array.isArray(arr)) return { read, write }
for (const e of arr) {
const op = String(e?.op || '').toLowerCase()
const v = Number(e?.value) || 0
if (op === 'read') read += v
else if (op === 'write') write += v
}
return { read, write }
}
/**
* Bytes/sec rates from consecutive cumulative counters.
* @param {object} statsData
* @param {{ rx: number, tx: number }} net
* @param {{ read: number, write: number }} blk
* @param {number} now
*/
function updateIoRates(statsData, net, blk, now) {
const prevT = statsData._ioTs || 0
const dt = prevT ? Math.max(0.001, (now - prevT) / 1000) : 0
if (dt > 0 && statsData._netRx != null) {
statsData.netRxRate = Math.max(0, (net.rx - statsData._netRx) / dt)
statsData.netTxRate = Math.max(0, (net.tx - statsData._netTx) / dt)
statsData.blkReadRate = Math.max(0, (blk.read - statsData._blkRead) / dt)
statsData.blkWriteRate = Math.max(0, (blk.write - statsData._blkWrite) / dt)
} else {
statsData.netRxRate = statsData.netRxRate || 0
statsData.netTxRate = statsData.netTxRate || 0
statsData.blkReadRate = statsData.blkReadRate || 0
statsData.blkWriteRate = statsData.blkWriteRate || 0
}
statsData._netRx = net.rx
statsData._netTx = net.tx
statsData._blkRead = blk.read
statsData._blkWrite = blk.write
statsData._ioTs = now
statsData.netRxTotal = net.rx
statsData.netTxTotal = net.tx
statsData.blkReadTotal = blk.read
statsData.blkWriteTotal = blk.write
}
function isContainerActive(statsData) {
return statsData.cpu > 0.5 || statsData.memory > 512 * 1024
}
@@ -100,12 +161,7 @@ function attachStatsStream(statsData, container) {
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()
applyDockerStatsSample(statsData, JSON.parse(line))
} catch {
// incomplete / bad line — drop
}
@@ -115,11 +171,7 @@ function attachStatsStream(statsData, container) {
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()
applyDockerStatsSample(statsData, sample)
} catch {
// wait for more data
if (buf.length > 2_000_000) buf = '' // safety
@@ -168,6 +220,14 @@ async function initializeContainerStats(containerInfo) {
cpu: 0,
memory: 0,
memoryLimit: 0,
netRxRate: 0,
netTxRate: 0,
blkReadRate: 0,
blkWriteRate: 0,
netRxTotal: 0,
netTxTotal: 0,
blkReadTotal: 0,
blkWriteTotal: 0,
ip: ipAddress,
stream: null,
updatedAt: 0,
@@ -182,6 +242,16 @@ async function initializeContainerStats(containerInfo) {
return statsData
}
function applyDockerStatsSample(statsData, sample) {
const now = Date.now()
statsData.cpu = calculateCPUPercent(sample)
const mem = calculateMemoryUsage(sample)
statsData.memory = mem.usage
statsData.memoryLimit = mem.limit
updateIoRates(statsData, sumNetworkBytes(sample), sumBlkioBytes(sample), now)
statsData.updatedAt = now
}
function destroyStatsEntry(id) {
const statsData = containerStats[id]
if (!statsData) return
@@ -279,11 +349,27 @@ export function startStatsBroadcast() {
cpu: Number(statsData.cpu) || 0,
memory: Number(statsData.memory) || 0,
memoryLimit: Number(statsData.memoryLimit) || 0,
netRxRate: Number(statsData.netRxRate) || 0,
netTxRate: Number(statsData.netTxRate) || 0,
blkReadRate: Number(statsData.blkReadRate) || 0,
blkWriteRate: Number(statsData.blkWriteRate) || 0,
netRxTotal: Number(statsData.netRxTotal) || 0,
netTxTotal: Number(statsData.netTxTotal) || 0,
blkReadTotal: Number(statsData.blkReadTotal) || 0,
blkWriteTotal: Number(statsData.blkWriteTotal) || 0,
ip: statsData.ip,
}
statsCache.set(containerId, { data: statsObj, timestamp: now })
aggregatedStats.push(statsObj)
recordSample(containerId, { cpu: statsObj.cpu, memory: statsObj.memory })
recordSample(containerId, {
cpu: statsObj.cpu,
memory: statsObj.memory,
memoryLimit: statsObj.memoryLimit,
netRxRate: statsObj.netRxRate,
netTxRate: statsObj.netTxRate,
blkReadRate: statsObj.blkReadRate,
blkWriteRate: statsObj.blkWriteRate,
})
}
pruneMissing(Object.keys(containerStats))