feat: cloud backup destinations + long-term resource history
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>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
<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>
|
||||
|
||||
@@ -34,6 +35,27 @@
|
||||
</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">
|
||||
@@ -325,4 +347,141 @@
|
||||
// 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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user