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:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user