diff --git a/frontend/src/lib/api/metrics.ts b/frontend/src/lib/api/metrics.ts index baf9f11..c3aaea8 100644 --- a/frontend/src/lib/api/metrics.ts +++ b/frontend/src/lib/api/metrics.ts @@ -21,5 +21,14 @@ export async function fetchSandboxMetrics(id: string, range: MetricRange): Promi export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; -// All ranges poll every 10 seconds. +// Poll interval varies by range — shorter ranges need fresher data. +export const METRIC_POLL_INTERVALS: Record = { + '5m': 10_000, + '10m': 10_000, + '1h': 30_000, + '6h': 60_000, + '24h': 120_000, +}; + +/** @deprecated Use METRIC_POLL_INTERVALS instead */ export const METRIC_POLL_INTERVAL = 10_000; diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index 19be331..56963b2 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -26,6 +26,8 @@ let chartRam: any = null; let pollInterval: ReturnType | null = null; + let lastDataKey = ''; // cheap fingerprint to skip redundant chart redraws + let visibilityHandler: (() => void) | null = null; async function load() { const result = await fetchStats(range); @@ -43,6 +45,10 @@ function updateCharts() { if (!stats) return; + // Skip redraw if data hasn't changed (same length + same last label). + const key = `${stats.series.labels.length}:${stats.series.labels.at(-1) ?? ''}`; + if (key === lastDataKey) return; + lastDataKey = key; // Use Array.from to pass plain JS arrays to Chart.js — Svelte 5 $state // wraps arrays in reactive proxies which Chart.js can't iterate reliably. const labels = formatLabels(Array.from(stats.series.labels), range); @@ -77,14 +83,19 @@ }); } + function stopPolling() { + if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + } + function restartPolling() { - if (pollInterval) clearInterval(pollInterval); + stopPolling(); load(); pollInterval = setInterval(load, POLL_INTERVALS[range]); } function setRange(r: TimeRange) { range = r; + lastDataKey = ''; // force chart redraw on range switch goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true }); restartPolling(); } @@ -237,10 +248,21 @@ updateCharts(); restartPolling(); + + // Pause polling when the browser tab is hidden to save bandwidth/CPU. + visibilityHandler = () => { + if (document.hidden) { + stopPolling(); + } else { + restartPolling(); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); }); onDestroy(() => { - if (pollInterval) clearInterval(pollInterval); + stopPolling(); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); chartRunning?.destroy(); chartCpu?.destroy(); chartRam?.destroy(); diff --git a/frontend/src/routes/admin/templates/+page.svelte b/frontend/src/routes/admin/templates/+page.svelte index 0a4703c..39b62da 100644 --- a/frontend/src/routes/admin/templates/+page.svelte +++ b/frontend/src/routes/admin/templates/+page.svelte @@ -36,6 +36,7 @@ // Polling let pollInterval: ReturnType | null = null; let hasActiveBuilds = $derived(builds.some((b) => b.status === 'pending' || b.status === 'running')); + let visibilityHandler: (() => void) | null = null; // Build log expansion let expandedBuildId = $state(null); @@ -97,7 +98,7 @@ function startPolling() { stopPolling(); pollInterval = setInterval(() => { - if (hasActiveBuilds) fetchBuilds(); + if (hasActiveBuilds && activeTab === 'builds') fetchBuilds(); }, 3000); } @@ -242,9 +243,22 @@ onMount(() => { fetchTemplates(); fetchBuilds().then(startPolling); + + // Pause polling when the browser tab is hidden. + visibilityHandler = () => { + if (document.hidden) { + stopPolling(); + } else { + startPolling(); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); }); - onDestroy(stopPolling); + onDestroy(() => { + stopPolling(); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); + });
diff --git a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte index 9c84f97..8b1aa67 100644 --- a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte @@ -11,7 +11,7 @@ import { fetchSandboxMetrics, METRIC_RANGES, - METRIC_POLL_INTERVAL, + METRIC_POLL_INTERVALS, type MetricRange, type MetricPoint } from '$lib/api/metrics'; @@ -80,6 +80,8 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any let ChartJS = $state(null); let pollInterval: ReturnType | null = null; + let lastDataKey = ''; // fingerprint to skip redundant chart redraws + let visibilityHandler: (() => void) | null = null; const metricsAvailable = $derived( capsule?.status === 'running' || capsule?.status === 'paused' @@ -141,6 +143,10 @@ function updateCharts() { if (!points.length) return; + // Skip redraw if data hasn't changed. + const key = `${points.length}:${points.at(-1)?.timestamp_unix ?? ''}`; + if (key === lastDataKey) return; + lastDataKey = key; const labels = formatLabels(Array.from(points), range); const w = smoothWindow(points.length); if (chartCpu) { @@ -171,15 +177,20 @@ function setRange(r: MetricRange) { range = r; + lastDataKey = ''; // force chart redraw on range switch goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true }); metricsLoading = true; restartPolling(); } + function stopPolling() { + if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + } + function restartPolling() { - if (pollInterval) clearInterval(pollInterval); + stopPolling(); loadMetrics(); - pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVAL); + pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVALS[range]); } // Chart design tokens (match StatsPanel.svelte) @@ -342,7 +353,7 @@ }); return () => { - if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + stopPolling(); chartCpu?.destroy(); chartCpu = null; chartRam?.destroy(); chartRam = null; }; @@ -367,10 +378,21 @@ const mod = await import('chart.js/auto'); ChartJS = mod.Chart; + + // Pause polling when the browser tab is hidden. + visibilityHandler = () => { + if (document.hidden) { + stopPolling(); + } else if (activeTab === 'metrics' && metricsAvailable) { + restartPolling(); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); }); onDestroy(() => { - if (pollInterval) clearInterval(pollInterval); + stopPolling(); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); chartCpu?.destroy(); chartRam?.destroy(); });