1
0
forked from wrenn/wrenn
Files
wrenn-releases/frontend/src/routes/dashboard/capsules/[id]/+page.svelte
pptx704 1244c08e42 fix: fetch sandbox metrics immediately on page load
Metrics data was only fetched after Chart.js dynamic import completed,
leaving graphs empty until the first poll interval fired. Now
loadMetrics() runs in parallel with the Chart.js import, and
initCharts() resets the dedup key so pre-fetched data populates
newly created chart instances.
2026-05-03 16:43:26 +06:00

774 lines
28 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getCapsule, pauseCapsule, resumeCapsule, type Capsule } from '$lib/api/capsules';
import { toast } from '$lib/toast.svelte';
import SnapshotDialog from '$lib/components/SnapshotDialog.svelte';
import DestroyDialog from '$lib/components/DestroyDialog.svelte';
import FilesTab from '$lib/components/FilesTab.svelte';
import TerminalTab from '$lib/components/TerminalTab.svelte';
import {
fetchCapsuleMetrics,
METRIC_RANGES,
METRIC_POLL_INTERVALS,
type MetricRange,
type MetricPoint
} from '$lib/api/metrics';
const capsuleId: string = $page.params.id ?? '';
let capsule = $state<Capsule | null>(null);
let capsuleLoading = $state(true);
let capsuleError = $state<string | null>(null);
// Lifecycle action state
let actionLoading = $state<string | null>(null);
let showDestroy = $state(false);
let showSnapshot = $state(false);
async function handlePause() {
if (!capsule) return;
actionLoading = 'pause';
const result = await pauseCapsule(capsule.id);
if (result.ok) {
capsule = result.data;
} else {
toast.error(result.error);
}
actionLoading = null;
}
async function handleResume() {
if (!capsule) return;
actionLoading = 'resume';
const result = await resumeCapsule(capsule.id);
if (result.ok) {
capsule = result.data;
} else {
toast.error(result.error);
}
actionLoading = null;
}
type Tab = 'metrics' | 'files' | 'terminal';
const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal'];
let activeTab = $state<Tab>('metrics');
function setTab(tab: Tab) {
activeTab = tab;
const url = new URL(window.location.href);
if (tab === 'metrics') {
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tab);
}
history.replaceState(null, '', url.toString());
}
let range = $state<MetricRange>('10m');
let points = $state<MetricPoint[]>([]);
let metricsLoading = $state(true);
let metricsError = $state<string | null>(null);
let canvasCpu = $state<HTMLCanvasElement | undefined>(undefined);
let canvasRam = $state<HTMLCanvasElement | undefined>(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartCpu: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartRam: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ChartJS = $state<any>(null);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let lastDataKey = ''; // fingerprint to skip redundant chart redraws
let visibilityHandler: (() => void) | null = null;
const metricsAvailable = $derived(
capsule?.status === 'running' || capsule?.status === 'paused'
);
// Latest values for live reading display in chart headers
const latestCpu = $derived<number | null>(
points.length > 0 ? points[points.length - 1].cpu_pct : null
);
const latestRamMB = $derived<number | null>(
points.length > 0 ? points[points.length - 1].mem_bytes / 1_048_576 : null
);
async function loadCapsule() {
const result = await getCapsule(capsuleId);
if (result.ok) {
capsule = result.data;
capsuleError = null;
} else {
capsuleError = result.error;
}
capsuleLoading = false;
}
async function loadMetrics() {
if (!metricsAvailable) return;
const result = await fetchCapsuleMetrics(capsuleId, range);
if (result.ok) {
points = result.data.points;
metricsError = null;
} else {
metricsError = result.error;
}
metricsLoading = false;
updateCharts();
}
/** Simple moving average — smooths noisy high-frequency samples. */
function smooth(data: number[], window: number): number[] {
if (window <= 1) return data;
const out: number[] = [];
for (let i = 0; i < data.length; i++) {
const start = Math.max(0, i - Math.floor(window / 2));
const end = Math.min(data.length, i + Math.ceil(window / 2));
let sum = 0;
for (let j = start; j < end; j++) sum += data[j];
out.push(+(sum / (end - start)).toFixed(2));
}
return out;
}
/** Window size scales with point count — more data = more smoothing. */
function smoothWindow(count: number): number {
if (count < 60) return 1; // < 60 pts: no smoothing
if (count < 200) return 3;
if (count < 600) return 5;
return 9;
}
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) {
chartCpu.data.labels = labels;
chartCpu.data.datasets[0].data = smooth(
Array.from(points.map((p) => +p.cpu_pct.toFixed(2))), w
);
chartCpu.update();
}
if (chartRam) {
chartRam.data.labels = labels;
chartRam.data.datasets[0].data = smooth(
Array.from(points.map((p) => +(p.mem_bytes / 1_048_576).toFixed(1))), w
);
chartRam.update();
}
}
function formatLabels(pts: MetricPoint[], r: MetricRange): string[] {
return pts.map((p) => {
const d = new Date(p.timestamp_unix * 1000);
if (r === '5m' || r === '10m') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
}
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() {
stopPolling();
loadMetrics();
pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVALS[range]);
}
// Chart design tokens (match StatsPanel.svelte)
const C_BLUE = '#5a9fd4';
const C_BLUE_FILL = 'rgba(90,159,212,0.11)';
const C_AMBER = '#d4a73c';
const C_AMBER_FILL = 'rgba(212,167,60,0.11)';
const C_GRID = 'rgba(255,255,255,0.05)';
const C_TICK = '#635f5c';
const FONT_MONO = "'JetBrains Mono', monospace";
const BASE_CHART_OPTIONS = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#111412',
borderColor: '#1f2321',
borderWidth: 1,
titleColor: '#454340',
bodyColor: '#d4cfc8',
titleFont: { family: FONT_MONO, size: 10 },
bodyFont: { family: FONT_MONO, size: 11 },
padding: 10,
caretSize: 4,
},
},
scales: {
x: {
grid: { color: C_GRID },
ticks: {
color: C_TICK,
font: { family: FONT_MONO, size: 10 },
maxTicksLimit: 8,
maxRotation: 0,
},
border: { color: C_GRID },
},
y: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 } },
border: { color: C_GRID },
beginAtZero: true,
},
},
};
function initCharts() {
if (!ChartJS || !canvasCpu || !canvasRam) return;
chartCpu?.destroy();
chartRam?.destroy();
chartCpu = new ChartJS(canvasCpu, {
type: 'line',
data: {
labels: [],
datasets: [
{
data: [],
borderColor: C_BLUE,
backgroundColor: C_BLUE_FILL,
borderWidth: 2,
fill: true,
tension: 0,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: C_BLUE,
},
],
},
options: {
...BASE_CHART_OPTIONS,
plugins: {
...BASE_CHART_OPTIONS.plugins,
tooltip: {
...BASE_CHART_OPTIONS.plugins.tooltip,
callbacks: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)}%`,
},
},
},
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: string | number) => `${+v}%`,
},
},
},
},
});
chartRam = new ChartJS(canvasRam, {
type: 'line',
data: {
labels: [],
datasets: [
{
data: [],
borderColor: C_AMBER,
backgroundColor: C_AMBER_FILL,
borderWidth: 2,
fill: true,
tension: 0,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: C_AMBER,
},
],
},
options: {
...BASE_CHART_OPTIONS,
plugins: {
...BASE_CHART_OPTIONS.plugins,
tooltip: {
...BASE_CHART_OPTIONS.plugins.tooltip,
callbacks: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(0)} MB`,
},
},
},
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: string | number) => `${+v} MB`,
},
},
},
},
});
lastDataKey = '';
updateCharts();
}
// Re-create charts whenever the metrics tab becomes active (canvases remount)
$effect(() => {
// Only track these two values for re-triggering
const tab = activeTab;
const chartLib = ChartJS;
if (tab !== 'metrics' || !chartLib) return;
// Wait for canvases to mount after the tab switch
tick().then(() => {
if (canvasCpu && canvasRam) {
initCharts();
restartPolling();
}
});
return () => {
stopPolling();
chartCpu?.destroy(); chartCpu = null;
chartRam?.destroy(); chartRam = null;
};
});
onMount(async () => {
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab') as Tab | null;
if (urlTab && VALID_TABS.includes(urlTab)) {
activeTab = urlTab;
}
const urlRange = params.get('range');
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
range = urlRange as MetricRange;
}
await loadCapsule();
if (!metricsAvailable) return;
loadMetrics();
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(() => {
stopPolling();
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);
chartCpu?.destroy();
chartRam?.destroy();
});
function statusColor(status: string): string {
switch (status) {
case 'running': return 'var(--color-accent)';
case 'paused': return 'var(--color-amber)';
case 'error': return 'var(--color-red)';
default: return 'var(--color-text-muted)';
}
}
function statusBg(status: string): string {
switch (status) {
case 'running': return 'rgba(94,140,88,0.12)';
case 'paused': return 'rgba(212,167,60,0.12)';
case 'error': return 'rgba(207,129,114,0.12)';
default: return 'rgba(255,255,255,0.05)';
}
}
function statusBorder(status: string): string {
switch (status) {
case 'running': return 'rgba(94,140,88,0.3)';
case 'paused': return 'rgba(212,167,60,0.3)';
case 'error': return 'rgba(207,129,114,0.3)';
default: return 'rgba(255,255,255,0.08)';
}
}
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtTimeout(sec: number): string {
if (!sec) return 'None';
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.round(sec / 60)}m`;
return `${Math.round(sec / 3600)}h`;
}
</script>
<svelte:head>
<title>Wrenn — {capsuleId}</title>
</svelte:head>
<style>
.metric-val {
transition: color 0.3s ease;
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.anim-in {
animation: fadeSlideUp 0.28s ease both;
}
</style>
{#if capsuleLoading}
<div class="flex items-center justify-center py-24">
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading capsule...
</div>
</div>
{:else if capsuleError}
<div class="px-7 py-8">
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-5 py-4">
<svg class="shrink-0 text-[var(--color-red)]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-ui text-[var(--color-red)]">{capsuleError}</span>
</div>
</div>
{:else if capsule}
<div class="flex flex-1 flex-col min-h-0">
<!-- Tabs + lifecycle actions -->
<div class="mt-5 flex items-center border-b border-[var(--color-border)] px-7">
<button
onclick={() => setTab('metrics')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'metrics'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Stats
</button>
<button
onclick={() => setTab('files')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'files'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
Files
</button>
<button
onclick={() => setTab('terminal')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'terminal'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
Terminal
</button>
<!-- Lifecycle actions (right-aligned) -->
<div class="ml-auto flex items-center gap-2">
{#if capsule.status === 'running'}
<button
onclick={handlePause}
disabled={actionLoading !== null}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/8 px-3 py-1.5 text-meta font-medium text-[var(--color-amber)] transition-all duration-150 hover:bg-[var(--color-amber)]/15 hover:border-[var(--color-amber)]/50 disabled:opacity-50"
>
{#if actionLoading === 'pause'}
<svg class="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
Pausing...
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
Pause
{/if}
</button>
{:else if capsule.status === 'paused'}
<button
onclick={handleResume}
disabled={actionLoading !== null}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 px-3 py-1.5 text-meta font-medium text-[var(--color-accent-bright)] transition-all duration-150 hover:bg-[var(--color-accent)]/15 hover:border-[var(--color-accent)]/50 disabled:opacity-50"
>
{#if actionLoading === 'resume'}
<svg class="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
Resuming...
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3" /></svg>
Resume
{/if}
</button>
<button
onclick={() => { showSnapshot = true; }}
disabled={actionLoading !== null}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3 py-1.5 text-meta font-medium text-[var(--color-text-secondary)] transition-all duration-150 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H2v13a2 2 0 002 2h16a2 2 0 002-2V7h-5l-2.5-3z" /><circle cx="12" cy="15" r="3" /></svg>
Snapshot
</button>
{/if}
{#if capsule.status === 'running' || capsule.status === 'paused'}
<button
onclick={() => { showDestroy = true; }}
disabled={actionLoading !== null}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-3 py-1.5 text-meta font-medium text-[var(--color-red)] transition-all duration-150 hover:bg-[var(--color-red)]/15 hover:border-[var(--color-red)]/50 disabled:opacity-50"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>
Destroy
</button>
{/if}
</div>
</div>
<!-- Tab content -->
<!-- Terminal stays mounted so sessions survive tab switches -->
<div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}>
<TerminalTab capsuleId={capsuleId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} />
</div>
{#if activeTab === 'files'}
<div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s">
<FilesTab capsuleId={capsuleId} isRunning={capsule.status === 'running'} />
</div>
{:else if activeTab === 'metrics'}
<div
class="anim-in flex flex-1 flex-col gap-5 min-h-0 p-8"
style="animation-delay: 0.05s"
>
<!-- Controls row -->
<div class="flex items-center justify-between">
{#if metricsAvailable && !metricsLoading}
<span class="flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-2 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
Live
</span>
{:else}
<div></div>
{/if}
{#if metricsAvailable}
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
{#each METRIC_RANGES as r, i}
<button
onclick={() => setRange(r)}
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
{range === r
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
>
{r}
</button>
{/each}
</div>
{/if}
</div>
<!-- Info card (StatsPanel style) -->
<div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<div class="flex divide-x divide-[var(--color-border)]">
<!-- Status -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5" style="box-shadow: inset 5px 0 0 {statusColor(capsule.status)}">
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</div>
<span
class="inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-label font-semibold uppercase tracking-[0.05em]"
style="color: {statusColor(capsule.status)}; background: {statusBg(capsule.status)}; border: 1px solid {statusBorder(capsule.status)}"
>
{#if capsule.status === 'running'}
<span class="relative flex h-[5px] w-[5px] shrink-0">
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
<span class="relative inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
</span>
{/if}
{capsule.status}
</span>
</div>
<!-- Template -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Template</div>
<span class="font-mono text-ui text-[var(--color-text-bright)]">{capsule.template}</span>
</div>
<!-- CPU -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">CPU</div>
<div class="mt-0.5 flex items-baseline gap-1">
<span class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{capsule.vcpus}</span>
<span class="font-mono text-label text-[var(--color-text-muted)]">vCPU{capsule.vcpus !== 1 ? 's' : ''}</span>
</div>
</div>
<!-- Memory -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Memory</div>
<div class="mt-0.5 flex items-baseline gap-1">
<span class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{capsule.memory_mb}</span>
<span class="font-mono text-label text-[var(--color-text-muted)]">MB</span>
</div>
</div>
<!-- Disk -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Disk</div>
<span class="mt-0.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-muted)]"></span>
</div>
<!-- Started -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Started</div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{fmtDate(capsule.started_at)}</span>
</div>
<!-- Idle Timeout -->
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Idle Timeout</div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{fmtTimeout(capsule.timeout_sec)}</span>
</div>
</div>
</div>
{#if metricsError}
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3">
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-ui text-[var(--color-red)]">Could not load metrics: {metricsError}. Will retry automatically.</span>
</div>
{/if}
{#if metricsAvailable}
<!-- Charts stacked — grow to fill remaining space -->
<div class="flex flex-1 flex-col gap-5 min-h-0">
<!-- CPU Usage -->
<div class="flex flex-1 flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" style="background: #5a9fd4"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU Usage</span>
</div>
{#if latestCpu !== null}
<div class="flex items-baseline gap-1">
<span class="metric-val font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestCpu.toFixed(1)}</span>
<span class="font-mono text-label text-[var(--color-text-muted)]">%</span>
</div>
{:else if metricsLoading}
<span class="font-serif text-[2.571rem] leading-none text-[var(--color-text-muted)]"></span>
{/if}
</div>
<div class="relative flex-1 min-h-[180px] px-5 pb-5 pt-3">
<canvas bind:this={canvasCpu}></canvas>
</div>
</div>
<!-- RAM Usage -->
<div class="flex flex-1 flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" style="background: #d4a73c"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM Usage</span>
</div>
{#if latestRamMB !== null}
<div class="flex items-baseline gap-1">
<span class="metric-val font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestRamMB.toFixed(0)}</span>
<span class="font-mono text-label text-[var(--color-text-muted)]">MB</span>
</div>
{:else if metricsLoading}
<span class="font-serif text-[2.571rem] leading-none text-[var(--color-text-muted)]"></span>
{/if}
</div>
<div class="relative flex-1 min-h-[180px] px-5 pb-5 pt-3">
<canvas bind:this={canvasRam}></canvas>
</div>
</div>
</div>
{:else}
<!-- Stats unavailable — capsule not running/paused -->
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-5 py-4">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span class="text-ui text-[var(--color-text-tertiary)]">
Live stats are only available for running or paused capsules —
current status: <span class="font-mono" style="color: {statusColor(capsule.status)}">{capsule.status}</span>
</span>
</div>
{/if}
</div>
{/if}
</div>
<SnapshotDialog
open={showSnapshot}
capsuleId={capsuleId}
onclose={() => { showSnapshot = false; }}
onsnapshot={() => { goto('/dashboard/capsules'); }}
/>
<DestroyDialog
open={showDestroy}
capsuleId={capsuleId}
onclose={() => { showDestroy = false; }}
ondestroyed={() => { goto('/dashboard/capsules'); }}
/>
{/if}