Cloud backups (Dropbox / WebDAV / SFTP):
- backup-manager.js: save + load handlers per provider, credential
resolution via credentialManager, destination probe.
- routes/backups.js: /credentials/{provider} (masked GET, POST, DELETE),
/test-destination, scheduling endpoints.
- status/js/backup-restore.js: destination picker, provider-specific
credential forms, test button wired to backend probe.
- npm deps already present (dropbox 10.34.0, webdav 5.7.1,
ssh2-sftp-client 11.0.0).
Resource history:
- resource-monitor.js: three-tier rollup storage — raw 10s samples
(7-day retention), hourly rollups (30-day), daily rollups
(365-day). getHistoryByRange() auto-selects the appropriate tier.
- routes/monitoring.js: /monitoring/history/:containerId now supports
startTime/endTime range mode (legacy ?hours=N still works).
- status/js/resource-monitor.js + dashboard.css: "History" tab with
range buttons (1h/24h/7d/30d/1y), SVG sparklines for
CPU / memory / network. Renderer handles raw and rolled-up shapes.
status/dist/features.js rebuilt from source via build.js.
Lifted out of wip/cloud-backups-and-history; the half-finished
app-deps feature from that branch (frontend calls /api/v1/apps/
check-dependencies but the endpoint doesn't exist) is preserved
separately on wip/app-deps for later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
488 lines
23 KiB
JavaScript
488 lines
23 KiB
JavaScript
// ========== RESOURCE MONITOR (Enhanced) ==========
|
|
(function() {
|
|
// Inject modal HTML
|
|
injectModal('stats-modal', `<div id="stats-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
<h3>📊 Resource Monitor</h3>
|
|
<p class="modal-subtitle">
|
|
Real-time and historical CPU, memory, network, and disk usage for containers.
|
|
</p>
|
|
|
|
<!-- Tab bar -->
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="stats-live">Live Stats</button>
|
|
<button class="panel-tab" data-panel="stats-aggregated">24h Summary</button>
|
|
<button class="panel-tab" data-panel="stats-history">History</button>
|
|
<button class="panel-tab" data-panel="stats-alerts">Alerts</button>
|
|
</div>
|
|
|
|
<!-- Tab: Live Stats -->
|
|
<div id="stats-live" class="panel-section active">
|
|
<div id="stats-container" class="scroll-container">
|
|
<div style="text-align: center; padding: 40px; color: var(--muted);">
|
|
<span class="brand-spinner"></span> Loading container stats...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: 24h Aggregated Summary -->
|
|
<div id="stats-aggregated" class="panel-section">
|
|
<div id="stats-aggregated-container" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">📈</span>
|
|
Loading 24-hour aggregated metrics...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Long-term History -->
|
|
<div id="stats-history" class="panel-section">
|
|
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Container:</label>
|
|
<select id="stats-history-container" style="padding: 4px 8px; background: var(--card-base); border: 1px solid var(--border); color: var(--fg); border-radius: 4px; font-size: 0.85rem; flex: 1; min-width: 180px;"></select>
|
|
<div id="stats-history-range-buttons" style="display: flex; gap: 4px;">
|
|
<button class="stats-range-btn active" data-range="1h">1h</button>
|
|
<button class="stats-range-btn" data-range="24h">24h</button>
|
|
<button class="stats-range-btn" data-range="7d">7d</button>
|
|
<button class="stats-range-btn" data-range="30d">30d</button>
|
|
<button class="stats-range-btn" data-range="1y">1y</button>
|
|
</div>
|
|
</div>
|
|
<div id="stats-history-container-area" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">📊</span>
|
|
Choose a container and time range to view history.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Alert Configuration -->
|
|
<div id="stats-alerts" class="panel-section">
|
|
<div id="stats-alerts-container" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">🔔</span>
|
|
Loading alert configurations...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-refresh toggle (bottom bar) -->
|
|
<div class="panel-bottom-bar">
|
|
<label class="checkbox-label" style="font-size: 0.85rem;">
|
|
<input type="checkbox" id="stats-auto-refresh" checked />
|
|
Auto-refresh every 5s
|
|
</label>
|
|
<button id="stats-refresh-btn" class="btn-sm">🔄 Refresh Now</button>
|
|
<span id="stats-last-update" class="text-auto-right"></span>
|
|
</div>
|
|
|
|
<!-- Close Button -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="stats-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
|
|
const modal = document.getElementById('stats-modal');
|
|
const openBtn = document.getElementById('container-stats-btn');
|
|
const cancelBtn = document.getElementById('stats-cancel');
|
|
const refreshBtn = document.getElementById('stats-refresh-btn');
|
|
const autoRefreshCheckbox = document.getElementById('stats-auto-refresh');
|
|
const container = document.getElementById('stats-container');
|
|
const aggregatedContainer = document.getElementById('stats-aggregated-container');
|
|
const alertsContainer = document.getElementById('stats-alerts-container');
|
|
const lastUpdateSpan = document.getElementById('stats-last-update');
|
|
|
|
let refreshInterval = null;
|
|
let cachedMonitoringData = null;
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0 || !bytes) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function getCpuColor(percent) {
|
|
if (percent < 30) return '#2ecc71';
|
|
if (percent < 70) return '#f39c12';
|
|
return '#e74c3c';
|
|
}
|
|
|
|
function getMemColor(percent) {
|
|
if (percent < 50) return '#2ecc71';
|
|
if (percent < 80) return '#f39c12';
|
|
return '#e74c3c';
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
// Try new monitoring API first, fall back to old
|
|
let stats = null;
|
|
let isNewApi = false;
|
|
try {
|
|
const res = await fetch('/api/v1/monitoring/stats');
|
|
const data = await res.json();
|
|
if (data.success && data.stats) { stats = data.stats; isNewApi = true; cachedMonitoringData = data.stats; }
|
|
} catch (_) {}
|
|
|
|
if (!isNewApi) {
|
|
const response = await fetch('/api/v1/stats/containers');
|
|
const data = await response.json();
|
|
if (data.success && data.stats) {
|
|
// Convert array format to object format
|
|
stats = {};
|
|
for (const s of data.stats) {
|
|
stats[s.name] = { name: s.name, current: { cpu: s.cpu, memory: { percent: s.memory.percent, usage: s.memory.used, limit: s.memory.limit, usageMB: Math.round(s.memory.used / 1048576), limitMB: Math.round(s.memory.limit / 1048576) }, network: { rxBytes: s.network.rx, txBytes: s.network.tx, rxMB: (s.network.rx / 1048576).toFixed(1), txMB: (s.network.tx / 1048576).toFixed(1) }, disk: { readMB: 0, writeMB: 0 } }, status: s.status };
|
|
}
|
|
cachedMonitoringData = stats;
|
|
}
|
|
}
|
|
|
|
if (!stats || Object.keys(stats).length === 0) {
|
|
container.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
|
|
|
for (const [id, info] of Object.entries(stats)) {
|
|
const cur = info.current || info;
|
|
const cpu = cur.cpu?.percent || 0;
|
|
const mem = cur.memory?.percent || 0;
|
|
const cpuColor = getCpuColor(cpu);
|
|
const memColor = getMemColor(mem);
|
|
const memUsed = cur.memory?.usage || cur.memory?.used || 0;
|
|
const memLimit = cur.memory?.limit || 0;
|
|
const netRx = cur.network?.rxBytes || cur.network?.rx || 0;
|
|
const netTx = cur.network?.txBytes || cur.network?.tx || 0;
|
|
const agg = info.aggregated;
|
|
|
|
html += `
|
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
|
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
|
|
${agg ? `<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${agg.cpu?.avg?.toFixed(0) || 0}% cpu</span>` : ''}
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${info.status || 'running'}</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; width: ${Math.min(cpu, 100)}%; background: ${cpuColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${cpuColor}; min-width: 45px; text-align: right;">${cpu.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; width: ${Math.min(mem, 100)}%; background: ${memColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${memColor}; min-width: 45px; text-align: right;">${mem.toFixed(1)}%</span>
|
|
</div>
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${formatBytes(memUsed)} / ${formatBytes(memLimit)}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
|
<div style="font-size: 0.8rem;">
|
|
<span style="color: #3498db;">↓ ${formatBytes(netRx)}</span>
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
<span style="color: #e74c3c;">↑ ${formatBytes(netTx)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
lastUpdateSpan.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--bad-fg);">❌ Failed to load stats: ${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// === 24h Aggregated Tab ===
|
|
async function loadAggregated() {
|
|
if (!aggregatedContainer) return;
|
|
const data = cachedMonitoringData;
|
|
if (!data || Object.keys(data).length === 0) {
|
|
aggregatedContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📈</span>No monitoring data available. Open the Live Stats tab first.</div>';
|
|
return;
|
|
}
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
|
for (const [id, info] of Object.entries(data)) {
|
|
const agg = info.aggregated;
|
|
if (!agg) continue;
|
|
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${info.name || id}</div>
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
<div class="stat-mini-card"><span class="stat-val">${agg.cpu?.avg?.toFixed(1) || 0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${agg.cpu?.max?.toFixed(1) || 0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${agg.memory?.avg?.toFixed(1) || 0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${agg.memory?.max?.toFixed(1) || 0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
</div>
|
|
${agg.dataPoints ? `<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${agg.dataPoints} data points over ${agg.timeRange || 24}h</div>` : ''}
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
aggregatedContainer.innerHTML = html;
|
|
}
|
|
|
|
// === Alerts Tab ===
|
|
async function loadAlerts() {
|
|
if (!alertsContainer) return;
|
|
alertsContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';
|
|
const data = cachedMonitoringData;
|
|
if (!data || Object.keys(data).length === 0) {
|
|
alertsContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🔔</span>No containers found. Open the Live Stats tab first.</div>';
|
|
return;
|
|
}
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
|
for (const [id, info] of Object.entries(data)) {
|
|
const alertCfg = info.alertConfig || {};
|
|
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
|
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
<input type="checkbox" class="alert-enabled" data-container="${id}" ${alertCfg.enabled ? 'checked' : ''} /> Enabled
|
|
</label>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
|
<input type="number" class="alert-cpu" data-container="${id}" value="${alertCfg.cpuThreshold || 80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
|
<input type="number" class="alert-mem" data-container="${id}" value="${alertCfg.memoryThreshold || 85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
|
<input type="number" class="alert-cooldown" data-container="${id}" value="${alertCfg.cooldownMinutes || 15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
<input type="checkbox" class="alert-autorestart" data-container="${id}" ${alertCfg.autoRestart ? 'checked' : ''} /> Auto-restart on breach
|
|
</label>
|
|
<span style="flex: 1;"></span>
|
|
<button class="alert-save-btn" data-container="${id}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
alertsContainer.innerHTML = html;
|
|
|
|
// Wire up save buttons
|
|
alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const cId = btn.dataset.container;
|
|
const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false;
|
|
const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80;
|
|
const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85;
|
|
const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15;
|
|
const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false;
|
|
try {
|
|
const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart })
|
|
});
|
|
const data = await res.json();
|
|
btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed';
|
|
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
|
} catch (e) {
|
|
btn.textContent = '❌ Error';
|
|
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
if (autoRefreshCheckbox?.checked) {
|
|
refreshInterval = setInterval(loadStats, DC.POLL.STATS);
|
|
}
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval);
|
|
refreshInterval = null;
|
|
}
|
|
}
|
|
|
|
// Open modal
|
|
openBtn?.addEventListener('click', () => {
|
|
modal.classList.add('show');
|
|
loadStats();
|
|
startAutoRefresh();
|
|
});
|
|
|
|
// Close modal
|
|
cancelBtn?.addEventListener('click', () => {
|
|
modal.classList.remove('show');
|
|
stopAutoRefresh();
|
|
});
|
|
|
|
modal?.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.classList.remove('show');
|
|
stopAutoRefresh();
|
|
}
|
|
});
|
|
|
|
refreshBtn?.addEventListener('click', loadStats);
|
|
|
|
autoRefreshCheckbox?.addEventListener('change', () => {
|
|
if (autoRefreshCheckbox.checked) startAutoRefresh();
|
|
else stopAutoRefresh();
|
|
});
|
|
|
|
// Lazy-load tabs
|
|
document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener('click', loadAggregated);
|
|
document.querySelector('[data-panel="stats-alerts"]')?.addEventListener('click', loadAlerts);
|
|
|
|
// === History Tab ===
|
|
const historyContainerSelect = document.getElementById('stats-history-container');
|
|
const historyArea = document.getElementById('stats-history-container-area');
|
|
const rangeButtons = document.querySelectorAll('.stats-range-btn');
|
|
|
|
let currentRange = '1h';
|
|
|
|
function rangeToMs(range) {
|
|
switch (range) {
|
|
case '1h': return 60 * 60 * 1000;
|
|
case '24h': return 24 * 60 * 60 * 1000;
|
|
case '7d': return 7 * 24 * 60 * 60 * 1000;
|
|
case '30d': return 30 * 24 * 60 * 60 * 1000;
|
|
case '1y': return 365 * 24 * 60 * 60 * 1000;
|
|
default: return 60 * 60 * 1000;
|
|
}
|
|
}
|
|
|
|
function tierLabel(tier) {
|
|
if (tier === 'raw') return 'live (10s samples)';
|
|
if (tier === 'hourly') return 'hourly average';
|
|
if (tier === 'daily') return 'daily average';
|
|
return tier;
|
|
}
|
|
|
|
// Simple SVG sparkline — no external lib
|
|
function renderSparkline(samples, accessor, color, label, unit) {
|
|
if (!samples || samples.length === 0) {
|
|
return `<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(label)}</div>`;
|
|
}
|
|
const values = samples.map(accessor).filter(v => v != null);
|
|
if (values.length === 0) {
|
|
return `<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(label)}</div>`;
|
|
}
|
|
const max = Math.max(...values, 1);
|
|
const min = Math.min(...values, 0);
|
|
const range = max - min || 1;
|
|
const w = 600, h = 80, pad = 4;
|
|
const stepX = (w - pad * 2) / Math.max(values.length - 1, 1);
|
|
const points = values.map((v, i) => {
|
|
const x = pad + i * stepX;
|
|
const y = h - pad - ((v - min) / range) * (h - pad * 2);
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
}).join(' ');
|
|
const last = values[values.length - 1];
|
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
return `
|
|
<div style="margin-bottom: 14px;">
|
|
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
|
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(label)}</span>
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${last.toFixed(1)}${unit} · avg ${avg.toFixed(1)}${unit} · max ${max.toFixed(1)}${unit}</span>
|
|
</div>
|
|
<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="width: 100%; height: ${h}px; background: var(--base); border-radius: 4px;">
|
|
<polyline fill="none" stroke="${color}" stroke-width="1.5" points="${points}" />
|
|
</svg>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function populateHistoryContainerSelect() {
|
|
if (!historyContainerSelect) return;
|
|
const data = cachedMonitoringData || {};
|
|
const previous = historyContainerSelect.value;
|
|
const entries = Object.entries(data);
|
|
if (entries.length === 0) {
|
|
historyContainerSelect.innerHTML = '<option value="">No containers</option>';
|
|
return;
|
|
}
|
|
historyContainerSelect.innerHTML = entries
|
|
.map(([id, info]) => `<option value="${escapeHtml(id)}">${escapeHtml(info.name || id)}</option>`)
|
|
.join('');
|
|
if (previous && data[previous]) historyContainerSelect.value = previous;
|
|
}
|
|
|
|
async function loadHistory() {
|
|
if (!historyArea || !historyContainerSelect) return;
|
|
const containerId = historyContainerSelect.value;
|
|
if (!containerId) {
|
|
historyArea.innerHTML = '<div class="panel-empty"><span class="empty-icon">📊</span>No container selected.</div>';
|
|
return;
|
|
}
|
|
const endTime = Date.now();
|
|
const startTime = endTime - rangeToMs(currentRange);
|
|
historyArea.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/monitoring/history/${encodeURIComponent(containerId)}?startTime=${startTime}&endTime=${endTime}`);
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed to load history');
|
|
|
|
const samples = data.samples || [];
|
|
const tier = data.tier || 'raw';
|
|
|
|
if (samples.length === 0) {
|
|
historyArea.innerHTML = `<div class="panel-empty"><span class="empty-icon">📊</span>No data for the last ${currentRange}. Tier: ${tierLabel(tier)}.</div>`;
|
|
return;
|
|
}
|
|
|
|
// Different shape between raw vs rolled-up samples
|
|
const isRaw = tier === 'raw';
|
|
const cpuAccessor = isRaw ? (s) => s.cpu?.percent : (s) => s.cpu?.avg;
|
|
const memAccessor = isRaw ? (s) => s.memory?.percent : (s) => s.memory?.avgPercent;
|
|
const netRxAccessor = isRaw ? (s) => (s.network?.rxMB || 0) : (s) => (s.network?.rxMB || 0);
|
|
const netTxAccessor = isRaw ? (s) => (s.network?.txMB || 0) : (s) => (s.network?.txMB || 0);
|
|
|
|
let html = `
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
${samples.length} samples · ${escapeHtml(tierLabel(tier))} · ${new Date(startTime).toLocaleString()} → ${new Date(endTime).toLocaleString()}
|
|
</div>
|
|
`;
|
|
html += renderSparkline(samples, cpuAccessor, '#2ecc71', 'CPU', '%');
|
|
html += renderSparkline(samples, memAccessor, '#3498db', 'Memory', '%');
|
|
html += renderSparkline(samples, netRxAccessor, '#9b59b6', 'Network RX', ' MB');
|
|
html += renderSparkline(samples, netTxAccessor, '#e67e22', 'Network TX', ' MB');
|
|
|
|
historyArea.innerHTML = html;
|
|
} catch (e) {
|
|
historyArea.innerHTML = `<div class="panel-empty"><span class="empty-icon">⚠️</span>Failed to load history: ${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
rangeButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
rangeButtons.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentRange = btn.dataset.range;
|
|
loadHistory();
|
|
});
|
|
});
|
|
|
|
historyContainerSelect?.addEventListener('change', loadHistory);
|
|
|
|
document.querySelector('[data-panel="stats-history"]')?.addEventListener('click', () => {
|
|
populateHistoryContainerSelect();
|
|
loadHistory();
|
|
});
|
|
})();
|