From 90bea52ccd834b48aaaa08fdf95db932d57385eb Mon Sep 17 00:00:00 2001 From: pptx704 Date: Mon, 13 Apr 2026 04:12:36 +0600 Subject: [PATCH 1/3] Add admin capsule management, fix file browser for special files, normalize dialog styles - Admin capsule CRUD: list, create (platform templates), get detail with terminal/files/metrics, snapshot, destroy - First signup auto-promotes to platform admin - JWT auth via query param for WebSocket connections - File browser: handle non-regular files (devices, pipes, sockets) gracefully instead of showing raw backend errors - Normalize admin template dialogs to match established dialog patterns: remove accent bars, unify animation/shadow/button styles --- .../20260412213141_seed_platform_team.sql | 12 + db/queries/users.sql | 3 + frontend/src/lib/api/admin-capsules.ts | 40 ++ frontend/src/lib/api/files.ts | 12 +- frontend/src/lib/api/metrics.ts | 4 +- .../src/lib/components/AdminSidebar.svelte | 6 +- .../lib/components/CreateCapsuleDialog.svelte | 11 +- frontend/src/lib/components/FilesTab.svelte | 60 +- .../src/lib/components/TerminalTab.svelte | 5 +- .../src/routes/admin/capsules/+page.svelte | 334 ++++++++++ .../routes/admin/capsules/[id]/+page.svelte | 623 ++++++++++++++++++ .../src/routes/admin/templates/+page.svelte | 24 +- internal/api/handlers_admin_capsules.go | 247 +++++++ internal/api/handlers_auth.go | 17 +- internal/api/handlers_snapshots.go | 10 +- internal/api/middleware_admin.go | 15 + internal/api/middleware_jwt.go | 15 +- internal/api/server.go | 18 + internal/db/users.sql.go | 11 + 19 files changed, 1417 insertions(+), 50 deletions(-) create mode 100644 db/migrations/20260412213141_seed_platform_team.sql create mode 100644 frontend/src/lib/api/admin-capsules.ts create mode 100644 frontend/src/routes/admin/capsules/+page.svelte create mode 100644 frontend/src/routes/admin/capsules/[id]/+page.svelte create mode 100644 internal/api/handlers_admin_capsules.go diff --git a/db/migrations/20260412213141_seed_platform_team.sql b/db/migrations/20260412213141_seed_platform_team.sql new file mode 100644 index 0000000..751a0cc --- /dev/null +++ b/db/migrations/20260412213141_seed_platform_team.sql @@ -0,0 +1,12 @@ +-- +goose Up + +-- Seed the platform team row. This is the sentinel team (all-zeros UUID) that +-- owns platform-wide resources: global templates, admin-created capsules, etc. +-- No user can become a member of this team — it exists solely to satisfy +-- foreign key constraints and to act as a namespace for platform resources. +INSERT INTO teams (id, name, slug) +VALUES ('00000000-0000-0000-0000-000000000000', 'Platform', 'platform') +ON CONFLICT (id) DO NOTHING; + +-- +goose Down +DELETE FROM teams WHERE id = '00000000-0000-0000-0000-000000000000'; diff --git a/db/queries/users.sql b/db/queries/users.sql index a244fc9..fe0e1fd 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -35,6 +35,9 @@ SELECT EXISTS( SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 ) AS has_permission; +-- name: CountUsers :one +SELECT COUNT(*) FROM users; + -- name: SearchUsersByEmailPrefix :many SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; diff --git a/frontend/src/lib/api/admin-capsules.ts b/frontend/src/lib/api/admin-capsules.ts new file mode 100644 index 0000000..337ee0a --- /dev/null +++ b/frontend/src/lib/api/admin-capsules.ts @@ -0,0 +1,40 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; +import type { Capsule, CreateCapsuleParams, Snapshot } from '$lib/api/capsules'; +import type { AdminTemplate } from '$lib/api/builds'; + +export async function listAdminCapsules(): Promise> { + return apiFetch('GET', '/api/v1/admin/capsules'); +} + +export async function getAdminCapsule(id: string): Promise> { + return apiFetch('GET', `/api/v1/admin/capsules/${id}`); +} + +export async function createAdminCapsule(params: CreateCapsuleParams): Promise> { + return apiFetch('POST', '/api/v1/admin/capsules', params); +} + +export async function destroyAdminCapsule(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`); +} + +export async function snapshotAdminCapsule(id: string, name?: string): Promise> { + return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name }); +} + +/** Fetch platform templates for the admin create dialog. */ +export async function listPlatformTemplates(): Promise> { + const result = await apiFetch('GET', '/api/v1/admin/templates'); + if (!result.ok) return result; + // Map AdminTemplate → Snapshot shape. + const snapshots: Snapshot[] = result.data.map((t) => ({ + name: t.name, + type: t.type, + vcpus: t.vcpus || undefined, + memory_mb: t.memory_mb || undefined, + size_bytes: t.size_bytes, + created_at: t.created_at, + platform: true, + })); + return { ok: true, data: snapshots }; +} diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index d7ac54b..7d066ec 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -4,7 +4,7 @@ import { type ApiResult } from '$lib/api/client'; export type FileEntry = { name: string; path: string; - type: 'file' | 'directory' | 'symlink'; + type: 'file' | 'directory' | 'symlink' | 'unknown'; size: number; mode: number; permissions: string; @@ -53,12 +53,12 @@ export function formatFileSize(bytes: number): string { return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`; } -export async function listDir(capsuleId: string, path: string, depth = 1): Promise> { +export async function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise> { try { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/capsules/${capsuleId}/files/list`, { + const res = await fetch(`${basePath}/${capsuleId}/files/list`, { method: 'POST', headers, body: JSON.stringify({ path, depth }), @@ -76,12 +76,13 @@ export async function readFile( capsuleId: string, path: string, signal?: AbortSignal, + basePath = '/api/v1/capsules', ): Promise> { try { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, { + const res = await fetch(`${basePath}/${capsuleId}/files/read`, { method: 'POST', headers, body: JSON.stringify({ path }), @@ -113,11 +114,12 @@ export async function downloadFile( path: string, filename: string, signal?: AbortSignal, + basePath = '/api/v1/capsules', ): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, { + const res = await fetch(`${basePath}/${capsuleId}/files/read`, { method: 'POST', headers, body: JSON.stringify({ path }), diff --git a/frontend/src/lib/api/metrics.ts b/frontend/src/lib/api/metrics.ts index 4c192d3..f093bf5 100644 --- a/frontend/src/lib/api/metrics.ts +++ b/frontend/src/lib/api/metrics.ts @@ -15,8 +15,8 @@ export type MetricsResponse = { points: MetricPoint[]; }; -export async function fetchCapsuleMetrics(id: string, range: MetricRange): Promise> { - return apiFetch('GET', `/api/v1/capsules/${id}/metrics?range=${range}`); +export async function fetchCapsuleMetrics(id: string, range: MetricRange, basePath = '/api/v1/capsules'): Promise> { + return apiFetch('GET', `${basePath}/${id}/metrics?range=${range}`); } export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; diff --git a/frontend/src/lib/components/AdminSidebar.svelte b/frontend/src/lib/components/AdminSidebar.svelte index d01e857..2b55db6 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -3,7 +3,8 @@ import { auth } from '$lib/auth.svelte'; import { IconServer, - IconTemplate, + IconBox, + IconMonitor, IconSettings, IconLogout, IconSidebar, @@ -22,7 +23,8 @@ }; const managementItems: NavItem[] = [ - { label: 'Templates', icon: IconTemplate, href: '/admin/templates' }, + { label: 'Templates', icon: IconBox, href: '/admin/templates' }, + { label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' }, { label: 'Hosts', icon: IconServer, href: '/admin/hosts' } ]; diff --git a/frontend/src/lib/components/CreateCapsuleDialog.svelte b/frontend/src/lib/components/CreateCapsuleDialog.svelte index 5aba66c..1b8b18a 100644 --- a/frontend/src/lib/components/CreateCapsuleDialog.svelte +++ b/frontend/src/lib/components/CreateCapsuleDialog.svelte @@ -1,12 +1,15 @@ + + + Wrenn Admin — Capsules + + +
+ + +
+ +
+
+ +
+
+

+ Capsules +

+

+ Launch temporary capsules to build and snapshot platform templates. +

+
+ +
+ + + {#if !loading && !error} +
+ + {capsules.length} + total + + {#if runningCount > 0} + + + {runningCount} + running + + {/if} + {#if pausedCount > 0} + + {pausedCount} + paused + + {/if} +
+ {/if} +
+ + +
+ {#if loading} +
+
+ + Loading capsules... +
+
+ {:else if error} +
+ + + + {error} +
+ {:else if capsules.length === 0} +
+
+ + + +
+

No capsules

+

+ Launch a capsule, configure it interactively, then snapshot it as a platform template. +

+ +
+ {:else} + +
+ + + + + + + + + + + + + {#each capsules as capsule (capsule.id)} + + + + + + + + + {/each} + +
IDStatusTemplateSpecsStartedActions
+ + + + {#if capsule.status === 'running'} + + + + + {/if} + {capsule.status} + + + {capsule.template} + + + {capsule.vcpus}v · {capsule.memory_mb}MB + + + {fmtDate(capsule.started_at)} + +
+ + Open + + {#if capsule.status === 'running' || capsule.status === 'paused'} + + {/if} +
+
+
+ {/if} +
+
+
+ + { showCreateDialog = false; }} + oncreated={handleCreated} + templateSource="platform" +/> + + +{#if destroyTarget} +
+ +
{ if (!destroying) { destroyTarget = null; } }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) { destroyTarget = null; } }} + >
+
+

Destroy Capsule

+

+ Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/admin/capsules/[id]/+page.svelte b/frontend/src/routes/admin/capsules/[id]/+page.svelte new file mode 100644 index 0000000..7765314 --- /dev/null +++ b/frontend/src/routes/admin/capsules/[id]/+page.svelte @@ -0,0 +1,623 @@ + + + + Wrenn Admin — {capsuleId} + + +
+ + +
+ {#if capsuleLoading} +
+
+ + Loading capsule... +
+
+ {:else if capsuleError} +
+
+ + + + {capsuleError} +
+
+ {:else if capsule} + +
+ + + Capsules + +
+ + {capsuleId} + + + {#if capsule.status === 'running'} + + + + + {/if} + {capsule.status} + + + +
+ {capsule.template} + / + {capsule.vcpus}v · {capsule.memory_mb}MB +
+ +
+ + {#if capsule.status === 'running' || capsule.status === 'paused'} + + + {/if} +
+ + +
+ + +
+ +
+ + +
+ + + {#if metricsAvailable} +
+ +
+
+ + + Live + +
+
+ {#each METRIC_RANGES as r, i} + + {/each} +
+
+ + +
+ +
+
+
+ + CPU Usage +
+ {#if latestCpu !== null} +
+ {latestCpu.toFixed(1)} + % +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ + +
+
+
+ + RAM Usage +
+ {#if latestRamMB !== null} +
+ {latestRamMB.toFixed(0)} + MB +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+
+
+ {/if} + + +
+ +
+
+
+ {/if} +
+
+ + +{#if showSnapshot} +
+ +
{ if (!snapshotting) showSnapshot = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) showSnapshot = false; }} + >
+ +
+
+
+ + + + +
+
+

Snapshot as platform template

+

{capsuleId}

+
+
+ +
+
+ + + + + +

This will pause, snapshot, and destroy the capsule. The snapshot will be available as a platform template for all teams.

+
+ + {#if snapshotError} +
+ {snapshotError} +
+ {/if} + +
+
+ + optional +
+ { if (e.key === 'Enter' && !snapshotting) handleSnapshot(); }} + /> +

Leave blank for an auto-generated name. If the name already exists, it will be overwritten.

+
+ +
+ + +
+
+
+
+{/if} + + +{#if showDestroy} +
+ +
{ if (!destroying) showDestroy = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) showDestroy = false; }} + >
+
+

Destroy Capsule

+

+ Terminate {capsuleId} and destroy all data inside it. This cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/admin/templates/+page.svelte b/frontend/src/routes/admin/templates/+page.svelte index a309658..5f1b9ad 100644 --- a/frontend/src/routes/admin/templates/+page.svelte +++ b/frontend/src/routes/admin/templates/+page.svelte @@ -727,20 +727,16 @@ {#if showCreate}
+
{ if (!creating) showCreate = false; }} onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} >
- -
-

Create Template @@ -903,7 +899,7 @@ + {/each} +

+
+ + {#if metricsError} +
+ + + + Could not load metrics: {metricsError}. Will retry automatically. +
+ {/if} + + +
+ + +
+
+
+ + CPU Usage +
+ {#if latestCpu !== null} +
+ {latestCpu.toFixed(1)} + % +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ + +
+
+
+ + RAM Usage +
+ {#if latestRamMB !== null} +
+ {latestRamMB.toFixed(0)} + MB +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ +
+
diff --git a/frontend/src/routes/admin/capsules/[id]/+page.svelte b/frontend/src/routes/admin/capsules/[id]/+page.svelte index 7765314..11dfc46 100644 --- a/frontend/src/routes/admin/capsules/[id]/+page.svelte +++ b/frontend/src/routes/admin/capsules/[id]/+page.svelte @@ -1,10 +1,11 @@ @@ -349,9 +153,7 @@ Capsules
- {capsuleId} - - -
{capsule.template} / {capsule.vcpus}v · {capsule.memory_mb}MB
-
{#if capsule.status === 'running' || capsule.status === 'paused'} @@ -396,91 +195,20 @@
-
- +
- - {#if metricsAvailable}
- -
-
- - - Live - -
-
- {#each METRIC_RANGES as r, i} - - {/each} -
-
- - -
- -
-
-
- - CPU Usage -
- {#if latestCpu !== null} -
- {latestCpu.toFixed(1)} - % -
- {:else if metricsLoading} - - {/if} -
-
- -
-
- - -
-
-
- - RAM Usage -
- {#if latestRamMB !== null} -
- {latestRamMB.toFixed(0)} - MB -
- {:else if metricsLoading} - - {/if} -
-
- -
-
-
+
{/if} - -
+
From 784fe5c7a86181eae36ab188f9f75ddc220a7252 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Mon, 13 Apr 2026 04:41:51 +0600 Subject: [PATCH 3/3] Polish admin capsule pages and improve shared components - Admin list: remove redundant Open button, normalize with dashboard patterns (sorting, search highlight, auto-refresh, animations) - Admin detail: breadcrumb header, status bar, visibility polling - FilesTab: add treeOnly prop, compact mode uses 2/7 tree + 5/7 preview split, expand tree to full width when no file selected, improve copy - MetricsPanel: hide Live badge in compact layout (redundant with status) - DestroyDialog: accept destroyFn prop for admin capsule deletion --- .../src/lib/components/DestroyDialog.svelte | 7 +- frontend/src/lib/components/FilesTab.svelte | 28 +- .../src/lib/components/MetricsPanel.svelte | 14 +- .../src/routes/admin/capsules/+page.svelte | 636 ++++++++++++------ .../routes/admin/capsules/[id]/+page.svelte | 203 +++--- 5 files changed, 552 insertions(+), 336 deletions(-) diff --git a/frontend/src/lib/components/DestroyDialog.svelte b/frontend/src/lib/components/DestroyDialog.svelte index 6f9ec92..03b6e94 100644 --- a/frontend/src/lib/components/DestroyDialog.svelte +++ b/frontend/src/lib/components/DestroyDialog.svelte @@ -1,13 +1,15 @@ @@ -112,169 +220,307 @@ Wrenn Admin — Capsules + +
-
-
- -
+
+

Capsules

-

+

Launch temporary capsules to build and snapshot platform templates.

- -
- - {#if !loading && !error} -
- - {capsules.length} - total - - {#if runningCount > 0} - - - {runningCount} - running - +
+ {#if !loading && runningCount > 0} +
+ + + + + {runningCount} + running +
{/if} - {#if pausedCount > 0} - - {pausedCount} - paused - - {/if} -
- {/if} -
- -
- {#if loading} -
-
- - Loading capsules... -
-
- {:else if error} -
- - - - {error} -
- {:else if capsules.length === 0} -
-
- - - -
-

No capsules

-

- Launch a capsule, configure it interactively, then snapshot it as a platform template. -

- {:else} - -
- - - - - - - - - - - - - {#each capsules as capsule (capsule.id)} - - - - - - - - - {/each} - -
IDStatusTemplateSpecsStartedActions
- - - - {#if capsule.status === 'running'} - - - - - {/if} - {capsule.status} - - - {capsule.template} - - - {capsule.vcpus}v · {capsule.memory_mb}MB - - - {fmtDate(capsule.started_at)} - -
- - Open - - {#if capsule.status === 'running' || capsule.status === 'paused'} - - {/if} -
-
+
+
+ + +
+ +
+
+ + + + +
+ {filteredCapsules.length} capsule{filteredCapsules.length !== 1 ? 's' : ''} + +
+ + + + + + +
+ + {#if error} +
+ + + + {error}. Try refreshing the page.
{/if} + + +
+ +
+
ID
+ {@render sortableHeader('Template', 'template')} + {@render sortableHeader('CPU', 'vcpus')} + {@render sortableHeader('Memory', 'memory_mb')} + {@render sortableHeader('Started', 'started_at')} + {@render sortableHeader('Status', 'status')} +
Actions
+
+ + {#if loading && capsules.length === 0} +
+
+ + + + Loading capsules... +
+
+ {:else if filteredCapsules.length === 0 && searchQuery} +
+
+
+ + + +
+
+

+ No matching capsules +

+

+ No capsules match "{searchQuery}". +

+ +
+ {:else if filteredCapsules.length === 0} +
+
+
+
+ + + +
+
+

+ No capsules +

+

+ Launch a capsule, configure it interactively, then snapshot it as a platform template. +

+ +
+ {:else} + {#each filteredCapsules as capsule, i (capsule.id)} + {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : capsule.status === 'error' ? 'bg-[var(--color-red)]' : 'bg-[var(--color-text-muted)]'} +
+ +
+ + +
+ {#if capsule.status === 'running'} + + + + + {:else if capsule.status === 'paused'} + + {:else if capsule.status === 'error'} + + {:else} + + {/if} + {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} + {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} + {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} + {:else} + {capsule.id} + {/if} + +
+ + +
+ {capsule.template} +
+ + +
+ {capsule.vcpus} +
+ + +
+ {capsule.memory_mb}MB +
+ + +
+ {formatTime(capsule.started_at)} + {#if capsule.last_active_at} + active {timeAgo(capsule.last_active_at)} + {/if} +
+ + +
+ + {capsule.status} + +
+ + +
+ {#if capsule.status === 'running' || capsule.status === 'paused'} + + {/if} +
+
+ {/each} + {/if} +
+ + +
+
+ + + + + All systems operational +
+
@@ -285,50 +531,30 @@ templateSource="platform" /> - {#if destroyTarget} -
- -
{ if (!destroying) { destroyTarget = null; } }} - onkeydown={(e) => { if (e.key === 'Escape' && !destroying) { destroyTarget = null; } }} - >
-
-

Destroy Capsule

-

- Terminate {destroyTarget.id} and destroy all data inside it. This cannot be undone. -

- - {#if destroyError} -
- {destroyError} -
- {/if} - -
- - -
-
-
+ { destroyTarget = null; }} + ondestroyed={handleDestroyed} + destroyFn={destroyAdminCapsule} + /> {/if} + +{#snippet sortableHeader(label: string, key: SortKey)} + +{/snippet} diff --git a/frontend/src/routes/admin/capsules/[id]/+page.svelte b/frontend/src/routes/admin/capsules/[id]/+page.svelte index 11dfc46..2712421 100644 --- a/frontend/src/routes/admin/capsules/[id]/+page.svelte +++ b/frontend/src/routes/admin/capsules/[id]/+page.svelte @@ -6,6 +6,8 @@ import TerminalTab from '$lib/components/TerminalTab.svelte'; import FilesTab from '$lib/components/FilesTab.svelte'; import MetricsPanel from '$lib/components/MetricsPanel.svelte'; + import DestroyDialog from '$lib/components/DestroyDialog.svelte'; + import CopyButton from '$lib/components/CopyButton.svelte'; import { toast } from '$lib/toast.svelte'; import { getAdminCapsule, @@ -29,8 +31,6 @@ // Destroy dialog let showDestroy = $state(false); - let destroying = $state(false); - let destroyError = $state(null); // Snapshot dialog let showSnapshot = $state(false); @@ -53,19 +53,6 @@ capsuleLoading = false; } - async function handleDestroy() { - destroying = true; - destroyError = null; - const result = await destroyAdminCapsule(capsuleId); - if (result.ok) { - toast.success('Capsule destroyed'); - goto('/admin/capsules'); - } else { - destroyError = result.error; - } - destroying = false; - } - async function handleSnapshot() { snapshotting = true; snapshotError = null; @@ -108,13 +95,33 @@ let pollTimer: ReturnType | null = null; + function startPolling() { + stopPolling(); + pollTimer = setInterval(loadCapsule, 10_000); + } + + function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + } + + function handleVisibility() { + if (document.hidden) { + stopPolling(); + } else { + loadCapsule(); + startPolling(); + } + } + onMount(() => { loadCapsule(); - pollTimer = setInterval(loadCapsule, 10_000); + startPolling(); + document.addEventListener('visibilitychange', handleVisibility); }); onDestroy(() => { - if (pollTimer) clearInterval(pollTimer); + stopPolling(); + document.removeEventListener('visibilitychange', handleVisibility); }); @@ -143,56 +150,59 @@
{:else if capsule} - -
- - - Capsules - -
- {capsuleId} - - {#if capsule.status === 'running'} - - - - - {/if} - {capsule.status} - -
- {capsule.template} - / - {capsule.vcpus}v · {capsule.memory_mb}MB -
-
+ +
+
+ + Capsules + + + + {capsuleId} + + - {#if capsule.status === 'running' || capsule.status === 'paused'} - - - {/if} + {#if capsule.status === 'running'} + + + + + {/if} + {capsule.status} + + {capsule.template} · {capsule.vcpus}v · {capsule.memory_mb}MB + +
+ {#if capsule.status === 'running' || capsule.status === 'paused'} + + + {/if} +
+
+
+
@@ -214,6 +224,19 @@
{/if} + + +
+
+ + + + + All systems operational +
+
@@ -302,50 +325,10 @@ {/if} - -{#if showDestroy} -
- -
{ if (!destroying) showDestroy = false; }} - onkeydown={(e) => { if (e.key === 'Escape' && !destroying) showDestroy = false; }} - >
-
-

Destroy Capsule

-

- Terminate {capsuleId} and destroy all data inside it. This cannot be undone. -

- - {#if destroyError} -
- {destroyError} -
- {/if} - -
- - -
-
-
-{/if} + { showDestroy = false; }} + ondestroyed={() => { toast.success('Capsule destroyed'); goto('/admin/capsules'); }} + destroyFn={destroyAdminCapsule} +/>