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 @@ + + + +
+ +
+ {#if layout === 'full'} + {#if !metricsLoading} + + + Live + + {:else} +
+ {/if} + {:else} +
+ {/if} + +
+ {#each METRIC_RANGES as r, i} + + {/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/lib/components/TerminalTab.svelte b/frontend/src/lib/components/TerminalTab.svelte index 00df00a..ab3774a 100644 --- a/frontend/src/lib/components/TerminalTab.svelte +++ b/frontend/src/lib/components/TerminalTab.svelte @@ -6,9 +6,10 @@ capsuleId: string; isRunning: boolean; visible?: boolean; + apiBasePath?: string; }; - let { capsuleId, isRunning, visible = true }: Props = $props(); + let { capsuleId, isRunning, visible = true, apiBasePath = '/api/v1/capsules' }: Props = $props(); type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -93,7 +94,7 @@ function getWsUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ''; - return `${proto}//${window.location.host}/api/v1/capsules/${capsuleId}/pty${token}`; + return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty${token}`; } function wsSend(ws: WebSocket | null, data: string) { diff --git a/frontend/src/routes/admin/capsules/+page.svelte b/frontend/src/routes/admin/capsules/+page.svelte new file mode 100644 index 0000000..e304170 --- /dev/null +++ b/frontend/src/routes/admin/capsules/+page.svelte @@ -0,0 +1,560 @@ + + + + Wrenn Admin — Capsules + + + + +
+ + +
+ +
+
+
+

+ Capsules +

+

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

+
+ +
+ {#if !loading && runningCount > 0} +
+ + + + + {runningCount} + running +
+ {/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 +
+
+
+
+ + { showCreateDialog = false; }} + oncreated={handleCreated} + templateSource="platform" +/> + +{#if destroyTarget} + { 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 new file mode 100644 index 0000000..2712421 --- /dev/null +++ b/frontend/src/routes/admin/capsules/[id]/+page.svelte @@ -0,0 +1,334 @@ + + + + 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} +
+ +
+ {/if} + +
+ +
+
+
+ {/if} + + +
+
+ + + + + All systems operational +
+
+
+
+ + +{#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} + + { showDestroy = false; }} + ondestroyed={() => { toast.success('Capsule destroyed'); goto('/admin/capsules'); }} + destroyFn={destroyAdminCapsule} +/> 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 @@