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
+598 -218
View File
@@ -259,7 +259,48 @@ function startStatsInterval() {
}
const smoothedStats = {}; // Container-specific smoothing storage
const historicalStats = {}; // Container-specific historical stats for charts
const MAX_HISTORY_POINTS = 60; // Keep last 60 data points (5 minutes at 5s intervals)
const MAX_HISTORY_POINTS = 900; // ~30m at 2s broadcast interval
/** Advanced container-details stats tab state */
const detailsStatsState = {
timeframeSec: 60,
paused: false,
autoscale: true,
containerId: null,
wired: false,
charts: { cpu: null, memory: null, net: null, disk: null },
};
function emptyHistorySeries() {
return {
timestamps: [],
cpu: [],
memory: [],
memoryLimit: [],
netRxRate: [],
netTxRate: [],
blkReadRate: [],
blkWriteRate: [],
};
}
function ensureHistorySeries(containerId) {
if (!historicalStats[containerId]) {
historicalStats[containerId] = emptyHistorySeries();
}
const h = historicalStats[containerId];
for (const k of Object.keys(emptyHistorySeries())) {
if (!Array.isArray(h[k])) h[k] = [];
}
return h;
}
function smoothIoField(obj, key, value, factor) {
const n = Number(value);
if (!Number.isFinite(n)) return;
if (!Number.isFinite(obj[key])) obj[key] = n;
else obj[key] = obj[key] * (1 - factor) + n * factor;
}
function smoothStats(containerId, newStats, smoothingFactor = 0.2) {
if (!smoothedStats[containerId]) {
@@ -267,56 +308,69 @@ function smoothStats(containerId, newStats, smoothingFactor = 0.2) {
cpu: 0,
memory: 0,
memoryLimit: 0,
netRxRate: 0,
netTxRate: 0,
blkReadRate: 0,
blkWriteRate: 0,
ip: newStats.ip || 'No IP Assigned',
};
}
const s = smoothedStats[containerId];
const cpu = Number(newStats.cpu);
const memory = Number(newStats.memory);
if (Number.isFinite(cpu)) {
smoothedStats[containerId].cpu =
smoothedStats[containerId].cpu * (1 - smoothingFactor) + cpu * smoothingFactor;
s.cpu = s.cpu * (1 - smoothingFactor) + cpu * smoothingFactor;
}
if (Number.isFinite(memory)) {
smoothedStats[containerId].memory =
smoothedStats[containerId].memory * (1 - smoothingFactor) + memory * smoothingFactor;
s.memory = s.memory * (1 - smoothingFactor) + memory * smoothingFactor;
}
// Preserve the latest IP address and memory limit (for bar scale)
smoothedStats[containerId].ip = newStats.ip || smoothedStats[containerId].ip;
s.ip = newStats.ip || s.ip;
const lim = Number(newStats.memoryLimit);
if (Number.isFinite(lim) && lim > 0) {
smoothedStats[containerId].memoryLimit = lim;
}
if (Number.isFinite(lim) && lim > 0) s.memoryLimit = lim;
// Store historical data for charts
if (!historicalStats[containerId]) {
historicalStats[containerId] = {
timestamps: [],
cpu: [],
memory: []
};
}
const history = historicalStats[containerId];
const now = new Date();
// Rates: light smoothing so charts stay readable
smoothIoField(s, 'netRxRate', newStats.netRxRate, 0.35);
smoothIoField(s, 'netTxRate', newStats.netTxRate, 0.35);
smoothIoField(s, 'blkReadRate', newStats.blkReadRate, 0.35);
smoothIoField(s, 'blkWriteRate', newStats.blkWriteRate, 0.35);
const history = ensureHistorySeries(containerId);
const now = Date.now();
history.timestamps.push(now);
history.cpu.push(smoothedStats[containerId].cpu);
history.memory.push(smoothedStats[containerId].memory);
// Keep only last MAX_HISTORY_POINTS
if (history.timestamps.length > MAX_HISTORY_POINTS) {
history.cpu.push(s.cpu);
history.memory.push(s.memory);
history.memoryLimit.push(s.memoryLimit || 0);
history.netRxRate.push(s.netRxRate || 0);
history.netTxRate.push(s.netTxRate || 0);
history.blkReadRate.push(s.blkReadRate || 0);
history.blkWriteRate.push(s.blkWriteRate || 0);
while (history.timestamps.length > MAX_HISTORY_POINTS) {
history.timestamps.shift();
history.cpu.shift();
history.memory.shift();
}
// Update charts if on container details view
if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === containerId) {
updateStatsCharts(containerId);
history.memoryLimit.shift();
history.netRxRate.shift();
history.netTxRate.shift();
history.blkReadRate.shift();
history.blkWriteRate.shift();
}
return smoothedStats[containerId];
if (
currentView === 'container-details' &&
currentContainerDetails &&
currentContainerDetails.Id === containerId &&
!detailsStatsState.paused
) {
const statsPane = document.getElementById('stats-pane');
if (statsPane?.classList.contains('active') || statsPane?.classList.contains('show')) {
updateContainerDetailsStatsLive(containerId);
}
}
return s;
}
@@ -4658,193 +4712,526 @@ function populateNetworkingTab(config) {
`;
}
let cpuChart = null;
let memoryChart = null;
function formatStatsBytes(n) {
const v = Number(n) || 0;
if (v < 1024) return `${v.toFixed(0)} B`;
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`;
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`;
return `${(v / 1024 ** 3).toFixed(2)} GB`;
}
function formatStatsRate(n) {
return `${formatStatsBytes(n)}/s`;
}
function formatStatsMem(bytes) {
const b = Number(bytes) || 0;
if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GB`;
return `${(b / 1024 ** 2).toFixed(1)} MB`;
}
function percentile(sortedAsc, p) {
if (!sortedAsc.length) return 0;
const idx = Math.min(
sortedAsc.length - 1,
Math.max(0, Math.ceil((p / 100) * sortedAsc.length) - 1)
);
return sortedAsc[idx];
}
function sliceHistoryByTimeframe(history, timeframeSec) {
const n = history.timestamps.length;
if (!n) {
return {
timestamps: [],
cpu: [],
memory: [],
memoryLimit: [],
netRxRate: [],
netTxRate: [],
blkReadRate: [],
blkWriteRate: [],
};
}
let start = 0;
if (timeframeSec > 0) {
const cutoff = Date.now() - timeframeSec * 1000;
start = history.timestamps.findIndex((t) => Number(t) >= cutoff);
if (start < 0) start = n;
}
const slice = (arr) => (Array.isArray(arr) ? arr.slice(start) : []);
return {
timestamps: slice(history.timestamps),
cpu: slice(history.cpu),
memory: slice(history.memory),
memoryLimit: slice(history.memoryLimit),
netRxRate: slice(history.netRxRate),
netTxRate: slice(history.netTxRate),
blkReadRate: slice(history.blkReadRate),
blkWriteRate: slice(history.blkWriteRate),
};
}
function formatChartLabels(timestamps) {
const span =
timestamps.length > 1
? Number(timestamps[timestamps.length - 1]) - Number(timestamps[0])
: 0;
const showDate = span > 3600 * 1000;
return timestamps.map((ts) => {
const d = new Date(ts);
const t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
if (!showDate) return t;
return `${d.getMonth() + 1}/${d.getDate()} ${t}`;
});
}
function chartThemeOptions(yLabel, yMax) {
const autoscale = detailsStatsState.autoscale;
const y = {
beginAtZero: true,
ticks: {
color: 'rgba(255, 255, 255, 0.55)',
font: { size: 10 },
maxTicksLimit: 6,
},
grid: { color: 'rgba(255, 255, 255, 0.06)' },
border: { color: 'rgba(255, 255, 255, 0.08)' },
title: yLabel
? {
display: true,
text: yLabel,
color: 'rgba(255, 255, 255, 0.4)',
font: { size: 10 },
}
: undefined,
};
if (!autoscale && yMax != null) y.max = yMax;
else if (autoscale && yMax != null) y.suggestedMax = yMax;
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
display: true,
position: 'top',
align: 'end',
labels: {
color: 'rgba(255, 255, 255, 0.65)',
boxWidth: 10,
boxHeight: 10,
font: { size: 10 },
padding: 8,
},
},
tooltip: {
backgroundColor: 'rgba(15, 19, 26, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
titleFont: { size: 11 },
bodyFont: { size: 11 },
padding: 8,
},
},
scales: {
y,
x: {
ticks: {
color: 'rgba(255, 255, 255, 0.45)',
font: { size: 9 },
maxTicksLimit: 8,
maxRotation: 0,
},
grid: { color: 'rgba(255, 255, 255, 0.04)' },
border: { color: 'rgba(255, 255, 255, 0.08)' },
},
},
elements: {
point: { radius: 0, hoverRadius: 3 },
line: { borderWidth: 1.75, tension: 0.3 },
},
};
}
function upsertLineChart(key, canvasId, labels, datasets, yLabel, yMax) {
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const chart = detailsStatsState.charts[key];
if (!chart) {
detailsStatsState.charts[key] = new Chart(canvas, {
type: 'line',
data: { labels, datasets },
options: chartThemeOptions(yLabel, yMax),
});
} else {
chart.data.labels = labels;
chart.data.datasets = datasets;
chart.options = chartThemeOptions(yLabel, yMax);
chart.update('none');
}
}
function destroyDetailsStatsCharts() {
for (const key of Object.keys(detailsStatsState.charts)) {
try {
detailsStatsState.charts[key]?.destroy();
} catch {
// ignore
}
detailsStatsState.charts[key] = null;
}
}
function ensureDetailsStatsWired() {
if (detailsStatsState.wired) return;
const root = document.getElementById('container-stats-content');
if (!root) return;
detailsStatsState.wired = true;
root.querySelectorAll('.detail-stats-tf').forEach((btn) => {
btn.addEventListener('click', () => {
root.querySelectorAll('.detail-stats-tf').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
detailsStatsState.timeframeSec = Number(btn.dataset.tf) || 0;
if (detailsStatsState.containerId) {
updateStatsCharts(detailsStatsState.containerId);
}
});
});
document.getElementById('detail-stats-autoscale')?.addEventListener('change', (e) => {
detailsStatsState.autoscale = Boolean(e.target.checked);
if (detailsStatsState.containerId) updateStatsCharts(detailsStatsState.containerId);
});
document.getElementById('detail-stats-pause-btn')?.addEventListener('click', () => {
detailsStatsState.paused = !detailsStatsState.paused;
const btn = document.getElementById('detail-stats-pause-btn');
const live = document.getElementById('detail-stats-live');
if (btn) {
btn.innerHTML = detailsStatsState.paused
? '<i class="fas fa-play"></i> Resume'
: '<i class="fas fa-pause"></i> Pause';
}
if (live) {
live.classList.toggle('is-paused', detailsStatsState.paused);
live.innerHTML = detailsStatsState.paused
? '<i class="fas fa-circle"></i> Paused'
: '<i class="fas fa-circle"></i> Live';
}
});
document.getElementById('detail-stats-export-btn')?.addEventListener('click', () => {
if (detailsStatsState.containerId) exportDetailsStatsCsv(detailsStatsState.containerId);
});
document.getElementById('detail-stats-refresh-btn')?.addEventListener('click', () => {
if (currentContainerDetails) {
fetchServerStatsHistory(currentContainerDetails.Id, true);
}
});
}
function mergeServerHistory(containerId, points) {
if (!Array.isArray(points) || !points.length) return;
const h = ensureHistorySeries(containerId);
// Prefer longer server series; keep live tail if client is ahead
if (points.length >= h.timestamps.length) {
h.timestamps = points.map((p) => p.t);
h.cpu = points.map((p) => Number(p.cpu) || 0);
h.memory = points.map((p) => Number(p.memory) || 0);
h.memoryLimit = points.map((p) => Number(p.memoryLimit) || 0);
h.netRxRate = points.map((p) => Number(p.netRxRate) || 0);
h.netTxRate = points.map((p) => Number(p.netTxRate) || 0);
h.blkReadRate = points.map((p) => Number(p.blkReadRate) || 0);
h.blkWriteRate = points.map((p) => Number(p.blkWriteRate) || 0);
}
}
function fetchServerStatsHistory(containerId, forceCharts = false) {
if (!manager.active?.connected || !containerId) return;
const limit = Math.min(MAX_HISTORY_POINTS, 900);
const since =
detailsStatsState.timeframeSec > 0
? Date.now() - detailsStatsState.timeframeSec * 1000
: undefined;
manager
.request(Methods.getStatsHistory, { id: containerId, limit, since })
.then((res) => {
if (res?.data?.length) {
mergeServerHistory(containerId, res.data);
if (forceCharts || !detailsStatsState.paused) updateStatsCharts(containerId);
} else if (forceCharts) {
updateStatsCharts(containerId);
}
})
.catch(() => {
if (forceCharts) updateStatsCharts(containerId);
});
}
function updateContainerDetailsStats(container) {
// This will be called by the stats update function
// For now, just show current stats if available
if (smoothedStats[container.Id]) {
const stats = smoothedStats[container.Id];
const cpuEl = document.getElementById('detail-cpu');
const memoryEl = document.getElementById('detail-memory');
if (cpuEl) cpuEl.textContent = `${stats.cpu.toFixed(2)}%`;
if (memoryEl) memoryEl.textContent = `${(stats.memory / (1024 * 1024)).toFixed(2)} MB`;
}
// Merge server-side history ring buffer into client charts (road-map stats history)
if (manager.active?.connected) {
manager
.request(Methods.getStatsHistory, { id: container.Id, limit: 120 })
.then((res) => {
if (!res?.data?.length) return;
if (!historicalStats[container.Id]) {
historicalStats[container.Id] = { timestamps: [], cpu: [], memory: [] };
}
const h = historicalStats[container.Id];
// Prefer server series when longer
if (res.data.length >= h.timestamps.length) {
h.timestamps = res.data.map((p) => p.t);
h.cpu = res.data.map((p) => p.cpu);
h.memory = res.data.map((p) => p.memory);
updateStatsCharts(container.Id);
}
})
.catch(() => {});
}
if (!container?.Id) return;
ensureDetailsStatsWired();
detailsStatsState.containerId = container.Id;
ensureHistorySeries(container.Id);
updateContainerDetailsStatsLive(container.Id);
fetchServerStatsHistory(container.Id, true);
}
// Initialize charts
updateStatsCharts(container.Id);
/** Live KPI + charts refresh (skips network fetch). */
function updateContainerDetailsStatsLive(containerId) {
const stats = smoothedStats[containerId];
const cpuEl = document.getElementById('detail-cpu');
const memEl = document.getElementById('detail-memory');
const netEl = document.getElementById('detail-net');
const diskEl = document.getElementById('detail-disk');
const cpuBar = document.getElementById('detail-cpu-bar');
const memBar = document.getElementById('detail-memory-bar');
const memSub = document.getElementById('detail-memory-sub');
const updatedEl = document.getElementById('detail-stats-updated');
if (stats) {
if (cpuEl) cpuEl.textContent = `${stats.cpu.toFixed(2)}%`;
if (memEl) memEl.textContent = formatStatsMem(stats.memory);
if (netEl) {
netEl.textContent = `${formatStatsBytes(stats.netRxRate || 0)} · ↑${formatStatsBytes(stats.netTxRate || 0)}`;
}
if (diskEl) {
diskEl.textContent = `R${formatStatsBytes(stats.blkReadRate || 0)} · W${formatStatsBytes(stats.blkWriteRate || 0)}`;
}
if (cpuBar) {
const pct = Math.min(100, Math.max(0, stats.cpu));
cpuBar.style.width = `${pct}%`;
}
const lim = Number(stats.memoryLimit) || 0;
if (memBar && lim > 0) {
memBar.style.width = `${Math.min(100, (stats.memory / lim) * 100)}%`;
} else if (memBar) {
memBar.style.width = '0%';
}
if (memSub) {
memSub.textContent =
lim > 0
? `of ${formatStatsMem(lim)} · ${((stats.memory / lim) * 100).toFixed(1)}%`
: 'limit unlimited';
}
}
if (updatedEl) {
updatedEl.textContent = new Date().toLocaleTimeString();
}
if (!detailsStatsState.paused) {
updateStatsCharts(containerId);
}
}
function updateStatsCharts(containerId) {
if (!historicalStats[containerId] || historicalStats[containerId].timestamps.length === 0) {
const full = historicalStats[containerId];
if (!full?.timestamps?.length) {
const samplesEl = document.getElementById('detail-stats-samples');
if (samplesEl) samplesEl.textContent = '0';
return;
}
const history = historicalStats[containerId];
const container = document.getElementById('stats-charts-container');
if (!container) return;
// Format timestamps for display
const labels = history.timestamps.map(ts => {
const date = new Date(ts);
return `${date.getMinutes()}:${date.getSeconds().toString().padStart(2, '0')}`;
});
// Create or update CPU chart
const cpuCtx = document.getElementById('cpu-chart-canvas');
if (!cpuCtx) {
// Create canvas if it doesn't exist
container.innerHTML = `
<div class="row g-3">
<div class="col-12">
<h6 class="mb-3">CPU Usage</h6>
<div class="chart-wrapper" style="height: 250px; position: relative;">
<canvas id="cpu-chart-canvas"></canvas>
</div>
</div>
<div class="col-12">
<h6 class="mb-3">Memory Usage</h6>
<div class="chart-wrapper" style="height: 250px; position: relative;">
<canvas id="memory-chart-canvas"></canvas>
</div>
</div>
</div>
`;
const win = sliceHistoryByTimeframe(full, detailsStatsState.timeframeSec);
const labels = formatChartLabels(win.timestamps);
const memMB = win.memory.map((m) => (Number(m) || 0) / (1024 * 1024));
// KPI window stats
const cpuSorted = [...win.cpu].sort((a, b) => a - b);
const avg = (arr) =>
arr.length ? arr.reduce((s, v) => s + (Number(v) || 0), 0) / arr.length : 0;
const peak = (arr) => (arr.length ? Math.max(...arr.map((v) => Number(v) || 0)) : 0);
const cpuAvg = avg(win.cpu);
const cpuPeak = peak(win.cpu);
const cpuP95 = percentile(cpuSorted, 95);
const memPeak = peak(win.memory);
const lastLim =
win.memoryLimit?.length
? Number(win.memoryLimit[win.memoryLimit.length - 1]) || 0
: smoothedStats[containerId]?.memoryLimit || 0;
const cpuSub = document.getElementById('detail-cpu-sub');
if (cpuSub) {
cpuSub.textContent = `avg ${cpuAvg.toFixed(1)}% · peak ${cpuPeak.toFixed(1)}%`;
}
const cpuCanvas = document.getElementById('cpu-chart-canvas');
const memoryCanvas = document.getElementById('memory-chart-canvas');
if (cpuCanvas && typeof Chart !== 'undefined') {
if (!cpuChart) {
cpuChart = new Chart(cpuCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'CPU %',
data: history.cpu,
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
const netSub = document.getElementById('detail-net-sub');
if (netSub) {
netSub.textContent = `avg ↓${formatStatsRate(avg(win.netRxRate))} · ↑${formatStatsRate(avg(win.netTxRate))}`;
}
const diskSub = document.getElementById('detail-disk-sub');
if (diskSub) {
diskSub.textContent = `avg R${formatStatsRate(avg(win.blkReadRate))} · W${formatStatsRate(avg(win.blkWriteRate))}`;
}
const samplesEl = document.getElementById('detail-stats-samples');
if (samplesEl) samplesEl.textContent = String(win.timestamps.length);
const windowEl = document.getElementById('detail-stats-window');
if (windowEl) {
windowEl.textContent =
detailsStatsState.timeframeSec > 0
? detailsStatsState.timeframeSec >= 60
? `${detailsStatsState.timeframeSec / 60}m`
: `${detailsStatsState.timeframeSec}s`
: 'all retained';
}
const p95El = document.getElementById('detail-stats-cpu-p95');
if (p95El) p95El.textContent = `${cpuP95.toFixed(1)}%`;
const memPeakEl = document.getElementById('detail-stats-mem-peak');
if (memPeakEl) memPeakEl.textContent = formatStatsMem(memPeak);
const cpuMax = detailsStatsState.autoscale
? Math.max(10, Math.ceil(cpuPeak * 1.15) || 10)
: Math.max(100, Math.ceil(cpuPeak));
const memMax = detailsStatsState.autoscale
? Math.max(16, Math.ceil(Math.max(...memMB, lastLim / (1024 * 1024) || 0) * 1.1) || 16)
: undefined;
upsertLineChart(
'cpu',
'cpu-chart-canvas',
labels,
[
{
label: 'CPU %',
data: win.cpu,
borderColor: 'rgb(52, 211, 153)',
backgroundColor: 'rgba(52, 211, 153, 0.12)',
fill: true,
},
],
'%',
cpuMax
);
upsertLineChart(
'memory',
'memory-chart-canvas',
labels,
[
{
label: 'Working set',
data: memMB,
borderColor: 'rgb(56, 189, 248)',
backgroundColor: 'rgba(56, 189, 248, 0.12)',
fill: true,
},
...(lastLim > 0
? [
{
label: 'Limit',
data: win.memory.map(() => lastLim / (1024 * 1024)),
borderColor: 'rgba(248, 113, 113, 0.65)',
backgroundColor: 'transparent',
borderDash: [4, 4],
fill: false,
pointRadius: 0,
},
x: {
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
}
}
});
} else {
cpuChart.data.labels = labels;
cpuChart.data.datasets[0].data = history.cpu;
cpuChart.update('none');
}
}
if (memoryCanvas && typeof Chart !== 'undefined') {
// Convert memory to MB
const memoryMB = history.memory.map(m => m / (1024 * 1024));
if (!memoryChart) {
memoryChart = new Chart(memoryCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Memory (MB)',
data: memoryMB,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
x: {
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
}
}
});
} else {
memoryChart.data.labels = labels;
memoryChart.data.datasets[0].data = memoryMB;
memoryChart.update('none');
}
}
]
: []),
],
'MB',
memMax
);
const netPeak = Math.max(peak(win.netRxRate), peak(win.netTxRate), 1);
upsertLineChart(
'net',
'net-chart-canvas',
labels,
[
{
label: 'RX',
data: win.netRxRate,
borderColor: 'rgb(167, 139, 250)',
backgroundColor: 'rgba(167, 139, 250, 0.1)',
fill: true,
},
{
label: 'TX',
data: win.netTxRate,
borderColor: 'rgb(251, 191, 36)',
backgroundColor: 'rgba(251, 191, 36, 0.08)',
fill: true,
},
],
'B/s',
detailsStatsState.autoscale ? netPeak * 1.2 : undefined
);
const diskPeak = Math.max(peak(win.blkReadRate), peak(win.blkWriteRate), 1);
upsertLineChart(
'disk',
'disk-chart-canvas',
labels,
[
{
label: 'Read',
data: win.blkReadRate,
borderColor: 'rgb(45, 212, 191)',
backgroundColor: 'rgba(45, 212, 191, 0.1)',
fill: true,
},
{
label: 'Write',
data: win.blkWriteRate,
borderColor: 'rgb(244, 114, 182)',
backgroundColor: 'rgba(244, 114, 182, 0.08)',
fill: true,
},
],
'B/s',
detailsStatsState.autoscale ? diskPeak * 1.2 : undefined
);
}
function exportDetailsStatsCsv(containerId) {
const full = historicalStats[containerId];
if (!full?.timestamps?.length) {
showAlert('info', 'No stats samples to export yet');
return;
}
const win = sliceHistoryByTimeframe(full, detailsStatsState.timeframeSec);
const rows = [
[
'timestamp_iso',
'cpu_percent',
'memory_bytes',
'memory_limit_bytes',
'net_rx_Bps',
'net_tx_Bps',
'blk_read_Bps',
'blk_write_Bps',
].join(','),
];
for (let i = 0; i < win.timestamps.length; i++) {
rows.push(
[
new Date(win.timestamps[i]).toISOString(),
(win.cpu[i] ?? 0).toFixed(4),
Math.round(win.memory[i] ?? 0),
Math.round(win.memoryLimit[i] ?? 0),
Math.round(win.netRxRate[i] ?? 0),
Math.round(win.netTxRate[i] ?? 0),
Math.round(win.blkReadRate[i] ?? 0),
Math.round(win.blkWriteRate[i] ?? 0),
].join(',')
);
}
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
const short = String(containerId).slice(0, 12);
a.href = URL.createObjectURL(blob);
a.download = `peardock-stats-${short}-${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(a.href);
showAlert('success', `Exported ${win.timestamps.length} samples`);
}
// Logs state management
let logsState = {
paused: false,
@@ -5263,7 +5650,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// Set up stats tab
// Set up stats tab (advanced KPIs + timeframe charts)
ensureDetailsStatsWired();
const statsTab = document.getElementById('stats-tab');
if (statsTab) {
statsTab.addEventListener('shown.bs.tab', () => {
@@ -5271,17 +5659,9 @@ document.addEventListener('DOMContentLoaded', () => {
updateContainerDetailsStats(currentContainerDetails);
}
});
statsTab.addEventListener('hidden.bs.tab', () => {
// Clean up charts when leaving stats tab
if (cpuChart) {
cpuChart.destroy();
cpuChart = null;
}
if (memoryChart) {
memoryChart.destroy();
memoryChart = null;
}
destroyDetailsStatsCharts();
});
}