Add advanced container stats tab with timeframes and I/O charts.
Release rolling / release (push) Has been cancelled
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:
@@ -304,8 +304,18 @@ export function registerContainerHandlers(session) {
|
||||
|
||||
session.respond('getStatsHistory', async (args) => {
|
||||
if (!args.id) throw new Error('container id required')
|
||||
const data = getHistory(args.id, { limit: args.limit })
|
||||
return { success: true, type: 'statsHistory', id: args.id, data }
|
||||
const data = getHistory(args.id, {
|
||||
limit: args.limit,
|
||||
since: args.since,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
type: 'statsHistory',
|
||||
id: args.id,
|
||||
data,
|
||||
since: args.since || null,
|
||||
count: data.length,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('getContainerLogs', async (args) => {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* Ring-buffer stats history for per-container charts.
|
||||
* Samples include CPU, memory, and optional network / block I/O rates.
|
||||
*/
|
||||
const DEFAULT_MAX_POINTS = Number(process.env.PEARDOCK_STATS_HISTORY_POINTS) || 120
|
||||
const DEFAULT_MAX_POINTS = Number(process.env.PEARDOCK_STATS_HISTORY_POINTS) || 900
|
||||
|
||||
/** @type {Map<string, Array<{ t: number, cpu: number, memory: number }>>} */
|
||||
/** @type {Map<string, Array<object>>} */
|
||||
const history = new Map()
|
||||
|
||||
/**
|
||||
* @param {string} containerId
|
||||
* @param {{ cpu: number, memory: number }} sample
|
||||
* @param {{
|
||||
* cpu: number,
|
||||
* memory: number,
|
||||
* memoryLimit?: number,
|
||||
* netRxRate?: number,
|
||||
* netTxRate?: number,
|
||||
* blkReadRate?: number,
|
||||
* blkWriteRate?: number,
|
||||
* }} sample
|
||||
*/
|
||||
export function recordSample(containerId, sample) {
|
||||
if (!containerId) return
|
||||
@@ -21,18 +30,30 @@ export function recordSample(containerId, sample) {
|
||||
t: Date.now(),
|
||||
cpu: Number(sample.cpu) || 0,
|
||||
memory: Number(sample.memory) || 0,
|
||||
memoryLimit: Number(sample.memoryLimit) || 0,
|
||||
netRxRate: Number(sample.netRxRate) || 0,
|
||||
netTxRate: Number(sample.netTxRate) || 0,
|
||||
blkReadRate: Number(sample.blkReadRate) || 0,
|
||||
blkWriteRate: Number(sample.blkWriteRate) || 0,
|
||||
})
|
||||
while (series.length > DEFAULT_MAX_POINTS) series.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} containerId
|
||||
* @param {{ limit?: number }} [opts]
|
||||
* @param {{ limit?: number, since?: number }} [opts]
|
||||
*/
|
||||
export function getHistory(containerId, opts = {}) {
|
||||
const series = history.get(containerId) || []
|
||||
const limit = Math.min(Number(opts.limit) || DEFAULT_MAX_POINTS, DEFAULT_MAX_POINTS)
|
||||
return series.slice(-limit)
|
||||
let series = history.get(containerId) || []
|
||||
const since = Number(opts.since)
|
||||
if (Number.isFinite(since) && since > 0) {
|
||||
series = series.filter((p) => p.t >= since)
|
||||
}
|
||||
const cap = Math.min(
|
||||
Number(opts.limit) || DEFAULT_MAX_POINTS,
|
||||
DEFAULT_MAX_POINTS
|
||||
)
|
||||
return series.slice(-cap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,3 +69,7 @@ export function pruneMissing(activeIds) {
|
||||
export function clearHistory() {
|
||||
history.clear()
|
||||
}
|
||||
|
||||
export function maxHistoryPoints() {
|
||||
return DEFAULT_MAX_POINTS
|
||||
}
|
||||
|
||||
+98
-12
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user