forked from wrenn/wrenn
v0.1.0 (#17)
This commit is contained in:
@ -2,24 +2,70 @@
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getCapsule, type Capsule } from '$lib/api/capsules';
|
||||
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 {
|
||||
fetchSandboxMetrics,
|
||||
fetchCapsuleMetrics,
|
||||
METRIC_RANGES,
|
||||
METRIC_POLL_INTERVAL,
|
||||
METRIC_POLL_INTERVALS,
|
||||
type MetricRange,
|
||||
type MetricPoint
|
||||
} from '$lib/api/metrics';
|
||||
|
||||
const sandboxId: string = $page.params.id ?? '';
|
||||
const capsuleId: string = $page.params.id ?? '';
|
||||
|
||||
let capsule = $state<Capsule | null>(null);
|
||||
let capsuleLoading = $state(true);
|
||||
let capsuleError = $state<string | null>(null);
|
||||
|
||||
type Tab = 'metrics' | 'files';
|
||||
// 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);
|
||||
@ -31,7 +77,11 @@
|
||||
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'
|
||||
@ -46,7 +96,7 @@
|
||||
);
|
||||
|
||||
async function loadCapsule() {
|
||||
const result = await getCapsule(sandboxId);
|
||||
const result = await getCapsule(capsuleId);
|
||||
if (result.ok) {
|
||||
capsule = result.data;
|
||||
capsuleError = null;
|
||||
@ -58,7 +108,7 @@
|
||||
|
||||
async function loadMetrics() {
|
||||
if (!metricsAvailable) return;
|
||||
const result = await fetchSandboxMetrics(sandboxId, range);
|
||||
const result = await fetchCapsuleMetrics(capsuleId, range);
|
||||
if (result.ok) {
|
||||
points = result.data.points;
|
||||
metricsError = null;
|
||||
@ -93,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) {
|
||||
@ -123,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)
|
||||
@ -182,23 +241,13 @@
|
||||
},
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const urlRange = new URLSearchParams(window.location.search).get('range');
|
||||
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
|
||||
range = urlRange as MetricRange;
|
||||
}
|
||||
function initCharts() {
|
||||
if (!ChartJS || !canvasCpu || !canvasRam) return;
|
||||
|
||||
await loadCapsule();
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
|
||||
if (!metricsAvailable) return;
|
||||
|
||||
await tick();
|
||||
|
||||
if (!canvasCpu || !canvasRam) return;
|
||||
|
||||
const { Chart } = await import('chart.js/auto');
|
||||
|
||||
chartCpu = new Chart(canvasCpu, {
|
||||
chartCpu = new ChartJS(canvasCpu, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
@ -241,7 +290,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
chartRam = new Chart(canvasRam, {
|
||||
chartRam = new ChartJS(canvasRam, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
@ -285,11 +334,65 @@
|
||||
});
|
||||
|
||||
updateCharts();
|
||||
restartPolling();
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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();
|
||||
});
|
||||
@ -338,7 +441,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — {sandboxId}</title>
|
||||
<title>Wrenn — {capsuleId}</title>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
@ -375,10 +478,10 @@
|
||||
{:else if capsule}
|
||||
<div class="flex flex-1 flex-col min-h-0">
|
||||
|
||||
<!-- Tabs (matches Templates page pattern) -->
|
||||
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
|
||||
<!-- Tabs + lifecycle actions -->
|
||||
<div class="mt-5 flex items-center border-b border-[var(--color-border)] px-7">
|
||||
<button
|
||||
onclick={() => (activeTab = 'metrics')}
|
||||
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)]'
|
||||
@ -391,22 +494,94 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
title="Coming soon"
|
||||
class="flex cursor-not-allowed items-center gap-2 border-b-2 border-transparent px-4 py-2.5 text-ui font-medium opacity-40"
|
||||
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
|
||||
<span class="rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
||||
Soon
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<!-- Stats tab content -->
|
||||
{#if activeTab === 'metrics'}
|
||||
<!-- 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"
|
||||
@ -579,4 +754,18 @@
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user