1
0
forked from wrenn/wrenn

Extract SnapshotDialog and DestroyDialog into reusable components

Add lifecycle buttons (pause, resume, snapshot, destroy) to the
individual capsule detail page and refactor both the list and detail
pages to share the new dialog components.
This commit is contained in:
2026-04-11 06:08:19 +06:00
parent 9332f4ac18
commit 2bad843069
4 changed files with 335 additions and 166 deletions

View File

@ -0,0 +1,82 @@
<script lang="ts">
import { destroyCapsule } from '$lib/api/capsules';
type Props = {
open: boolean;
capsuleId: string;
onclose: () => void;
ondestroyed?: () => void;
};
let { open, capsuleId, onclose, ondestroyed }: Props = $props();
let destroying = $state(false);
let error = $state<string | null>(null);
async function handleDestroy() {
destroying = true;
error = null;
const result = await destroyCapsule(capsuleId);
if (result.ok) {
error = null;
ondestroyed?.();
onclose();
} else {
error = result.error;
}
destroying = false;
}
function handleClose() {
if (!destroying) {
error = null;
onclose();
}
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={handleClose}
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
></div>
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Destroy Capsule</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Terminate <span class="font-mono text-[var(--color-text-secondary)]">{capsuleId}</span> and destroy all data inside it. This cannot be undone.
</p>
{#if error}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{error}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={handleClose}
disabled={destroying}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDestroy}
disabled={destroying}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if destroying}
<svg class="animate-spin" width="13" height="13" 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>
Destroying...
{:else}
Destroy
{/if}
</button>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,130 @@
<script lang="ts">
import { createSnapshot } from '$lib/api/capsules';
type Props = {
open: boolean;
capsuleId: string;
pauseFirst?: boolean;
onclose: () => void;
onsnapshot?: () => void;
};
let { open, capsuleId, pauseFirst = false, onclose, onsnapshot }: Props = $props();
let snapshotName = $state('');
let snapshotting = $state(false);
let error = $state<string | null>(null);
function reset() {
snapshotName = '';
error = null;
}
async function handleConfirm() {
snapshotting = true;
error = null;
const result = await createSnapshot(capsuleId, snapshotName.trim() || undefined);
if (result.ok) {
reset();
onsnapshot?.();
onclose();
} else {
error = result.error;
}
snapshotting = false;
}
function handleClose() {
if (!snapshotting) {
reset();
onclose();
}
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={handleClose}
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
></div>
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] overflow-hidden" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
<svg width="18" height="18" 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>
</div>
<div>
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Capture snapshot</h2>
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{capsuleId}</p>
</div>
</div>
<div class="px-6 pt-5 pb-6 space-y-4">
{#if pauseFirst}
<div class="flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-3 py-2.5">
<svg class="mt-px shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p class="text-meta text-[var(--color-amber)] leading-relaxed">This capsule will be <strong class="font-semibold">paused first</strong>, then its full state (memory + disk) will be captured.</p>
</div>
{:else}
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule's current state (memory + disk) will be captured and stored as a reusable snapshot.</p>
{/if}
{#if error}
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{error}
</div>
{/if}
<div>
<div class="mb-1.5 flex items-baseline justify-between">
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">Snapshot name</label>
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
</div>
<input
id="snapshot-name"
type="text"
bind:value={snapshotName}
disabled={snapshotting}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
placeholder="e.g. after-apt-install, pre-migration"
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleConfirm(); }}
/>
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Leave blank to use an auto-generated name.</p>
</div>
<div class="flex justify-end gap-3 pt-1">
<button
onclick={handleClose}
disabled={snapshotting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleConfirm}
disabled={snapshotting}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if snapshotting}
<svg class="animate-spin" width="13" height="13" 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>
Capturing...
{:else}
Capture snapshot
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte'; import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte';
import SnapshotDialog from '$lib/components/SnapshotDialog.svelte';
import DestroyDialog from '$lib/components/DestroyDialog.svelte';
import CopyButton from '$lib/components/CopyButton.svelte'; import CopyButton from '$lib/components/CopyButton.svelte';
import { capsuleRunningCount } from '$lib/capsule-store.svelte'; import { capsuleRunningCount } from '$lib/capsule-store.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -9,8 +11,6 @@
listCapsules, listCapsules,
pauseCapsule, pauseCapsule,
resumeCapsule, resumeCapsule,
destroyCapsule,
createSnapshot,
type Capsule type Capsule
} from '$lib/api/capsules'; } from '$lib/api/capsules';
@ -45,14 +45,9 @@
// Snapshot dialog state // Snapshot dialog state
let snapshotTarget = $state<{ capsule: Capsule; pauseFirst: boolean } | null>(null); let snapshotTarget = $state<{ capsule: Capsule; pauseFirst: boolean } | null>(null);
let snapshotName = $state('');
let snapshotting = $state(false);
let snapshotError = $state<string | null>(null);
// Destroy confirmation state // Destroy confirmation state
let destroyTarget = $state<Capsule | null>(null); let destroyTarget = $state<Capsule | null>(null);
let destroying = $state(false);
let destroyError = $state<string | null>(null);
// Briefly highlight a newly created capsule row // Briefly highlight a newly created capsule row
let newCapsuleId = $state<string | null>(null); let newCapsuleId = $state<string | null>(null);
@ -179,45 +174,25 @@
function handleSnapshot(capsule: Capsule) { function handleSnapshot(capsule: Capsule) {
openMenuId = null; openMenuId = null;
snapshotName = '';
snapshotError = null;
snapshotTarget = { capsule, pauseFirst: false }; snapshotTarget = { capsule, pauseFirst: false };
} }
function handlePauseAndSnapshot(capsule: Capsule) { function handlePauseAndSnapshot(capsule: Capsule) {
openMenuId = null; openMenuId = null;
snapshotName = '';
snapshotError = null;
snapshotTarget = { capsule, pauseFirst: true }; snapshotTarget = { capsule, pauseFirst: true };
} }
async function handleSnapshotConfirm() { function handleSnapshotDone() {
if (!snapshotTarget) return; snapshotTarget = null;
snapshotting = true; fetchCapsules();
snapshotError = null;
const result = await createSnapshot(snapshotTarget.capsule.id, snapshotName.trim() || undefined);
if (result.ok) {
snapshotTarget = null;
await fetchCapsules();
} else {
snapshotError = result.error;
}
snapshotting = false;
} }
async function handleDestroy() { function handleDestroyed() {
if (!destroyTarget) return; if (destroyTarget) {
destroying = true; const id = destroyTarget.id;
destroyError = null;
const id = destroyTarget.id;
const result = await destroyCapsule(id);
if (result.ok) {
capsules = capsules.filter((c) => c.id !== id); capsules = capsules.filter((c) => c.id !== id);
destroyTarget = null;
} else {
destroyError = result.error;
} }
destroying = false; destroyTarget = null;
} }
function handleCapsuleCreated(capsule: Capsule) { function handleCapsuleCreated(capsule: Capsule) {
@ -579,7 +554,7 @@
{/if} {/if}
<div class="my-1 border-t border-[var(--color-border)]"></div> <div class="my-1 border-t border-[var(--color-border)]"></div>
<button <button
onclick={() => { const target = openCapsule; openMenuId = null; destroyError = null; destroyTarget = target; }} onclick={() => { const target = openCapsule; openMenuId = null; destroyTarget = target; }}
class="flex w-full items-center gap-2.5 px-3 py-2 text-meta text-[var(--color-red)] transition-colors duration-150 hover:bg-[var(--color-red)]/5" class="flex w-full items-center gap-2.5 px-3 py-2 text-meta text-[var(--color-red)] transition-colors duration-150 hover:bg-[var(--color-red)]/5"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
@ -594,139 +569,23 @@
<!-- Snapshot Dialog --> <!-- Snapshot Dialog -->
{#if snapshotTarget} {#if snapshotTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center"> <SnapshotDialog
<!-- svelte-ignore a11y_no_static_element_interactions --> open={true}
<div capsuleId={snapshotTarget.capsule.id}
class="absolute inset-0 bg-black/60" pauseFirst={snapshotTarget.pauseFirst}
onclick={() => { if (!snapshotting) snapshotTarget = null; }} onclose={() => { snapshotTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }} onsnapshot={handleSnapshotDone}
></div> />
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] overflow-hidden" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
<svg width="18" height="18" 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>
</div>
<div>
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Capture snapshot</h2>
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{snapshotTarget.capsule.id}</p>
</div>
</div>
<div class="px-6 pt-5 pb-6 space-y-4">
{#if snapshotTarget.pauseFirst}
<div class="flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-3 py-2.5">
<svg class="mt-px shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p class="text-meta text-[var(--color-amber)] leading-relaxed">This capsule will be <strong class="font-semibold">paused first</strong>, then its full state (memory + disk) will be captured.</p>
</div>
{:else}
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule's current memory state will be captured and stored as a reusable snapshot.</p>
{/if}
{#if snapshotError}
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{snapshotError}
</div>
{/if}
<div>
<div class="mb-1.5 flex items-baseline justify-between">
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">Snapshot name</label>
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
</div>
<input
id="snapshot-name"
type="text"
bind:value={snapshotName}
disabled={snapshotting}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
placeholder="e.g. after-apt-install, pre-migration"
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleSnapshotConfirm(); }}
/>
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Leave blank to use an auto-generated name.</p>
</div>
<div class="flex justify-end gap-3 pt-1">
<button
onclick={() => { snapshotTarget = null; }}
disabled={snapshotting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleSnapshotConfirm}
disabled={snapshotting}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if snapshotting}
<svg class="animate-spin" width="13" height="13" 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>
Capturing...
{:else}
Capture snapshot
{/if}
</button>
</div>
</div>
</div>
</div>
{/if} {/if}
<!-- Destroy confirmation dialog --> <!-- Destroy Dialog -->
{#if destroyTarget} {#if destroyTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center"> <DestroyDialog
<!-- svelte-ignore a11y_no_static_element_interactions --> open={true}
<div capsuleId={destroyTarget.id}
class="absolute inset-0 bg-black/60" onclose={() => { destroyTarget = null; }}
onclick={() => { if (!destroying) destroyTarget = null; }} ondestroyed={handleDestroyed}
onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} />
></div>
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Destroy Capsule</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Terminate <span class="font-mono text-[var(--color-text-secondary)]">{destroyTarget.id}</span> and destroy all data inside it. This cannot be undone.
</p>
{#if destroyError}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{destroyError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => { destroyTarget = null; }}
disabled={destroying}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDestroy}
disabled={destroying}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if destroying}
<svg class="animate-spin" width="13" height="13" 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>
Destroying...
{:else}
Destroy
{/if}
</button>
</div>
</div>
</div>
{/if} {/if}
<!-- Create Capsule Dialog --> <!-- Create Capsule Dialog -->

View File

@ -2,7 +2,10 @@
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; 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 FilesTab from '$lib/components/FilesTab.svelte';
import TerminalTab from '$lib/components/TerminalTab.svelte'; import TerminalTab from '$lib/components/TerminalTab.svelte';
import { import {
@ -19,6 +22,35 @@
let capsuleLoading = $state(true); let capsuleLoading = $state(true);
let capsuleError = $state<string | null>(null); 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'; type Tab = 'metrics' | 'files' | 'terminal';
const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal']; const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal'];
let activeTab = $state<Tab>('metrics'); let activeTab = $state<Tab>('metrics');
@ -424,6 +456,58 @@
{:else if capsule} {:else if capsule}
<div class="flex flex-1 flex-col min-h-0"> <div class="flex flex-1 flex-col min-h-0">
<!-- Action buttons -->
<div class="flex items-center justify-end gap-2 px-7 pt-5">
{#if capsule.status === 'running'}
<button
onclick={handlePause}
disabled={actionLoading !== null}
class="flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/8 px-3.5 py-2 text-ui 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="14" height="14" 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="14" height="14" 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-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 px-3.5 py-2 text-ui 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="14" height="14" 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="14" height="14" 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-2 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3.5 py-2 text-ui 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="14" height="14" 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-2 rounded-[var(--radius-button)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-3.5 py-2 text-ui 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="14" height="14" 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>
<!-- Tabs (matches Templates page pattern) --> <!-- Tabs (matches Templates page pattern) -->
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7"> <div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
<button <button
@ -648,4 +732,18 @@
</div> </div>
{/if} {/if}
</div> </div>
<SnapshotDialog
open={showSnapshot}
capsuleId={sandboxId}
onclose={() => { showSnapshot = false; }}
onsnapshot={() => { goto('/dashboard/capsules'); }}
/>
<DestroyDialog
open={showDestroy}
capsuleId={sandboxId}
onclose={() => { showDestroy = false; }}
ondestroyed={() => { goto('/dashboard/capsules'); }}
/>
{/if} {/if}