1
0
forked from wrenn/wrenn

Merge pull request 'Completed template build for admins' (#21) from feat/admin-template-build into dev

Reviewed-on: wrenn/wrenn#21
This commit is contained in:
2026-04-11 21:41:18 +00:00
32 changed files with 1487 additions and 512 deletions

View File

@ -12,6 +12,7 @@ WRENN_HOST_LISTEN_ADDR=:50051
WRENN_DIR=/var/lib/wrenn WRENN_DIR=/var/lib/wrenn
WRENN_HOST_INTERFACE=eth0 WRENN_HOST_INTERFACE=eth0
WRENN_CP_URL=http://localhost:8080 WRENN_CP_URL=http://localhost:8080
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
# Lago (billing — external service) # Lago (billing — external service)
LAGO_API_URL=http://localhost:3000 LAGO_API_URL=http://localhost:3000

View File

@ -63,15 +63,28 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Expand base images to the standard disk size (sparse, no extra physical // Parse default rootfs size from env (e.g. "5G", "2Gi", "1000M").
defaultRootfsSizeMB := sandbox.DefaultDiskSizeMB
if sizeStr := os.Getenv("WRENN_DEFAULT_ROOTFS_SIZE"); sizeStr != "" {
parsed, err := sandbox.ParseSizeToMB(sizeStr)
if err != nil {
slog.Error("invalid WRENN_DEFAULT_ROOTFS_SIZE", "value", sizeStr, "error", err)
os.Exit(1)
}
defaultRootfsSizeMB = parsed
slog.Info("using custom rootfs size", "size_mb", defaultRootfsSizeMB)
}
// Expand base images to the configured disk size (sparse, no extra physical
// disk). This ensures dm-snapshot sandboxes see the full size from boot. // disk). This ensures dm-snapshot sandboxes see the full size from boot.
if err := sandbox.EnsureImageSizes(rootDir, sandbox.DefaultDiskSizeMB); err != nil { if err := sandbox.EnsureImageSizes(rootDir, defaultRootfsSizeMB); err != nil {
slog.Error("failed to expand base images", "error", err) slog.Error("failed to expand base images", "error", err)
os.Exit(1) os.Exit(1)
} }
cfg := sandbox.Config{ cfg := sandbox.Config{
WrennDir: rootDir, WrennDir: rootDir,
DefaultRootfsSizeMB: defaultRootfsSizeMB,
} }
mgr := sandbox.New(cfg) mgr := sandbox.New(cfg)

View File

@ -0,0 +1,17 @@
-- +goose Up
ALTER TABLE templates
ADD COLUMN default_user TEXT NOT NULL DEFAULT 'root',
ADD COLUMN default_env JSONB NOT NULL DEFAULT '{}';
ALTER TABLE template_builds
ADD COLUMN default_user TEXT NOT NULL DEFAULT 'root',
ADD COLUMN default_env JSONB NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE template_builds
DROP COLUMN default_env,
DROP COLUMN default_user;
ALTER TABLE templates
DROP COLUMN default_env,
DROP COLUMN default_user;

View File

@ -31,3 +31,8 @@ WHERE id = $1;
UPDATE template_builds UPDATE template_builds
SET error = $2, status = 'failed', completed_at = NOW() SET error = $2, status = 'failed', completed_at = NOW()
WHERE id = $1; WHERE id = $1;
-- name: UpdateBuildDefaults :exec
UPDATE template_builds
SET default_user = $2, default_env = $3
WHERE id = $1;

View File

@ -1,6 +1,6 @@
-- name: InsertTemplate :one -- name: InsertTemplate :one
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; RETURNING *;
-- name: GetTemplate :one -- name: GetTemplate :one

View File

@ -1,4 +1,4 @@
import { apiFetch, type ApiResult } from '$lib/api/client'; import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
export type BuildLogEntry = { export type BuildLogEntry = {
step: number; step: number;
@ -26,6 +26,8 @@ export type Build = {
error?: string; error?: string;
sandbox_id?: string; sandbox_id?: string;
host_id?: string; host_id?: string;
default_user: string;
default_env: Record<string, string>;
created_at: string; created_at: string;
started_at?: string; started_at?: string;
completed_at?: string; completed_at?: string;
@ -39,9 +41,18 @@ export type CreateBuildParams = {
vcpus?: number; vcpus?: number;
memory_mb?: number; memory_mb?: number;
skip_pre_post?: boolean; skip_pre_post?: boolean;
archive?: File;
}; };
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> { export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
if (params.archive) {
// Use multipart when an archive file is provided.
const { archive, ...config } = params;
const formData = new FormData();
formData.append('config', JSON.stringify(config));
formData.append('archive', archive);
return apiFetchMultipart('POST', '/api/v1/admin/builds', formData);
}
return apiFetch('POST', '/api/v1/admin/builds', params); return apiFetch('POST', '/api/v1/admin/builds', params);
} }

View File

@ -22,3 +22,24 @@ export async function apiFetch<T>(method: string, path: string, body?: unknown):
return { ok: false, error: 'Unable to connect to the server' }; return { ok: false, error: 'Unable to connect to the server' };
} }
} }
export async function apiFetchMultipart<T>(method: string, path: string, formData: FormData): Promise<ApiResult<T>> {
try {
const headers: Record<string, string> = {};
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(path, {
method,
headers,
body: formData
});
if (res.status === 204) return { ok: true, data: undefined as T };
const data = await res.json();
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
return { ok: true, data: data as T };
} catch {
return { ok: false, error: 'Unable to connect to the server' };
}
}

View File

@ -22,8 +22,8 @@
}; };
const managementItems: NavItem[] = [ const managementItems: NavItem[] = [
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' }, { label: 'Templates', icon: IconTemplate, href: '/admin/templates' },
{ label: 'Templates', icon: IconTemplate, href: '/admin/templates' } { label: 'Hosts', icon: IconServer, href: '/admin/hosts' }
]; ];
function isActive(href: string): boolean { function isActive(href: string): boolean {

View File

@ -49,7 +49,7 @@
const platformItems: NavItem[] = [ const platformItems: NavItem[] = [
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' }, { label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }, { label: 'Templates', icon: IconBox, href: '/dashboard/templates' },
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' } { label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
]; ];

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
onMount(() => goto('/admin/hosts', { replaceState: true })); onMount(() => goto('/admin/templates', { replaceState: true }));
</script> </script>

View File

@ -168,45 +168,48 @@
<main class="flex min-w-0 flex-1 flex-col overflow-hidden"> <main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header --> <!-- Header -->
<header class="flex shrink-0 flex-col gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6 py-5"> <header class="relative shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]">
<div class="flex items-start justify-between"> <!-- Subtle gradient wash behind header for depth -->
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-accent)]/[0.02] to-transparent pointer-events-none"></div>
<div class="relative flex items-start justify-between px-8 pt-7 pb-5">
<div> <div>
<h1 class="font-serif text-[1.75rem] leading-none tracking-[-0.03em] text-[var(--color-text-bright)]"> <h1 class="font-serif text-page leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
Hosts Hosts
</h1> </h1>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Platform and BYOC compute across all teams. Platform and BYOC compute across all teams.
</p> </p>
</div> </div>
{#if activeTab === 'platform'} {#if activeTab === 'platform'}
<button <button
onclick={() => { showCreate = true; createError = null; createForm = { provider: '', availability_zone: '' }; }} onclick={() => { showCreate = true; createError = null; createForm = { provider: '', availability_zone: '' }; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0" class="group flex items-center gap-2.5 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform duration-200 group-hover:rotate-90"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Host Add Host
</button> </button>
{/if} {/if}
</div> </div>
<!-- Stat pills --> <!-- Stat strip — bolder presence -->
{#if !loading && !error} {#if !loading && !error}
<div class="flex items-center gap-2"> <div class="relative flex items-center gap-3 px-8 pb-5">
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1"> <div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{totalCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{totalCount}</span>
<span class="text-label text-[var(--color-text-muted)]">total</span> <span class="text-label text-[var(--color-text-muted)]">total</span>
</div> </div>
<div class="flex items-baseline gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 gap-2">
<span class="relative mt-px flex h-1.5 w-1.5 shrink-0 self-center"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-accent)] opacity-60"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)] opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span> <span class="relative inline-flex h-2 w-2 rounded-full bg-[var(--color-accent)]"></span>
</span> </span>
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-accent-bright)]">{onlineCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-accent-bright)]">{onlineCount}</span>
<span class="text-label text-[var(--color-accent-bright)]/70">online</span> <span class="text-label text-[var(--color-accent-bright)]/70">online</span>
</div> </div>
{#if pendingCount > 0} {#if pendingCount > 0}
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-amber)]">{pendingCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-amber)]">{pendingCount}</span>
<span class="text-label text-[var(--color-amber)]/70">pending</span> <span class="text-label text-[var(--color-amber)]/70">pending</span>
</div> </div>
{/if} {/if}
@ -214,30 +217,32 @@
{/if} {/if}
</header> </header>
<!-- Tabs --> <!-- Tabs — heavier presence -->
<div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6"> <div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-8">
{#each [['platform', 'Platform', platformHosts.length], ['byoc', 'BYOC', byocHosts.length]] as [id, label, count] (id)} {#each [['platform', 'Platform', platformHosts.length], ['byoc', 'BYOC', byocHosts.length]] as [id, label, count] (id)}
<button <button
onclick={() => { activeTab = id as 'platform' | 'byoc'; if (id === 'byoc') byocPage = 0; }} onclick={() => { activeTab = id as 'platform' | 'byoc'; if (id === 'byoc') byocPage = 0; }}
class="relative py-3 pr-5 text-ui transition-colors duration-150 {activeTab === id class="tab-button {activeTab === id
? 'font-medium text-[var(--color-text-bright)]' ? 'text-[var(--color-text-bright)] font-medium'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}" : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}"
> >
{label} {label}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-5 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
{#if !loading} {#if !loading}
<span class="ml-2 rounded-full bg-[var(--color-bg-4)] px-1.5 py-0.5 text-label text-[var(--color-text-muted)]"> <span class="ml-2 rounded-full px-2 py-0.5 text-label tabular-nums transition-colors duration-200 {activeTab === id
? 'bg-[var(--color-accent)]/12 text-[var(--color-accent-bright)]'
: 'bg-[var(--color-bg-4)] text-[var(--color-text-muted)]'}">
{count} {count}
</span> </span>
{/if} {/if}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-0 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto px-8 py-6">
{#if loading} {#if loading}
{@render skeletonRows()} {@render skeletonRows()}
{:else if error} {:else if error}
@ -251,16 +256,16 @@
{#if byocHosts.length === 0} {#if byocHosts.length === 0}
{@render emptyState('byoc')} {@render emptyState('byoc')}
{:else} {:else}
<div class="space-y-5"> <div class="space-y-6">
{#each byocGroups as group (group.teamId ?? '__none__')} {#each byocGroups as group (group.teamId ?? '__none__')}
{@const groupPageHosts = byocPageHosts.filter(h => h.team_id === group.teamId || (group.teamId === null && !h.team_id))} {@const groupPageHosts = byocPageHosts.filter(h => h.team_id === group.teamId || (group.teamId === null && !h.team_id))}
{#if groupPageHosts.length > 0} {#if groupPageHosts.length > 0}
<div> <div>
<div class="mb-2.5 flex items-center gap-2.5"> <div class="mb-3 flex items-center gap-2.5">
<span class="text-label font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]"> <span class="text-label font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{group.teamName} {group.teamName}
</span> </span>
<span class="rounded-full bg-[var(--color-bg-3)] px-1.5 py-0.5 font-mono text-label text-[var(--color-text-muted)]"> <span class="rounded-full bg-[var(--color-bg-3)] px-2 py-0.5 font-mono text-label tabular-nums text-[var(--color-text-muted)]">
{group.hosts.length} {group.hosts.length}
</span> </span>
</div> </div>
@ -271,22 +276,22 @@
<!-- Pagination --> <!-- Pagination -->
{#if byocPageCount > 1} {#if byocPageCount > 1}
<div class="flex items-center justify-between pt-2"> <div class="flex items-center justify-between pt-3">
<span class="text-meta text-[var(--color-text-muted)]"> <span class="text-meta text-[var(--color-text-muted)]">
Page {byocPage + 1} of {byocPageCount} · {byocHosts.length} hosts Page <span class="font-mono tabular-nums">{byocPage + 1}</span> of <span class="font-mono tabular-nums">{byocPageCount}</span> · <span class="font-mono tabular-nums">{byocHosts.length}</span> hosts
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
onclick={() => (byocPage = Math.max(0, byocPage - 1))} onclick={() => (byocPage = Math.max(0, byocPage - 1))}
disabled={byocPage === 0} disabled={byocPage === 0}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40" class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3.5 py-1.5 text-meta text-[var(--color-text-secondary)] transition-all duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
← Previous ← Previous
</button> </button>
<button <button
onclick={() => (byocPage = Math.min(byocPageCount - 1, byocPage + 1))} onclick={() => (byocPage = Math.min(byocPageCount - 1, byocPage + 1))}
disabled={byocPage >= byocPageCount - 1} disabled={byocPage >= byocPageCount - 1}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40" class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3.5 py-1.5 text-meta text-[var(--color-text-secondary)] transition-all duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
Next → Next →
</button> </button>
@ -301,34 +306,34 @@
</div> </div>
{#snippet skeletonRows()} {#snippet skeletonRows()}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th> <th class="table-header">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th> <th class="table-header">Status</th>
<th class="hidden px-4 py-3 md:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Specs</th> <th class="table-header hidden md:table-cell">Specs</th>
<th class="hidden px-4 py-3 lg:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Last Heartbeat</th> <th class="table-header hidden lg:table-cell">Last Heartbeat</th>
<th class="px-4 py-3"></th> <th class="table-header w-20"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each Array(5) as _, i} {#each Array(5) as _, i}
<tr class="border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms"> <tr class="table-row-animate border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms">
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="skeleton mb-1.5 h-3 w-28 rounded"></div> <div class="skeleton mb-1.5 h-3 w-28 rounded"></div>
<div class="skeleton h-2.5 w-20 rounded"></div> <div class="skeleton h-2.5 w-20 rounded"></div>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="skeleton h-3 w-16 rounded-full"></div> <div class="skeleton h-3 w-16 rounded-full"></div>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<div class="skeleton h-3 w-24 rounded"></div> <div class="skeleton h-3 w-24 rounded"></div>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<div class="skeleton h-3 w-20 rounded"></div> <div class="skeleton h-3 w-20 rounded"></div>
</td> </td>
<td class="px-4 py-3.5 text-right"> <td class="px-5 py-3.5 text-right">
<div class="skeleton ml-auto h-6 w-14 rounded"></div> <div class="skeleton ml-auto h-6 w-14 rounded"></div>
</td> </td>
</tr> </tr>
@ -342,26 +347,28 @@
{#if hosts.length === 0} {#if hosts.length === 0}
{@render emptyState('platform')} {@render emptyState('platform')}
{:else} {:else}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th> <th class="table-header">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th> <th class="table-header">Status</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Specs</th> <th class="table-header hidden md:table-cell">Specs</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Last Heartbeat</th> <th class="table-header hidden lg:table-cell">Last Heartbeat</th>
<th class="px-4 py-3"></th> <th class="table-header w-20"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each hosts as host (host.id)} {#each hosts as host, i (host.id)}
<tr <tr
class="row-entry border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 class="table-row-animate border-b border-[var(--color-border)] last:border-0 transition-colors duration-200
{host.id === newHostId ? 'new-row' : ''} {host.id === newHostId ? 'new-row' : ''}
{flashHostId === host.id ? 'bg-[var(--color-accent-glow)]' : 'hover:bg-[var(--color-bg-2)]'}" {flashHostId === host.id ? 'bg-[var(--color-accent-glow)]' : 'hover:bg-[var(--color-bg-2)]'}
{host.status === 'online' ? 'host-row-online' : ''}"
style="animation-delay: {i * 30}ms"
> >
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="font-mono text-meta text-[var(--color-text-primary)]">{host.id}</div> <div class="font-mono text-ui font-medium text-[var(--color-text-bright)]">{host.id}</div>
{#if host.address} {#if host.address}
<div class="mt-0.5 font-mono text-label text-[var(--color-text-muted)]">{host.address}</div> <div class="mt-0.5 font-mono text-label text-[var(--color-text-muted)]">{host.address}</div>
{/if} {/if}
@ -371,31 +378,31 @@
</div> </div>
{/if} {/if}
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<span class="flex items-center gap-1.5 text-meta font-medium" style="color: {statusColor(host.status)}"> <span class="flex items-center gap-2 text-meta font-semibold" style="color: {statusColor(host.status)}">
{#if host.status === 'online'} {#if host.status === 'online'}
<span class="relative flex h-1.5 w-1.5 shrink-0"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background: {statusColor(host.status)}"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full opacity-60" style="background: {statusColor(host.status)}"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background: {statusColor(host.status)}"></span> <span class="relative inline-flex h-2 w-2 rounded-full" style="background: {statusColor(host.status)}"></span>
</span> </span>
{:else} {:else}
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background: {statusColor(host.status)}"></span> <span class="h-2 w-2 shrink-0 rounded-full" style="background: {statusColor(host.status)}"></span>
{/if} {/if}
{host.status} {host.status}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<span class="text-meta text-[var(--color-text-secondary)]">{formatSpecs(host)}</span> <span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">{formatSpecs(host)}</span>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={host.last_heartbeat_at ? formatDate(host.last_heartbeat_at) : undefined}> <span class="text-meta text-[var(--color-text-muted)]" title={host.last_heartbeat_at ? formatDate(host.last_heartbeat_at) : undefined}>
{host.last_heartbeat_at ? timeAgo(host.last_heartbeat_at) : '—'} {host.last_heartbeat_at ? timeAgo(host.last_heartbeat_at) : '—'}
</span> </span>
</td> </td>
<td class="px-4 py-3.5 text-right"> <td class="px-5 py-3.5 text-right">
<button <button
onclick={() => openDeleteConfirm(host)} onclick={() => openDeleteConfirm(host)}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]" class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
> >
Delete Delete
</button> </button>
@ -409,18 +416,31 @@
{/snippet} {/snippet}
{#snippet emptyState(type: 'platform' | 'byoc')} {#snippet emptyState(type: 'platform' | 'byoc')}
<div class="flex flex-col items-center justify-center py-24 text-center"> <div class="flex flex-col items-center justify-center py-28 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]"> <!-- Floating icon with glow ring -->
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> <div class="relative mb-7">
<div class="absolute -inset-3 rounded-2xl bg-[var(--color-accent-glow)] blur-xl"></div>
<div class="empty-icon-float relative flex h-18 w-18 items-center justify-center rounded-xl border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-card">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
</div> </div>
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]"> <p class="font-serif text-heading leading-snug text-[var(--color-text-secondary)]">
{type === 'platform' ? 'No platform hosts yet.' : 'No BYOC hosts across any team.'} {type === 'platform' ? 'No platform hosts yet' : 'No BYOC hosts across any team'}
</p> </p>
<p class="mt-1.5 text-ui text-[var(--color-text-muted)]"> <p class="mt-2 max-w-[320px] text-ui text-[var(--color-text-muted)]">
{type === 'platform' {type === 'platform'
? 'Add a host to start scheduling capsules onto your own compute.' ? 'Add a host to start scheduling capsules onto your own compute.'
: 'Teams that register their own compute will appear here.'} : 'Teams that register their own compute will appear here.'}
</p> </p>
{#if type === 'platform'}
<button
onclick={() => { showCreate = true; createError = null; createForm = { provider: '', availability_zone: '' }; }}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 px-4 py-2 text-ui font-medium text-[var(--color-accent-bright)] transition-all duration-200 hover:bg-[var(--color-accent)]/20 hover:border-[var(--color-accent)]/50"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add your first host
</button>
{/if}
</div> </div>
{/snippet} {/snippet}
@ -435,10 +455,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
></div> ></div>
<div <div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Top accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-accent)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Add Platform Host Add Platform Host
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -491,7 +515,7 @@
<button <button
onclick={handleCreatePlatform} onclick={handleCreatePlatform}
disabled={creating} disabled={creating}
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" class="group flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
> >
{#if creating} {#if creating}
<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> <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>
@ -501,6 +525,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -510,11 +535,15 @@
<div class="fixed inset-0 z-50 flex items-center justify-center"> <div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60"></div> <div class="absolute inset-0 bg-black/60"></div>
<div <div
class="relative w-full max-w-[500px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[500px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<!-- Success accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-accent-bright)] to-transparent"></div>
<div class="p-6">
<!-- Animated checkmark --> <!-- Animated checkmark -->
<div class="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-accent-glow)]"> <div class="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-accent-glow-mid)]">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-bright)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-bright)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline <polyline
points="20 6 9 17 4 12" points="20 6 9 17 4 12"
@ -524,7 +553,7 @@
</svg> </svg>
</div> </div>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Host registered Host registered
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -558,11 +587,12 @@
<div class="mt-6"> <div class="mt-6">
<button <button
onclick={closeTokenReveal} onclick={closeTokenReveal}
class="w-full rounded-[var(--radius-button)] bg-[var(--color-bg-4)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-5)]" class="w-full rounded-[var(--radius-button)] bg-[var(--color-bg-4)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-primary)] transition-all duration-150 hover:bg-[var(--color-bg-5)]"
> >
Done Done
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -578,10 +608,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }}
></div> ></div>
<div <div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Danger accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-red)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Delete Host Delete Host
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -621,7 +655,7 @@
<button <button
onclick={handleDelete} onclick={handleDelete}
disabled={deleting || deletePreviewLoading} disabled={deleting || deletePreviewLoading}
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 disabled:opacity-50" class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_16px_rgba(207,129,114,0.25)] hover:brightness-110 disabled:opacity-50"
> >
{#if deleting} {#if deleting}
<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> <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>
@ -631,6 +665,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -676,4 +711,54 @@
.checkmark-drawn { .checkmark-drawn {
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
/* Stat pill — shared base */
.stat-pill {
display: flex;
align-items: baseline;
gap: 6px;
border-radius: var(--radius-button);
border-width: 1px;
padding: 6px 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.stat-pill:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
/* Table header */
.table-header {
padding: 10px 20px;
text-align: left;
font-size: var(--text-label);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-tertiary);
}
/* Staggered row entrance */
.table-row-animate {
animation: fadeUp 0.25s ease both;
}
/* Tab button */
.tab-button {
position: relative;
padding: 14px 20px 14px 0;
font-size: var(--text-ui);
transition: color 0.15s ease;
cursor: pointer;
}
/* Online host row — subtle left accent */
.host-row-online {
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* Empty state icon float */
.empty-icon-float {
animation: iconFloat 3s ease-in-out infinite;
}
</style> </style>

View File

@ -56,7 +56,8 @@
memory_mb: 512, memory_mb: 512,
recipe: '', recipe: '',
healthcheck: '', healthcheck: '',
skip_pre_post: false skip_pre_post: false,
archive: null as File | null
}); });
let creating = $state(false); let creating = $state(false);
let createError = $state<string | null>(null); let createError = $state<string | null>(null);
@ -131,12 +132,13 @@
healthcheck: createForm.healthcheck.trim() || undefined, healthcheck: createForm.healthcheck.trim() || undefined,
vcpus: createForm.vcpus, vcpus: createForm.vcpus,
memory_mb: createForm.memory_mb, memory_mb: createForm.memory_mb,
skip_pre_post: createForm.skip_pre_post skip_pre_post: createForm.skip_pre_post,
archive: createForm.archive || undefined
}); });
if (result.ok) { if (result.ok) {
showCreate = false; showCreate = false;
createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false }; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null };
builds = [result.data, ...builds]; builds = [result.data, ...builds];
activeTab = 'builds'; activeTab = 'builds';
expandedBuildId = result.data.id; expandedBuildId = result.data.id;
@ -235,6 +237,8 @@
case 'RUN': return 'var(--color-blue)'; case 'RUN': return 'var(--color-blue)';
case 'START': return 'var(--color-accent-bright)'; case 'START': return 'var(--color-accent-bright)';
case 'ENV': return 'var(--color-amber)'; case 'ENV': return 'var(--color-amber)';
case 'USER': return 'var(--color-accent)';
case 'COPY': return 'var(--color-text-bright)';
case 'WORKDIR': return 'var(--color-text-tertiary)'; case 'WORKDIR': return 'var(--color-text-tertiary)';
default: return 'var(--color-text-muted)'; default: return 'var(--color-text-muted)';
} }
@ -266,47 +270,50 @@
<main class="flex min-w-0 flex-1 flex-col overflow-hidden"> <main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header --> <!-- Header -->
<header class="flex shrink-0 flex-col gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6 py-5"> <header class="relative shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]">
<div class="flex items-start justify-between"> <!-- Subtle gradient wash behind header for depth -->
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-accent)]/[0.02] to-transparent pointer-events-none"></div>
<div class="relative flex items-start justify-between px-8 pt-7 pb-5">
<div> <div>
<h1 class="font-serif text-[1.75rem] leading-none tracking-[-0.03em] text-[var(--color-text-bright)]"> <h1 class="font-serif text-page leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
Templates Templates
</h1> </h1>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Build and manage global templates available to all teams. Build and manage global templates available to all teams.
</p> </p>
</div> </div>
<button <button
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false }; }} onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null }; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0" class="group flex items-center gap-2.5 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform duration-200 group-hover:rotate-90"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Template Create Template
</button> </button>
</div> </div>
<!-- Stat pills --> <!-- Stat strip — generous horizontal spacing, bolder presence -->
{#if !templatesLoading && !templatesError} {#if !templatesLoading && !templatesError}
<div class="flex items-center gap-2"> <div class="relative flex items-center gap-3 px-8 pb-5">
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1"> <div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{templateCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{templateCount}</span>
<span class="text-label text-[var(--color-text-muted)]">templates</span> <span class="text-label text-[var(--color-text-muted)]">templates</span>
</div> </div>
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1"> <div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{baseCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{baseCount}</span>
<span class="text-label text-[var(--color-text-muted)]">base</span> <span class="text-label text-[var(--color-text-muted)]">base</span>
</div> </div>
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-accent-bright)]">{snapshotCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-accent-bright)]">{snapshotCount}</span>
<span class="text-label text-[var(--color-accent-bright)]/70">snapshots</span> <span class="text-label text-[var(--color-accent-bright)]/70">snapshots</span>
</div> </div>
{#if runningBuilds > 0} {#if runningBuilds > 0}
<div class="flex items-baseline gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 gap-2">
<span class="relative mt-px flex h-1.5 w-1.5 shrink-0 self-center"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-blue)] opacity-60"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-blue)] opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-blue)]"></span> <span class="relative inline-flex h-2 w-2 rounded-full bg-[var(--color-blue)]"></span>
</span> </span>
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-blue)]">{runningBuilds}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-blue)]">{runningBuilds}</span>
<span class="text-label text-[var(--color-blue)]/70">building</span> <span class="text-label text-[var(--color-blue)]/70">building</span>
</div> </div>
{/if} {/if}
@ -314,30 +321,32 @@
{/if} {/if}
</header> </header>
<!-- Tabs --> <!-- Tabs — heavier presence -->
<div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6"> <div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-8">
{#each [['templates', 'Templates', templateCount], ['builds', 'Builds', builds.length]] as [id, label, count] (id)} {#each [['templates', 'Templates', templateCount], ['builds', 'Builds', builds.length]] as [id, label, count] (id)}
<button <button
onclick={() => { activeTab = id as 'templates' | 'builds'; }} onclick={() => { activeTab = id as 'templates' | 'builds'; }}
class="relative py-3 pr-5 text-ui transition-colors duration-150 {activeTab === id class="tab-button {activeTab === id
? 'font-medium text-[var(--color-text-bright)]' ? 'text-[var(--color-text-bright)] font-medium'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}" : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}"
> >
{label} {label}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-5 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
{#if !templatesLoading} {#if !templatesLoading}
<span class="ml-2 rounded-full bg-[var(--color-bg-4)] px-1.5 py-0.5 text-label text-[var(--color-text-muted)]"> <span class="ml-2 rounded-full px-2 py-0.5 text-label tabular-nums transition-colors duration-200 {activeTab === id
? 'bg-[var(--color-accent)]/12 text-[var(--color-accent-bright)]'
: 'bg-[var(--color-bg-4)] text-[var(--color-text-muted)]'}">
{count} {count}
</span> </span>
{/if} {/if}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-0 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto px-8 py-6">
{#if activeTab === 'templates'} {#if activeTab === 'templates'}
{#if templatesLoading} {#if templatesLoading}
{@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])} {@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])}
@ -370,20 +379,20 @@
<!-- ── Snippets ─────────────────────────────────────────────────────── --> <!-- ── Snippets ─────────────────────────────────────────────────────── -->
{#snippet skeletonRows(count: number, headers: string[])} {#snippet skeletonRows(count: number, headers: string[])}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
{#each headers as h} {#each headers as h}
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">{h}</th> <th class="table-header">{h}</th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each Array(count) as _, i} {#each Array(count) as _, i}
<tr class="border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms"> <tr class="border-b border-[var(--color-border)] last:border-0 table-row-animate" style="animation-delay: {i * 60}ms">
{#each headers as _h, j} {#each headers as _h, j}
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="skeleton h-3 rounded" style="width: {60 + j * 12}px"></div> <div class="skeleton h-3 rounded" style="width: {60 + j * 12}px"></div>
</td> </td>
{/each} {/each}
@ -395,81 +404,99 @@
{/snippet} {/snippet}
{#snippet emptyState(type: 'templates' | 'builds')} {#snippet emptyState(type: 'templates' | 'builds')}
<div class="flex flex-col items-center justify-center py-24 text-center"> <div class="flex flex-col items-center justify-center py-28 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]"> <!-- Floating icon with glow ring -->
{#if type === 'templates'} <div class="relative mb-7">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> <div class="absolute -inset-3 rounded-2xl bg-[var(--color-accent-glow)] blur-xl"></div>
{:else} <div class="empty-icon-float relative flex h-18 w-18 items-center justify-center rounded-xl border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-card">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="m16 13-3.5 3.5-2-2L8 17"/></svg> {#if type === 'templates'}
{/if} <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
{:else}
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="m16 13-3.5 3.5-2-2L8 17"/></svg>
{/if}
</div>
</div> </div>
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]"> <p class="font-serif text-heading leading-snug text-[var(--color-text-secondary)]">
{type === 'templates' ? 'No templates yet.' : 'No builds yet.'} {type === 'templates' ? 'No templates yet' : 'No builds yet'}
</p> </p>
<p class="mt-1.5 text-ui text-[var(--color-text-muted)]"> <p class="mt-2 max-w-[320px] text-ui text-[var(--color-text-muted)]">
{type === 'templates' {type === 'templates'
? 'Create a template to provide pre-configured environments for all teams.' ? 'Create a template to provide pre-configured environments for all teams.'
: 'Start a template build to see progress and logs here.'} : 'Start a template build to see progress and logs here.'}
</p> </p>
{#if type === 'templates'}
<button
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null }; }}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 px-4 py-2 text-ui font-medium text-[var(--color-accent-bright)] transition-all duration-200 hover:bg-[var(--color-accent)]/20 hover:border-[var(--color-accent)]/50"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create your first template
</button>
{/if}
</div> </div>
{/snippet} {/snippet}
{#snippet templatesTable()} {#snippet templatesTable()}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</th> <th class="table-header">Name</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Type</th> <th class="table-header">Type</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Specs</th> <th class="table-header hidden md:table-cell">Specs</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Size</th> <th class="table-header hidden lg:table-cell">Size</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Created</th> <th class="table-header hidden lg:table-cell">Created</th>
<th class="px-4 py-3"></th> <th class="table-header w-20"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each templates as tmpl (tmpl.name)} {#each templates as tmpl, i (tmpl.name)}
<tr class="border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]"> <tr
<td class="px-4 py-3.5"> class="table-row-animate border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]"
<div class="flex items-center gap-1.5"> style="animation-delay: {i * 30}ms"
<span class="font-mono text-meta text-[var(--color-text-primary)]">{tmpl.name}</span> >
<td class="px-5 py-3.5">
<div class="flex items-center gap-2">
<span class="font-mono text-ui font-medium text-[var(--color-text-bright)]">{tmpl.name}</span>
<CopyButton value={tmpl.name} /> <CopyButton value={tmpl.name} />
</div> </div>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
{#if tmpl.type === 'snapshot'} {#if tmpl.type === 'snapshot'}
<span class="inline-flex items-center rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2 py-0.5 text-label font-medium text-[var(--color-accent-bright)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-0.5 text-label font-medium text-[var(--color-accent-bright)]">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
snapshot snapshot
</span> </span>
{:else} {:else}
<span class="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2 py-0.5 text-label font-medium text-[var(--color-text-secondary)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-0.5 text-label font-medium text-[var(--color-text-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-text-muted)]"></span>
base base
</span> </span>
{/if} {/if}
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
{#if tmpl.vcpus && tmpl.memory_mb} {#if tmpl.vcpus && tmpl.memory_mb}
<span class="text-meta text-[var(--color-text-secondary)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
{tmpl.vcpus} vCPU · {tmpl.memory_mb} MB {tmpl.vcpus} vCPU · {tmpl.memory_mb} MB
</span> </span>
{:else} {:else}
<span class="text-meta text-[var(--color-text-muted)]"></span> <span class="text-meta text-[var(--color-text-muted)]"></span>
{/if} {/if}
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-muted)]">
{tmpl.size_bytes ? formatBytes(tmpl.size_bytes) : '—'} {tmpl.size_bytes ? formatBytes(tmpl.size_bytes) : '—'}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={formatDate(tmpl.created_at)}> <span class="text-meta text-[var(--color-text-muted)]" title={formatDate(tmpl.created_at)}>
{timeAgo(tmpl.created_at)} {timeAgo(tmpl.created_at)}
</span> </span>
</td> </td>
<td class="px-4 py-3.5 text-right"> <td class="px-5 py-3.5 text-right">
<button <button
onclick={() => { deleteTarget = tmpl; deleteError = null; }} onclick={() => { deleteTarget = tmpl; deleteError = null; }}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]" class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
> >
Delete Delete
</button> </button>
@ -482,28 +509,30 @@
{/snippet} {/snippet}
{#snippet buildsTable()} {#snippet buildsTable()}
<div class="space-y-0 rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Build</th> <th class="table-header">Build</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</th> <th class="table-header">Name</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Base</th> <th class="table-header hidden md:table-cell">Base</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th> <th class="table-header">Status</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Progress</th> <th class="table-header hidden md:table-cell">Progress</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Started</th> <th class="table-header hidden lg:table-cell">Started</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Duration</th> <th class="table-header hidden lg:table-cell">Duration</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each builds as build (build.id)} {#each builds as build, i (build.id)}
<tr <tr
class="border-b border-[var(--color-border)] last:border-0 cursor-pointer transition-colors duration-200 class="table-row-animate border-b border-[var(--color-border)] last:border-0 cursor-pointer transition-colors duration-200
{expandedBuildId === build.id ? 'bg-[var(--color-bg-2)]' : 'hover:bg-[var(--color-bg-2)]'}" {expandedBuildId === build.id ? 'bg-[var(--color-bg-2)]' : 'hover:bg-[var(--color-bg-2)]'}
{build.status === 'running' ? 'build-row-active' : ''}"
style="animation-delay: {i * 30}ms"
onclick={() => toggleBuildExpand(build.id)} onclick={() => toggleBuildExpand(build.id)}
> >
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2.5">
<svg <svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
@ -514,49 +543,51 @@
<span class="font-mono text-meta text-[var(--color-text-primary)]">{build.id}</span> <span class="font-mono text-meta text-[var(--color-text-primary)]">{build.id}</span>
</div> </div>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<span class="text-meta text-[var(--color-text-primary)]">{build.name}</span> <span class="text-ui font-medium text-[var(--color-text-primary)]">{build.name}</span>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]">{build.base_template}</span> <span class="font-mono text-meta text-[var(--color-text-muted)]">{build.base_template}</span>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<span class="flex items-center gap-1.5 text-meta font-medium" style="color: {statusColor(build.status)}"> <span class="flex items-center gap-2 text-meta font-semibold" style="color: {statusColor(build.status)}">
{#if build.status === 'running'} {#if build.status === 'running'}
<span class="relative flex h-1.5 w-1.5 shrink-0"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background: {statusColor(build.status)}"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full opacity-60" style="background: {statusColor(build.status)}"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background: {statusColor(build.status)}"></span> <span class="relative inline-flex h-2 w-2 rounded-full" style="background: {statusColor(build.status)}"></span>
</span> </span>
{:else if build.status === 'success'} {:else if build.status === 'success'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{:else if build.status === 'failed'} {:else if build.status === 'failed'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{:else} {:else}
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background: {statusColor(build.status)}"></span> <span class="h-2 w-2 shrink-0 rounded-full" style="background: {statusColor(build.status)}"></span>
{/if} {/if}
{build.status} {build.status}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <div class="flex items-center gap-3">
{build.current_step} / {build.total_steps} <span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
</span> {build.current_step}/{build.total_steps}
{#if build.status === 'running' && build.total_steps > 0} </span>
<div class="mt-1.5 h-1 w-20 overflow-hidden rounded-full bg-[var(--color-bg-4)]"> {#if build.total_steps > 0}
<div <div class="relative h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-bg-4)]">
class="h-full rounded-full bg-[var(--color-blue)] transition-all duration-500" <div
style="width: {(build.current_step / build.total_steps) * 100}%" class="h-full rounded-full transition-all duration-700 ease-out {build.status === 'running' ? 'progress-bar-glow' : ''}"
></div> style="width: {(build.current_step / build.total_steps) * 100}%; background: {statusColor(build.status)}"
</div> ></div>
{/if} </div>
{/if}
</div>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={formatDate(build.started_at)}> <span class="text-meta text-[var(--color-text-muted)]" title={formatDate(build.started_at)}>
{build.started_at ? timeAgo(build.started_at) : '—'} {build.started_at ? timeAgo(build.started_at) : '—'}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-muted)]">
{formatDuration(build.started_at, build.completed_at)} {formatDuration(build.started_at, build.completed_at)}
</span> </span>
</td> </td>
@ -565,7 +596,7 @@
{#if expandedBuildId === build.id} {#if expandedBuildId === build.id}
<tr> <tr>
<td colspan="7" class="border-b border-[var(--color-border)] last:border-0"> <td colspan="7" class="border-b border-[var(--color-border)] last:border-0">
<div class="bg-[var(--color-bg-0)] px-6 py-4" style="animation: fadeUp 0.15s ease both"> <div class="bg-[var(--color-bg-0)] px-6 py-5" style="animation: fadeUp 0.15s ease both">
{#if build.status === 'pending' || build.status === 'running'} {#if build.status === 'pending' || build.status === 'running'}
<div class="mb-4 flex justify-end"> <div class="mb-4 flex justify-end">
<button <button
@ -608,7 +639,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-red)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-red)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{/if} {/if}
<span class="shrink-0 text-label font-semibold text-[var(--color-text-tertiary)]">{phaseLabel}</span> <span class="shrink-0 text-label font-semibold text-[var(--color-text-tertiary)]">{phaseLabel}</span>
<code class="flex-1 truncate font-mono text-meta"><span style="color: {keywordColor(kw)}">{kw}</span>{#if kwRest}<span class="text-[var(--color-text-secondary)]"> {kwRest}</span>{/if}</code> <code class="flex-1 truncate font-mono text-meta"><span style="color: {keywordColor(kw)}">{kw}</span>{#if kwRest}{' '}<span class="text-[var(--color-text-secondary)]">{kwRest}</span>{/if}</code>
<span class="shrink-0 font-mono text-label text-[var(--color-text-muted)]">{log.elapsed_ms}ms</span> <span class="shrink-0 font-mono text-label text-[var(--color-text-muted)]">{log.elapsed_ms}ms</span>
{#if log.exit !== 0} {#if log.exit !== 0}
<span class="shrink-0 rounded-full bg-[var(--color-red)]/10 px-1.5 py-0.5 font-mono text-label text-[var(--color-red)]"> <span class="shrink-0 rounded-full bg-[var(--color-red)]/10 px-1.5 py-0.5 font-mono text-label text-[var(--color-red)]">
@ -661,13 +692,16 @@
<!-- Recipe reference --> <!-- Recipe reference -->
{#if build.recipe && build.recipe.length > 0} {#if build.recipe && build.recipe.length > 0}
<div class="mt-4 border-t border-[var(--color-border)] pt-4"> <div class="mt-4 border-t border-[var(--color-border)] pt-4">
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Recipe</span> <div class="flex items-center gap-1.5">
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Recipe</span>
<CopyButton value={build.recipe.join('\n')} />
</div>
<div class="mt-2 rounded-[var(--radius-input)] bg-[var(--color-bg-1)] border border-[var(--color-border)] px-3 py-2"> <div class="mt-2 rounded-[var(--radius-input)] bg-[var(--color-bg-1)] border border-[var(--color-border)] px-3 py-2">
{#each build.recipe as cmd, i} {#each build.recipe as cmd, i}
{@const [kw, kwRest] = splitInstruction(cmd)} {@const [kw, kwRest] = splitInstruction(cmd)}
<div class="flex gap-2 py-0.5"> <div class="flex gap-2 py-0.5">
<span class="shrink-0 font-mono text-label text-[var(--color-text-muted)] tabular-nums">{i + 1}.</span> <span class="shrink-0 font-mono text-label text-[var(--color-text-muted)] tabular-nums">{i + 1}.</span>
<code class="font-mono text-meta"><span style="color: {keywordColor(kw)}">{kw}</span>{#if kwRest}<span class="text-[var(--color-text-secondary)]"> {kwRest}</span>{/if}</code> <code class="font-mono text-meta"><span style="color: {keywordColor(kw)}">{kw}</span>{#if kwRest}{' '}<span class="text-[var(--color-text-secondary)]">{kwRest}</span>{/if}</code>
</div> </div>
{/each} {/each}
</div> </div>
@ -701,10 +735,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
></div> ></div>
<div <div
class="relative w-full max-w-[520px] max-h-[90vh] overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[520px] max-h-[90vh] overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Top accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-accent)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Create Template Create Template
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -787,10 +825,45 @@
class="w-full resize-y rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-meta leading-relaxed text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60" class="w-full resize-y rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-meta leading-relaxed text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
></textarea> ></textarea>
<p class="mt-1 text-label text-[var(--color-text-muted)]"> <p class="mt-1 text-label text-[var(--color-text-muted)]">
Supports <code class="font-mono">RUN</code>, <code class="font-mono">START</code>, <code class="font-mono">WORKDIR</code>, <code class="font-mono">ENV key=value</code>. RUN steps have a 30s timeout; override with <code class="font-mono">RUN --timeout=5m</code>. Supports <code class="font-mono">RUN</code>, <code class="font-mono">START</code>, <code class="font-mono">WORKDIR</code>, <code class="font-mono">ENV key=value</code>, <code class="font-mono">USER name</code>, <code class="font-mono">COPY src dst</code>. RUN steps have a 30s timeout; override with <code class="font-mono">RUN --timeout=5m</code>. COPY references files from the uploaded archive.
</p> </p>
</div> </div>
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="tmpl-archive">
Build Archive <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional, for COPY commands)</span>
</label>
<div class="flex items-center gap-3">
<label
class="flex cursor-pointer items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 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)]"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose file
<input
id="tmpl-archive"
type="file"
accept=".tar,.tar.gz,.tgz,.zip"
disabled={creating}
onchange={(e) => { const f = (e.target as HTMLInputElement).files?.[0]; createForm.archive = f ?? null; }}
class="hidden"
/>
</label>
{#if createForm.archive}
<span class="flex items-center gap-1.5 text-meta text-[var(--color-text-secondary)]">
<span class="font-mono">{createForm.archive.name}</span>
<button
onclick={() => { createForm.archive = null; }}
class="text-[var(--color-text-muted)] hover:text-[var(--color-red)] transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</span>
{:else}
<span class="text-meta text-[var(--color-text-muted)]">tar, tar.gz, or zip</span>
{/if}
</div>
</div>
<div> <div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="tmpl-healthcheck"> <label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="tmpl-healthcheck">
Healthcheck <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span> Healthcheck <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span>
@ -830,7 +903,7 @@
<button <button
onclick={handleCreate} onclick={handleCreate}
disabled={creating || !createForm.name.trim() || !createForm.recipe.trim()} disabled={creating || !createForm.name.trim() || !createForm.recipe.trim()}
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" class="group flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
> >
{#if creating} {#if creating}
<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> <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>
@ -840,6 +913,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -855,10 +929,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }}
></div> ></div>
<div <div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Danger accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-red)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Delete Template Delete Template
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -882,7 +960,7 @@
<button <button
onclick={handleDeleteTemplate} onclick={handleDeleteTemplate}
disabled={deleting} disabled={deleting}
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 disabled:opacity-50" class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_16px_rgba(207,129,114,0.25)] hover:brightness-110 disabled:opacity-50"
> >
{#if deleting} {#if deleting}
<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> <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>
@ -892,6 +970,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -917,4 +996,59 @@
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.4s ease infinite; animation: shimmer 1.4s ease infinite;
} }
/* Stat pill — shared base */
.stat-pill {
display: flex;
align-items: baseline;
gap: 6px;
border-radius: var(--radius-button);
border-width: 1px;
padding: 6px 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.stat-pill:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
/* Table header */
.table-header {
padding: 10px 20px;
text-align: left;
font-size: var(--text-label);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-tertiary);
}
/* Staggered row entrance */
.table-row-animate {
animation: fadeUp 0.25s ease both;
}
/* Tab button */
.tab-button {
position: relative;
padding: 14px 20px 14px 0;
font-size: var(--text-ui);
transition: color 0.15s ease;
cursor: pointer;
}
/* Active build row — subtle left accent */
.build-row-active {
box-shadow: inset 3px 0 0 var(--color-blue);
}
/* Progress bar glow for running builds */
.progress-bar-glow {
box-shadow: 0 0 8px rgba(90, 159, 212, 0.4);
}
/* Empty state icon float */
.empty-icon-float {
animation: iconFloat 3s ease-in-out infinite;
}
</style> </style>

View File

@ -478,60 +478,8 @@
{: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 --> <!-- Tabs + lifecycle actions -->
<div class="flex items-center justify-end gap-2 px-7 pt-5"> <div class="mt-5 flex items-center border-b border-[var(--color-border)] px-7">
{#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) -->
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
<button <button
onclick={() => setTab('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 class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
@ -570,6 +518,58 @@
</svg> </svg>
Terminal Terminal
</button> </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> </div>
<!-- Tab content --> <!-- Tab content -->

View File

@ -162,10 +162,10 @@
<div class="flex flex-1 flex-col overflow-hidden"> <div class="flex flex-1 flex-col overflow-hidden">
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]"> <main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
<!-- Header --> <!-- Header -->
<div class="px-7 pt-8"> <div class="px-8 pt-8">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h1 class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]"> <h1 class="font-serif text-page tracking-[-0.03em] text-[var(--color-text-bright)]">
Templates Templates
</h1> </h1>
<p class="mt-2 text-ui text-[var(--color-text-secondary)]"> <p class="mt-2 text-ui text-[var(--color-text-secondary)]">
@ -231,15 +231,15 @@
</div> </div>
<div class="skeleton h-4 w-20 rounded-sm"></div> <div class="skeleton h-4 w-20 rounded-sm"></div>
</div> </div>
<div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]"> <div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] shadow-sm">
<div class="grid border-b border-[var(--color-border)] bg-[var(--color-bg-3)]" style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px"> <div class="grid border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40" style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px">
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Name</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Type</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Type</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">vCPUs</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">vCPUs</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Memory</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Memory</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Size</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Size</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Created</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Created</div>
<div class="px-5 py-3 text-right text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Actions</div> <div class="px-5 py-3 text-right text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Actions</div>
</div> </div>
{#each Array(4) as _, i} {#each Array(4) as _, i}
<div <div
@ -294,30 +294,28 @@
{#if filteredSnapshots.length === 0} {#if filteredSnapshots.length === 0}
<!-- Empty state --> <!-- Empty state -->
<div class="flex flex-col items-center justify-center py-[72px]"> <div class="flex flex-col items-center justify-center py-28 text-center">
<div class="relative mb-5"> <div class="relative mb-7">
<!-- Radial glow behind icon --> <div class="absolute -inset-3 rounded-2xl bg-[var(--color-accent-glow)] blur-xl"></div>
<div class="absolute inset-0 -m-4 rounded-full" style="background: radial-gradient(circle, rgba(94,140,88,0.08) 0%, transparent 70%)"></div>
<div <div
class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]" class="empty-icon-float relative flex h-18 w-18 items-center justify-center rounded-xl border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-card"
style="animation: iconFloat 4s ease-in-out infinite"
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /> <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" /> <polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" />
</svg> </svg>
</div> </div>
</div> </div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"> <p class="font-serif text-heading leading-snug text-[var(--color-text-secondary)]">
{emptyHeading(typeFilter)} {emptyHeading(typeFilter)}
</p> </p>
<p class="mt-1.5 max-w-[340px] text-center text-ui text-[var(--color-text-tertiary)]"> <p class="mt-2 max-w-[320px] text-ui text-[var(--color-text-muted)]">
{emptyDescription(typeFilter)} {emptyDescription(typeFilter)}
</p> </p>
{#if typeFilter === 'all' || typeFilter === 'snapshot'} {#if typeFilter === 'all' || typeFilter === 'snapshot'}
<a <a
href="/dashboard/capsules" href="/dashboard/capsules"
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)] px-4 py-2 text-ui font-medium text-[var(--color-text-secondary)] transition-all duration-150 hover:border-[var(--color-border-mid)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] active:scale-95" class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 px-4 py-2 text-ui font-medium text-[var(--color-accent-bright)] transition-all duration-200 hover:bg-[var(--color-accent)]/20 hover:border-[var(--color-accent)]/50"
> >
Go to Capsules Go to Capsules
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
@ -329,16 +327,16 @@
</div> </div>
{:else} {:else}
<!-- Table --> <!-- Table -->
<div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]"> <div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] shadow-sm">
<!-- Header --> <!-- Header -->
<div class="grid border-b border-[var(--color-border)] bg-[var(--color-bg-3)]" style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px"> <div class="grid border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40" style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px">
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Name</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Type</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Type</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">vCPUs</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">vCPUs</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Memory</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Memory</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Size</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Size</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Created</div> <div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Created</div>
<div class="px-5 py-3 text-right text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Actions</div> <div class="px-5 py-3 text-right text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Actions</div>
</div> </div>
<!-- Rows --> <!-- Rows -->
@ -366,25 +364,22 @@
<!-- Type badge --> <!-- Type badge -->
<div class="px-5 py-4"> <div class="px-5 py-4">
{#if isSnapshot} {#if isSnapshot}
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/10 px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-0.5 text-label font-medium text-[var(--color-accent-bright)]">
<span <span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-accent)]" snapshot
style="box-shadow: 0 0 6px rgba(94,140,88,0.5); animation: wrenn-glow 1.8s ease-in-out infinite"
></span>
Snapshot
</span> </span>
{:else} {:else}
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/10 px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-2.5 py-0.5 text-label font-medium text-[var(--color-blue)]">
<span class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-blue)]"></span> <span class="h-1.5 w-1.5 rounded-full bg-[var(--color-blue)]"></span>
Image image
</span> </span>
{/if} {/if}
</div> </div>
<!-- vCPUs --> <!-- vCPUs -->
<div class="px-5 py-4"> <div class="px-5 py-4">
{#if snapshot.type === 'snapshot' && snapshot.vcpus != null} {#if snapshot.vcpus != null && snapshot.vcpus > 0}
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5" title={isSnapshot ? 'Required' : 'Recommended'}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" /><line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" /><line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" /><line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" /> <rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" /><line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" /><line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" /><line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg> </svg>
@ -397,8 +392,8 @@
<!-- Memory --> <!-- Memory -->
<div class="px-5 py-4"> <div class="px-5 py-4">
{#if snapshot.type === 'snapshot' && snapshot.memory_mb != null} {#if snapshot.memory_mb != null && snapshot.memory_mb > 0}
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5" title={isSnapshot ? 'Required' : 'Recommended'}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
<rect x="2" y="6" width="20" height="12" rx="2" /><line x1="6" y1="12" x2="6" y2="12.01" /><line x1="10" y1="12" x2="10" y2="12.01" /><line x1="14" y1="12" x2="14" y2="12.01" /><line x1="18" y1="12" x2="18" y2="12.01" /> <rect x="2" y="6" width="20" height="12" rx="2" /><line x1="6" y1="12" x2="6" y2="12.01" /><line x1="10" y1="12" x2="10" y2="12.01" /><line x1="14" y1="12" x2="14" y2="12.01" /><line x1="18" y1="12" x2="18" y2="12.01" />
</svg> </svg>
@ -475,7 +470,7 @@
</main> </main>
<!-- Status bar --> <!-- Status bar -->
<footer class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-7"> <footer class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-8">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span <span
class="inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" class="inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"
@ -526,12 +521,16 @@
></div> ></div>
<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" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.2s ease both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Delete snapshot</h2> <!-- Danger accent edge -->
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]"> <div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-red)] to-transparent"></div>
Permanently delete <span class="font-mono font-medium text-[var(--color-text-secondary)]">{deleteTarget.name}</span>.
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">Delete snapshot</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Permanently delete <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{deleteTarget.name}</code>.
Running capsules won't be affected, but you won't be able to launch new ones from it. Running capsules won't be affected, but you won't be able to launch new ones from it.
</p> </p>
@ -548,7 +547,7 @@
{/if} {/if}
{#if deleteError} {#if deleteError}
<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)]"> <div class="mt-3 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)]">
{deleteError} {deleteError}
</div> </div>
{/if} {/if}
@ -564,7 +563,7 @@
<button <button
onclick={handleDelete} onclick={handleDelete}
disabled={deleting} disabled={deleting}
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-115 hover:-translate-y-px active:translate-y-0 active:scale-95 disabled:opacity-50 disabled:hover:translate-y-0" class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_16px_rgba(207,129,114,0.25)] hover:brightness-110 disabled:opacity-50"
> >
{#if deleting} {#if deleting}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -576,6 +575,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -591,11 +591,15 @@
></div> ></div>
<div <div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.2s ease both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2> <!-- Top accent edge -->
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]"> <div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-accent)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Configure resources and launch a new capsule from this snapshot. Configure resources and launch a new capsule from this snapshot.
</p> </p>
@ -612,12 +616,9 @@
</label> </label>
<div class="flex items-center gap-2 rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-0)] px-3 py-2"> <div class="flex items-center gap-2 rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-0)] px-3 py-2">
{#if launchTarget.type === 'snapshot'} {#if launchTarget.type === 'snapshot'}
<span <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-accent)]"></span>
class="inline-block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-accent)]"
style="box-shadow: 0 0 6px rgba(94,140,88,0.5); animation: wrenn-glow 1.8s ease-in-out infinite"
></span>
{:else} {:else}
<span class="inline-block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-blue)]"></span> <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-blue)]"></span>
{/if} {/if}
<span class="flex-1 font-mono text-ui text-[var(--color-text-bright)]">{launchTarget.name}</span> <span class="flex-1 font-mono text-ui text-[var(--color-text-bright)]">{launchTarget.name}</span>
<span class="text-label text-[var(--color-text-muted)]"> <span class="text-label text-[var(--color-text-muted)]">
@ -694,7 +695,7 @@
<button <button
onclick={handleLaunch} onclick={handleLaunch}
disabled={launching} disabled={launching}
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 active:scale-95 disabled:opacity-50 disabled:hover:translate-y-0" class="group flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
> >
{#if launching} {#if launching}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -706,6 +707,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -715,17 +717,17 @@
.skeleton { .skeleton {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--color-bg-4) 0%, var(--color-bg-3) 25%,
var(--color-bg-5) 50%, var(--color-bg-4) 50%,
var(--color-bg-4) 100% var(--color-bg-3) 75%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite; animation: shimmer 1.4s ease infinite;
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: 200% center; } 0% { background-position: -200% 0; }
100% { background-position: -200% center; } 100% { background-position: 200% 0; }
} }
/* Left accent stripe — slides in on hover, color-keyed to snapshot type */ /* Left accent stripe — slides in on hover, color-keyed to snapshot type */
@ -745,4 +747,9 @@
.snapshot-row.type-image:hover { .snapshot-row.type-image:hover {
background: rgba(90, 159, 212, 0.04); background: rgba(90, 159, 212, 0.04);
} }
/* Empty state icon float — matches admin pattern */
.empty-icon-float {
animation: iconFloat 3s ease-in-out infinite;
}
</style> </style>

View File

@ -17,8 +17,9 @@ mkdir -p /sys/fs/cgroup
mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null || true mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null || true
echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
# Set hostname # Set hostname and make it resolvable (sudo requires this).
hostname sandbox hostname sandbox
echo "127.0.0.1 sandbox" >> /etc/hosts
# Configure networking if the kernel ip= boot arg did not already set it up. # Configure networking if the kernel ip= boot arg did not already set it up.
if ! ip addr show eth0 2>/dev/null | grep -q "169.254.0.21"; then if ! ip addr show eth0 2>/dev/null | grep -q "169.254.0.21"; then

View File

@ -3,8 +3,10 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -54,6 +56,8 @@ type buildResponse struct {
Error *string `json:"error,omitempty"` Error *string `json:"error,omitempty"`
SandboxID *string `json:"sandbox_id,omitempty"` SandboxID *string `json:"sandbox_id,omitempty"`
HostID *string `json:"host_id,omitempty"` HostID *string `json:"host_id,omitempty"`
DefaultUser string `json:"default_user"`
DefaultEnv json.RawMessage `json:"default_env"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
StartedAt *string `json:"started_at,omitempty"` StartedAt *string `json:"started_at,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"`
@ -71,6 +75,8 @@ func buildToResponse(b db.TemplateBuild) buildResponse {
CurrentStep: b.CurrentStep, CurrentStep: b.CurrentStep,
TotalSteps: b.TotalSteps, TotalSteps: b.TotalSteps,
Logs: b.Logs, Logs: b.Logs,
DefaultUser: b.DefaultUser,
DefaultEnv: b.DefaultEnv,
} }
if b.Healthcheck != "" { if b.Healthcheck != "" {
resp.Healthcheck = &b.Healthcheck resp.Healthcheck = &b.Healthcheck
@ -101,11 +107,54 @@ func buildToResponse(b db.TemplateBuild) buildResponse {
} }
// Create handles POST /v1/admin/builds. // Create handles POST /v1/admin/builds.
// Accepts either JSON body or multipart/form-data with a "config" JSON part
// and an optional "archive" file part (tar/tar.gz/zip for COPY commands).
func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createBuildRequest var req createBuildRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { var archive []byte
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") var archiveName string
return
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/") {
// 100 MB max for multipart (archive + JSON config).
if err := r.ParseMultipartForm(100 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart form")
return
}
// Parse JSON config from "config" field.
configStr := r.FormValue("config")
if configStr == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "multipart form requires a 'config' JSON field")
return
}
if err := json.Unmarshal([]byte(configStr), &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid config JSON in multipart form")
return
}
// Read optional archive file (max 100 MB).
file, header, err := r.FormFile("archive")
if err == nil {
defer file.Close()
const maxArchiveSize = 100 << 20 // 100 MB
lr := io.LimitReader(file, maxArchiveSize+1)
archive, err = io.ReadAll(lr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to read archive file")
return
}
if int64(len(archive)) > maxArchiveSize {
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request", "archive exceeds 100 MB limit")
return
}
archiveName = header.Filename
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
} }
if req.Name == "" { if req.Name == "" {
@ -129,6 +178,8 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
VCPUs: req.VCPUs, VCPUs: req.VCPUs,
MemoryMB: req.MemoryMB, MemoryMB: req.MemoryMB,
SkipPrePost: req.SkipPrePost, SkipPrePost: req.SkipPrePost,
Archive: archive,
ArchiveName: archiveName,
}) })
if err != nil { if err != nil {
slog.Error("failed to create build", "error", err) slog.Error("failed to create build", "error", err)

View File

@ -210,13 +210,15 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
tmpl, err := h.db.InsertTemplate(snapCtx, db.InsertTemplateParams{ tmpl, err := h.db.InsertTemplate(snapCtx, db.InsertTemplateParams{
ID: newTemplateID, ID: newTemplateID,
Name: req.Name, Name: req.Name,
Type: "snapshot", Type: "snapshot",
Vcpus: sb.Vcpus, Vcpus: sb.Vcpus,
MemoryMb: sb.MemoryMb, MemoryMb: sb.MemoryMb,
SizeBytes: resp.Msg.SizeBytes, SizeBytes: resp.Msg.SizeBytes,
TeamID: ac.TeamID, TeamID: ac.TeamID,
DefaultUser: "root",
DefaultEnv: []byte("{}"),
}) })
if err != nil { if err != nil {
slog.Error("failed to insert template record", "name", req.Name, "error", err) slog.Error("failed to insert template record", "name", req.Name, "error", err)

View File

@ -152,14 +152,16 @@ type TeamApiKey struct {
} }
type Template struct { type Template struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"`
} }
type TemplateBuild struct { type TemplateBuild struct {
@ -183,6 +185,8 @@ type TemplateBuild struct {
TemplateID pgtype.UUID `json:"template_id"` TemplateID pgtype.UUID `json:"template_id"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
SkipPrePost bool `json:"skip_pre_post"` SkipPrePost bool `json:"skip_pre_post"`
DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"`
} }
type User struct { type User struct {

View File

@ -12,7 +12,7 @@ import (
) )
const getTemplateBuild = `-- name: GetTemplateBuild :one const getTemplateBuild = `-- name: GetTemplateBuild :one
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post FROM template_builds WHERE id = $1 SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env FROM template_builds WHERE id = $1
` `
func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) { func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) {
@ -39,6 +39,8 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat
&i.TemplateID, &i.TemplateID,
&i.TeamID, &i.TeamID,
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
@ -46,7 +48,7 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat
const insertTemplateBuild = `-- name: InsertTemplateBuild :one const insertTemplateBuild = `-- name: InsertTemplateBuild :one
INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id, skip_pre_post) INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id, skip_pre_post)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10, $11)
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env
` `
type InsertTemplateBuildParams struct { type InsertTemplateBuildParams struct {
@ -99,12 +101,14 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui
&i.TemplateID, &i.TemplateID,
&i.TeamID, &i.TeamID,
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const listTemplateBuilds = `-- name: ListTemplateBuilds :many const listTemplateBuilds = `-- name: ListTemplateBuilds :many
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post FROM template_builds ORDER BY created_at DESC SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env FROM template_builds ORDER BY created_at DESC
` `
func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) { func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) {
@ -137,6 +141,8 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro
&i.TemplateID, &i.TemplateID,
&i.TeamID, &i.TeamID,
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -148,6 +154,23 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro
return items, nil return items, nil
} }
const updateBuildDefaults = `-- name: UpdateBuildDefaults :exec
UPDATE template_builds
SET default_user = $2, default_env = $3
WHERE id = $1
`
type UpdateBuildDefaultsParams struct {
ID pgtype.UUID `json:"id"`
DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"`
}
func (q *Queries) UpdateBuildDefaults(ctx context.Context, arg UpdateBuildDefaultsParams) error {
_, err := q.db.Exec(ctx, updateBuildDefaults, arg.ID, arg.DefaultUser, arg.DefaultEnv)
return err
}
const updateBuildError = `-- name: UpdateBuildError :exec const updateBuildError = `-- name: UpdateBuildError :exec
UPDATE template_builds UPDATE template_builds
SET error = $2, status = 'failed', completed_at = NOW() SET error = $2, status = 'failed', completed_at = NOW()
@ -204,7 +227,7 @@ SET status = $2,
started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END, started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
completed_at = CASE WHEN $2 IN ('success', 'failed', 'cancelled') THEN NOW() ELSE completed_at END completed_at = CASE WHEN $2 IN ('success', 'failed', 'cancelled') THEN NOW() ELSE completed_at END
WHERE id = $1 WHERE id = $1
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env
` `
type UpdateBuildStatusParams struct { type UpdateBuildStatusParams struct {
@ -236,6 +259,8 @@ func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusPa
&i.TemplateID, &i.TemplateID,
&i.TeamID, &i.TeamID,
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }

View File

@ -45,7 +45,7 @@ func (q *Queries) DeleteTemplatesByTeam(ctx context.Context, teamID pgtype.UUID)
} }
const getPlatformTemplateByName = `-- name: GetPlatformTemplateByName :one const getPlatformTemplateByName = `-- name: GetPlatformTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1
` `
// Check if a global (platform) template exists with the given name. // Check if a global (platform) template exists with the given name.
@ -61,12 +61,14 @@ func (q *Queries) GetPlatformTemplateByName(ctx context.Context, name string) (T
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const getTemplate = `-- name: GetTemplate :one const getTemplate = `-- name: GetTemplate :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE id = $1 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE id = $1
` `
func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, error) { func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, error) {
@ -81,12 +83,14 @@ func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, er
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const getTemplateByName = `-- name: GetTemplateByName :one const getTemplateByName = `-- name: GetTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = $1 AND name = $2 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = $1 AND name = $2
` `
type GetTemplateByNameParams struct { type GetTemplateByNameParams struct {
@ -107,12 +111,14 @@ func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNamePa
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const getTemplateByTeam = `-- name: GetTemplateByTeam :one const getTemplateByTeam = `-- name: GetTemplateByTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000') SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
` `
type GetTemplateByTeamParams struct { type GetTemplateByTeamParams struct {
@ -133,24 +139,28 @@ func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamPa
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const insertTemplate = `-- name: InsertTemplate :one const insertTemplate = `-- name: InsertTemplate :one
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env
` `
type InsertTemplateParams struct { type InsertTemplateParams struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"`
} }
func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
@ -162,6 +172,8 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
arg.MemoryMb, arg.MemoryMb,
arg.SizeBytes, arg.SizeBytes,
arg.TeamID, arg.TeamID,
arg.DefaultUser,
arg.DefaultEnv,
) )
var i Template var i Template
err := row.Scan( err := row.Scan(
@ -173,12 +185,14 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
) )
return i, err return i, err
} }
const listTemplates = `-- name: ListTemplates :many const listTemplates = `-- name: ListTemplates :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates ORDER BY created_at DESC
` `
func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
@ -199,6 +213,8 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -211,7 +227,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
} }
const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC
` `
// Platform templates are visible to all teams. // Platform templates are visible to all teams.
@ -233,6 +249,8 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -245,7 +263,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
} }
const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC
` `
type ListTemplatesByTeamAndTypeParams struct { type ListTemplatesByTeamAndTypeParams struct {
@ -272,6 +290,8 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -284,7 +304,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
} }
const listTemplatesByTeamOnly = `-- name: ListTemplatesByTeamOnly :many const listTemplatesByTeamOnly = `-- name: ListTemplatesByTeamOnly :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = $1 ORDER BY created_at DESC
` `
// List templates owned by a specific team (NOT including platform templates). // List templates owned by a specific team (NOT including platform templates).
@ -306,6 +326,8 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -318,7 +340,7 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI
} }
const listTemplatesByType = `-- name: ListTemplatesByType :many const listTemplatesByType = `-- name: ListTemplatesByType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE type = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE type = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) { func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) {
@ -339,6 +361,8 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID, &i.ID,
&i.DefaultUser,
&i.DefaultEnv,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@ -3,6 +3,7 @@ package envdclient
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@ -273,10 +274,36 @@ func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
// env vars and the corresponding files under /run/wrenn/ inside the guest. // env vars and the corresponding files under /run/wrenn/ inside the guest.
// Must be called after snapshot restore so envd picks up the new sandbox's metadata. // Must be called after snapshot restore so envd picks up the new sandbox's metadata.
func (c *Client) PostInit(ctx context.Context) error { func (c *Client) PostInit(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/init", nil) return c.PostInitWithDefaults(ctx, "", nil)
}
// PostInitWithDefaults calls envd's POST /init endpoint with optional default
// user and environment variables. These are applied to envd's defaults so all
// subsequent process executions use them.
func (c *Client) PostInitWithDefaults(ctx context.Context, defaultUser string, envVars map[string]string) error {
var body io.Reader
if defaultUser != "" || len(envVars) > 0 {
payload := make(map[string]any)
if defaultUser != "" {
payload["defaultUser"] = defaultUser
}
if len(envVars) > 0 {
payload["envVars"] = envVars
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal init body: %w", err)
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/init", body)
if err != nil { if err != nil {
return fmt.Errorf("create request: %w", err) return fmt.Errorf("create request: %w", err)
} }
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@ -285,8 +312,8 @@ func (c *Client) PostInit(ctx context.Context) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent { if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body) respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("post init: status %d: %s", resp.StatusCode, string(body)) return fmt.Errorf("post init: status %d: %s", resp.StatusCode, string(respBody))
} }
return nil return nil

View File

@ -69,6 +69,13 @@ func (s *Server) CreateSandbox(
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err)) return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
} }
// Apply template defaults (user, env vars) if provided.
if msg.DefaultUser != "" || len(msg.DefaultEnv) > 0 {
if err := s.mgr.SetDefaults(ctx, sb.ID, msg.DefaultUser, msg.DefaultEnv); err != nil {
slog.Warn("failed to set sandbox defaults", "sandbox", sb.ID, "error", err)
}
}
return connect.NewResponse(&pb.CreateSandboxResponse{ return connect.NewResponse(&pb.CreateSandboxResponse{
SandboxId: sb.ID, SandboxId: sb.ID,
Status: string(sb.Status), Status: string(sb.Status),
@ -100,10 +107,19 @@ func (s *Server) ResumeSandbox(
ctx context.Context, ctx context.Context,
req *connect.Request[pb.ResumeSandboxRequest], req *connect.Request[pb.ResumeSandboxRequest],
) (*connect.Response[pb.ResumeSandboxResponse], error) { ) (*connect.Response[pb.ResumeSandboxResponse], error) {
sb, err := s.mgr.Resume(ctx, req.Msg.SandboxId, int(req.Msg.TimeoutSec)) msg := req.Msg
sb, err := s.mgr.Resume(ctx, msg.SandboxId, int(msg.TimeoutSec))
if err != nil { if err != nil {
return nil, connect.NewError(connect.CodeInternal, err) return nil, connect.NewError(connect.CodeInternal, err)
} }
// Apply template defaults (user, env vars) if provided.
if msg.DefaultUser != "" || len(msg.DefaultEnv) > 0 {
if err := s.mgr.SetDefaults(ctx, sb.ID, msg.DefaultUser, msg.DefaultEnv); err != nil {
slog.Warn("failed to set sandbox defaults on resume", "sandbox", sb.ID, "error", err)
}
}
return connect.NewResponse(&pb.ResumeSandboxResponse{ return connect.NewResponse(&pb.ResumeSandboxResponse{
SandboxId: sb.ID, SandboxId: sb.ID,
Status: string(sb.Status), Status: string(sb.Status),

View File

@ -7,10 +7,11 @@ import (
) )
// ExecContext holds mutable state that persists across recipe steps. // ExecContext holds mutable state that persists across recipe steps.
// It is initialized empty and updated by ENV and WORKDIR steps. // It is initialized empty and updated by ENV, WORKDIR, and USER steps.
type ExecContext struct { type ExecContext struct {
WorkDir string WorkDir string
EnvVars map[string]string EnvVars map[string]string
User string // Current unix user for command execution.
} }
// This regex matches: // This regex matches:
@ -25,7 +26,20 @@ var envRegex = regexp.MustCompile(`\$\$|\$\{([a-zA-Z0-9_]*)\}|\$([a-zA-Z0-9_]+)`
// If WORKDIR and/or ENV are set, they are prepended as a shell preamble: // If WORKDIR and/or ENV are set, they are prepended as a shell preamble:
// //
// cd '/the/dir' && KEY='val' /bin/sh -c 'original command' // cd '/the/dir' && KEY='val' /bin/sh -c 'original command'
//
// If USER is set to a non-root user, the entire command is wrapped with su:
//
// su <user> -s /bin/sh -c '<preamble + command>'
func (c *ExecContext) WrappedCommand(cmd string) string { func (c *ExecContext) WrappedCommand(cmd string) string {
inner := c.innerCommand(cmd)
if c.User != "" && c.User != "root" {
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
}
return inner
}
// innerCommand builds the command with workdir/env preamble but without user wrapping.
func (c *ExecContext) innerCommand(cmd string) string {
prefix := c.shellPrefix() prefix := c.shellPrefix()
if prefix == "" { if prefix == "" {
return cmd return cmd
@ -42,7 +56,11 @@ func (c *ExecContext) WrappedCommand(cmd string) string {
// simultaneously before a healthcheck is evaluated. // simultaneously before a healthcheck is evaluated.
func (c *ExecContext) StartCommand(cmd string) string { func (c *ExecContext) StartCommand(cmd string) string {
prefix := c.shellPrefix() prefix := c.shellPrefix()
return prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &" inner := prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &"
if c.User != "" && c.User != "root" {
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
}
return inner
} }
// shellPrefix builds the "cd ... && KEY=val " preamble for a shell command. // shellPrefix builds the "cd ... && KEY=val " preamble for a shell command.

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"path"
"strings" "strings"
"time" "time"
@ -16,6 +17,10 @@ import (
// explicit --timeout flag. // explicit --timeout flag.
const DefaultStepTimeout = 30 * time.Second const DefaultStepTimeout = 30 * time.Second
// BuildFilesDir is the directory inside the sandbox where uploaded build
// archives are extracted. COPY instructions reference paths relative to this.
const BuildFilesDir = "/tmp/build-files"
// BuildLogEntry is the per-step record stored in template_builds.logs (JSONB). // BuildLogEntry is the per-step record stored in template_builds.logs (JSONB).
type BuildLogEntry struct { type BuildLogEntry struct {
Step int `json:"step"` Step int `json:"step"`
@ -32,13 +37,18 @@ type BuildLogEntry struct {
// the method on the hostagent Connect RPC client. // the method on the hostagent Connect RPC client.
type ExecFunc func(ctx context.Context, req *connect.Request[pb.ExecRequest]) (*connect.Response[pb.ExecResponse], error) type ExecFunc func(ctx context.Context, req *connect.Request[pb.ExecRequest]) (*connect.Response[pb.ExecResponse], error)
// ProgressFunc is called after each step with the current step counter and
// accumulated log entries. Used for per-step DB progress updates.
type ProgressFunc func(step int, entries []BuildLogEntry)
// Execute runs steps sequentially against sandboxID using execFn. // Execute runs steps sequentially against sandboxID using execFn.
// //
// - phase labels the log entries (e.g., "pre-build", "recipe", "post-build"). // - phase labels the log entries (e.g., "pre-build", "recipe", "post-build").
// - startStep is the 1-based offset so entries are globally numbered across phases. // - startStep is the 1-based offset so entries are globally numbered across phases.
// - defaultTimeout applies to RUN steps with no per-step --timeout; 0 → 10 minutes. // - defaultTimeout applies to RUN steps with no per-step --timeout; 0 → 10 minutes.
// - bctx is mutated in place as ENV/WORKDIR steps execute, and carries forward // - bctx is mutated in place as ENV/WORKDIR/USER steps execute, and carries forward
// into subsequent phases when the caller passes the same pointer. // into subsequent phases when the caller passes the same pointer.
// - onProgress is called after each step for live progress updates (may be nil).
// //
// Returns all log entries appended during this call, the next step counter // Returns all log entries appended during this call, the next step counter
// value, and whether all steps succeeded. On false the last entry contains // value, and whether all steps succeeded. On false the last entry contains
@ -53,6 +63,7 @@ func Execute(
defaultTimeout time.Duration, defaultTimeout time.Duration,
bctx *ExecContext, bctx *ExecContext,
execFn ExecFunc, execFn ExecFunc,
onProgress ProgressFunc,
) (entries []BuildLogEntry, nextStep int, ok bool) { ) (entries []BuildLogEntry, nextStep int, ok bool) {
if defaultTimeout <= 0 { if defaultTimeout <= 0 {
defaultTimeout = 10 * time.Minute defaultTimeout = 10 * time.Minute
@ -72,19 +83,30 @@ func Execute(
entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true}) entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true})
case KindWORKDIR: case KindWORKDIR:
// Create the directory if it doesn't exist.
mkdirEntry := execRawShell(ctx, st.Raw, sandboxID, phase, step, 10*time.Second, execFn,
"mkdir -p "+shellescape(st.Path))
if !mkdirEntry.Ok {
entries = append(entries, mkdirEntry)
return entries, step, false
}
bctx.WorkDir = st.Path bctx.WorkDir = st.Path
entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true}) mkdirEntry.Ok = true
entries = append(entries, mkdirEntry)
case KindUSER, KindCOPY: case KindUSER:
verb := strings.ToUpper(strings.Fields(st.Raw)[0]) entry, succeeded := execUser(ctx, st, sandboxID, phase, step, bctx, execFn)
entries = append(entries, BuildLogEntry{ entries = append(entries, entry)
Step: step, if !succeeded {
Phase: phase, return entries, step, false
Cmd: st.Raw, }
Stderr: verb + " is not yet supported",
Ok: false, case KindCOPY:
}) entry, succeeded := execCopy(ctx, st, sandboxID, phase, step, bctx, execFn)
return entries, step, false entries = append(entries, entry)
if !succeeded {
return entries, step, false
}
case KindSTART: case KindSTART:
entry, succeeded := execStart(ctx, st, sandboxID, phase, step, bctx, execFn) entry, succeeded := execStart(ctx, st, sandboxID, phase, step, bctx, execFn)
@ -104,6 +126,10 @@ func Execute(
return entries, step, false return entries, step, false
} }
} }
if onProgress != nil {
onProgress(step, entries)
}
} }
return entries, step, true return entries, step, true
} }
@ -145,6 +171,114 @@ func execRun(
return entry, entry.Ok return entry, entry.Ok
} }
// execUser creates a unix user (if not exists), grants passwordless sudo,
// and updates bctx.User for subsequent steps.
func execUser(
ctx context.Context,
st Step,
sandboxID, phase string,
step int,
bctx *ExecContext,
execFn ExecFunc,
) (BuildLogEntry, bool) {
username := st.Key
// Create user if not exists, with home directory and bash shell.
// Grant passwordless sudo access (E2B convention).
// Uses printf %s to avoid shell injection in the sudoers line.
script := fmt.Sprintf(
"id %s >/dev/null 2>&1 || (adduser --disabled-password --gecos '' --shell /bin/bash %s && printf '%%s ALL=(ALL) NOPASSWD:ALL\\n' %s >> /etc/sudoers)",
shellescape(username), shellescape(username), shellescape(username),
)
entry := execRawShell(ctx, st.Raw, sandboxID, phase, step, 30*time.Second, execFn, script)
if entry.Ok {
bctx.User = username
}
return entry, entry.Ok
}
// execCopy copies a file or directory from the build archive (extracted at
// BuildFilesDir) to the destination path inside the sandbox. Ownership is
// set to the current user from bctx.
func execCopy(
ctx context.Context,
st Step,
sandboxID, phase string,
step int,
bctx *ExecContext,
execFn ExecFunc,
) (BuildLogEntry, bool) {
// Validate all source paths: must be relative and not escape the archive directory.
var srcPaths []string
for _, s := range st.Srcs {
cleaned := path.Clean(s)
if strings.HasPrefix(cleaned, "..") || strings.HasPrefix(cleaned, "/") {
return BuildLogEntry{
Step: step,
Phase: phase,
Cmd: st.Raw,
Stderr: fmt.Sprintf("COPY source must be a relative path within the archive: %q", s),
}, false
}
srcPaths = append(srcPaths, shellescape(BuildFilesDir+"/"+cleaned))
}
dst := st.Dst
// Resolve relative destination against the current WORKDIR.
if dst != "" && dst[0] != '/' && bctx.WorkDir != "" {
dst = bctx.WorkDir + "/" + dst
}
owner := "root"
if bctx.User != "" {
owner = bctx.User
}
script := fmt.Sprintf(
"cp -r %s %s && chown -R %s:%s %s",
strings.Join(srcPaths, " "), shellescape(dst), shellescape(owner), shellescape(owner), shellescape(dst),
)
entry := execRawShell(ctx, st.Raw, sandboxID, phase, step, 60*time.Second, execFn, script)
return entry, entry.Ok
}
// execRawShell runs a shell command directly (as root) without ExecContext
// wrapping. Used for internal operations like user creation and file copy.
func execRawShell(
ctx context.Context,
raw, sandboxID, phase string,
step int,
timeout time.Duration,
execFn ExecFunc,
shellCmd string,
) BuildLogEntry {
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", shellCmd},
TimeoutSec: int32(timeout.Seconds()),
}))
entry := BuildLogEntry{
Step: step,
Phase: phase,
Cmd: raw,
Elapsed: time.Since(start).Milliseconds(),
}
if err != nil {
entry.Stderr = fmt.Sprintf("exec error: %v", err)
return entry
}
entry.Stdout = string(resp.Msg.Stdout)
entry.Stderr = string(resp.Msg.Stderr)
entry.Exit = resp.Msg.ExitCode
entry.Ok = resp.Msg.ExitCode == 0
return entry
}
func execStart( func execStart(
ctx context.Context, ctx context.Context,
st Step, st Step,

View File

@ -24,9 +24,11 @@ type Step struct {
Raw string // original string, preserved for logging Raw string // original string, preserved for logging
Shell string // KindRUN, KindSTART: the shell command text Shell string // KindRUN, KindSTART: the shell command text
Timeout time.Duration // KindRUN: 0 means use caller's default Timeout time.Duration // KindRUN: 0 means use caller's default
Key string // KindENV: variable name Key string // KindENV: variable name; KindUSER: username
Value string // KindENV: variable value Value string // KindENV: variable value
Path string // KindWORKDIR: directory path Path string // KindWORKDIR: directory path
Srcs []string // KindCOPY: source paths (relative to build archive)
Dst string // KindCOPY: destination path inside sandbox
} }
// ParseStep parses a single recipe instruction string into a Step. // ParseStep parses a single recipe instruction string into a Step.
@ -61,9 +63,9 @@ func ParseStep(s string) (Step, error) {
case "WORKDIR": case "WORKDIR":
return parseWORKDIR(s, rest) return parseWORKDIR(s, rest)
case "USER": case "USER":
return Step{Kind: KindUSER, Raw: s}, nil return parseUSER(s, rest)
case "COPY": case "COPY":
return Step{Kind: KindCOPY, Raw: s}, nil return parseCOPY(s, rest)
default: default:
return Step{}, fmt.Errorf("unknown instruction %q (expected RUN, START, ENV, WORKDIR, USER, or COPY)", keyword) return Step{}, fmt.Errorf("unknown instruction %q (expected RUN, START, ENV, WORKDIR, USER, or COPY)", keyword)
} }
@ -127,3 +129,33 @@ func parseWORKDIR(raw, path string) (Step, error) {
} }
return Step{Kind: KindWORKDIR, Raw: raw, Path: path}, nil return Step{Kind: KindWORKDIR, Raw: raw, Path: path}, nil
} }
func parseUSER(raw, username string) (Step, error) {
if username == "" {
return Step{}, fmt.Errorf("USER requires a username: %q", raw)
}
// Validate: alphanumeric, hyphens, underscores only; must start with a letter or underscore.
for i, c := range username {
if i == 0 && !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
return Step{}, fmt.Errorf("USER username must start with a letter or underscore: %q", raw)
}
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return Step{}, fmt.Errorf("USER username contains invalid character %q: %q", string(c), raw)
}
}
return Step{Kind: KindUSER, Raw: raw, Key: username}, nil
}
func parseCOPY(raw, rest string) (Step, error) {
if rest == "" {
return Step{}, fmt.Errorf("COPY requires <src>... <dst>: %q", raw)
}
parts := strings.Fields(rest)
if len(parts) < 2 {
return Step{}, fmt.Errorf("COPY requires <src>... <dst>: %q", raw)
}
// Last argument is the destination, everything before is sources.
dst := parts[len(parts)-1]
srcs := parts[:len(parts)-1]
return Step{Kind: KindCOPY, Raw: raw, Srcs: srcs, Dst: dst}, nil
}

View File

@ -1,6 +1,7 @@
package recipe package recipe
import ( import (
"reflect"
"testing" "testing"
"time" "time"
) )
@ -111,16 +112,42 @@ func TestParseStep(t *testing.T) {
input: "WORKDIR", input: "WORKDIR",
wantErr: true, wantErr: true,
}, },
// USER and COPY stubs // USER
{ {
name: "USER stub", name: "USER basic",
input: "USER www-data", input: "USER www-data",
want: Step{Kind: KindUSER, Raw: "USER www-data"}, want: Step{Kind: KindUSER, Raw: "USER www-data", Key: "www-data"},
}, },
{ {
name: "COPY stub", name: "USER empty",
input: "USER",
wantErr: true,
},
{
name: "USER invalid chars",
input: "USER bad user",
wantErr: true,
},
// COPY
{
name: "COPY basic",
input: "COPY config.yaml /etc/app/config.yaml", input: "COPY config.yaml /etc/app/config.yaml",
want: Step{Kind: KindCOPY, Raw: "COPY config.yaml /etc/app/config.yaml"}, want: Step{Kind: KindCOPY, Raw: "COPY config.yaml /etc/app/config.yaml", Srcs: []string{"config.yaml"}, Dst: "/etc/app/config.yaml"},
},
{
name: "COPY multiple sources",
input: "COPY a.txt b.txt /dest/",
want: Step{Kind: KindCOPY, Raw: "COPY a.txt b.txt /dest/", Srcs: []string{"a.txt", "b.txt"}, Dst: "/dest/"},
},
{
name: "COPY missing dst",
input: "COPY config.yaml",
wantErr: true,
},
{
name: "COPY empty",
input: "COPY",
wantErr: true,
}, },
// Unknown keyword // Unknown keyword
{ {
@ -148,7 +175,7 @@ func TestParseStep(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ParseStep(%q) unexpected error: %v", tc.input, err) t.Fatalf("ParseStep(%q) unexpected error: %v", tc.input, err)
} }
if got != tc.want { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseStep(%q)\n got %+v\n want %+v", tc.input, got, tc.want) t.Errorf("ParseStep(%q)\n got %+v\n want %+v", tc.input, got, tc.want)
} }
}) })

View File

@ -6,6 +6,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/layout"
@ -66,6 +68,42 @@ func EnsureImageSizes(wrennDir string, targetMB int) error {
return nil return nil
} }
// ParseSizeToMB parses a human-readable size string into megabytes.
// Supported suffixes: G, Gi (gibibytes), M, Mi (mebibytes).
// Examples: "5G" → 5120, "2Gi" → 2048, "1000M" → 1000, "512Mi" → 512.
func ParseSizeToMB(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty size string")
}
// Find where the numeric part ends.
i := 0
for i < len(s) && (s[i] == '.' || (s[i] >= '0' && s[i] <= '9')) {
i++
}
if i == 0 {
return 0, fmt.Errorf("invalid size %q: no numeric value", s)
}
numStr := s[:i]
suffix := strings.TrimSpace(s[i:])
num, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid size %q: %w", s, err)
}
switch suffix {
case "G", "Gi":
return int(num * 1024), nil
case "M", "Mi", "":
return int(num), nil
default:
return 0, fmt.Errorf("invalid size %q: unknown suffix %q (use G, Gi, M, or Mi)", s, suffix)
}
}
// expandImage expands a single rootfs image if it is smaller than targetBytes. // expandImage expands a single rootfs image if it is smaller than targetBytes.
func expandImage(rootfs string, targetBytes int64, targetMB int) error { func expandImage(rootfs string, targetBytes int64, targetMB int) error {
info, err := os.Stat(rootfs) info, err := os.Stat(rootfs)

View File

@ -28,8 +28,9 @@ import (
// Config holds the paths and defaults for the sandbox manager. // Config holds the paths and defaults for the sandbox manager.
type Config struct { type Config struct {
WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package
EnvdTimeout time.Duration EnvdTimeout time.Duration
DefaultRootfsSizeMB int // target size for template rootfs images; 0 → DefaultDiskSizeMB
} }
// Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd. // Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd.
@ -924,8 +925,8 @@ func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID string, teamID, t
// Clean up dm device and loop device now that flatten is complete. // Clean up dm device and loop device now that flatten is complete.
m.cleanupDM(sb) m.cleanupDM(sb)
// Shrink the flattened image to its minimum size so stored templates are // Shrink the flattened image to its minimum size, then re-expand to the
// compact. EnsureImageSizes will re-expand them on the next agent startup. // configured default rootfs size so sandboxes see the full disk from boot.
if out, err := exec.Command("e2fsck", "-fy", outputPath).CombinedOutput(); err != nil { if out, err := exec.Command("e2fsck", "-fy", outputPath).CombinedOutput(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() > 1 { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() > 1 {
slog.Warn("e2fsck before shrink failed (non-fatal)", "output", string(out), "error", err) slog.Warn("e2fsck before shrink failed (non-fatal)", "output", string(out), "error", err)
@ -935,6 +936,15 @@ func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID string, teamID, t
slog.Warn("resize2fs -M failed (non-fatal)", "output", string(out), "error", err) slog.Warn("resize2fs -M failed (non-fatal)", "output", string(out), "error", err)
} }
// Re-expand to default rootfs size.
targetMB := m.cfg.DefaultRootfsSizeMB
if targetMB <= 0 {
targetMB = DefaultDiskSizeMB
}
if err := expandImage(outputPath, int64(targetMB)*1024*1024, targetMB); err != nil {
slog.Warn("failed to expand template to default size (non-fatal)", "error", err)
}
sizeBytes, err := snapshot.DirSize(flattenDstDir, "") sizeBytes, err := snapshot.DirSize(flattenDstDir, "")
if err != nil { if err != nil {
slog.Warn("failed to calculate template size", "error", err) slog.Warn("failed to calculate template size", "error", err)
@ -1223,6 +1233,23 @@ func (m *Manager) GetClient(sandboxID string) (*envdclient.Client, error) {
return sb.client, nil return sb.client, nil
} }
// SetDefaults calls envd's PostInit to configure the default user and
// environment variables for a running sandbox. This is called by the host
// agent after sandbox creation or resume when the template specifies defaults.
func (m *Manager) SetDefaults(ctx context.Context, sandboxID, defaultUser string, defaultEnv map[string]string) error {
if defaultUser == "" && len(defaultEnv) == 0 {
return nil
}
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusRunning {
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
return sb.client.PostInitWithDefaults(ctx, defaultUser, defaultEnv)
}
// PtyAttach starts a new PTY process or reconnects to an existing one. // PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process. If empty, reconnects using tag. // If cmd is non-empty, starts a new process. If empty, reconnects using tag.
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) { func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {

View File

@ -27,8 +27,11 @@ const (
) )
// preBuildCmds run before the user recipe to prepare the build environment. // preBuildCmds run before the user recipe to prepare the build environment.
// apt update runs as root first, then USER switches to wrenn-user for the recipe.
var preBuildCmds = []string{ var preBuildCmds = []string{
"RUN apt update", "RUN apt update",
"USER wrenn-user",
"WORKDIR /home/wrenn-user",
} }
// postBuildCmds run after the user recipe to clean up caches and reduce image size. // postBuildCmds run after the user recipe to clean up caches and reduce image size.
@ -36,6 +39,7 @@ var postBuildCmds = []string{
"RUN apt clean", "RUN apt clean",
"RUN apt autoremove -y", "RUN apt autoremove -y",
"RUN rm -rf /var/lib/apt/lists/*", "RUN rm -rf /var/lib/apt/lists/*",
"RUN rm -rf /tmp/build-files /tmp/build-files.*",
} }
// buildAgentClient is the subset of the host agent client used by the build worker. // buildAgentClient is the subset of the host agent client used by the build worker.
@ -43,6 +47,7 @@ type buildAgentClient interface {
CreateSandbox(ctx context.Context, req *connect.Request[pb.CreateSandboxRequest]) (*connect.Response[pb.CreateSandboxResponse], error) CreateSandbox(ctx context.Context, req *connect.Request[pb.CreateSandboxRequest]) (*connect.Response[pb.CreateSandboxResponse], error)
DestroySandbox(ctx context.Context, req *connect.Request[pb.DestroySandboxRequest]) (*connect.Response[pb.DestroySandboxResponse], error) DestroySandbox(ctx context.Context, req *connect.Request[pb.DestroySandboxRequest]) (*connect.Response[pb.DestroySandboxResponse], error)
Exec(ctx context.Context, req *connect.Request[pb.ExecRequest]) (*connect.Response[pb.ExecResponse], error) Exec(ctx context.Context, req *connect.Request[pb.ExecRequest]) (*connect.Response[pb.ExecResponse], error)
WriteFile(ctx context.Context, req *connect.Request[pb.WriteFileRequest]) (*connect.Response[pb.WriteFileResponse], error)
CreateSnapshot(ctx context.Context, req *connect.Request[pb.CreateSnapshotRequest]) (*connect.Response[pb.CreateSnapshotResponse], error) CreateSnapshot(ctx context.Context, req *connect.Request[pb.CreateSnapshotRequest]) (*connect.Response[pb.CreateSnapshotResponse], error)
FlattenRootfs(ctx context.Context, req *connect.Request[pb.FlattenRootfsRequest]) (*connect.Response[pb.FlattenRootfsResponse], error) FlattenRootfs(ctx context.Context, req *connect.Request[pb.FlattenRootfsRequest]) (*connect.Response[pb.FlattenRootfsResponse], error)
} }
@ -56,6 +61,7 @@ type BuildService struct {
mu sync.Mutex mu sync.Mutex
cancelMap map[string]context.CancelFunc // buildID → per-build cancel func cancelMap map[string]context.CancelFunc // buildID → per-build cancel func
filesMap map[string][]byte // buildID → uploaded archive bytes
} }
// BuildCreateParams holds the parameters for creating a template build. // BuildCreateParams holds the parameters for creating a template build.
@ -67,6 +73,27 @@ type BuildCreateParams struct {
VCPUs int32 VCPUs int32
MemoryMB int32 MemoryMB int32
SkipPrePost bool SkipPrePost bool
Archive []byte // Optional tar/tar.gz/zip archive for COPY commands.
ArchiveName string // Original filename (used to detect format).
}
// storeArchive stores uploaded archive bytes keyed by build ID for the worker.
func (s *BuildService) storeArchive(buildID string, data []byte) {
s.mu.Lock()
defer s.mu.Unlock()
if s.filesMap == nil {
s.filesMap = make(map[string][]byte)
}
s.filesMap[buildID] = data
}
// takeArchive retrieves and removes stored archive bytes for a build.
func (s *BuildService) takeArchive(buildID string) []byte {
s.mu.Lock()
defer s.mu.Unlock()
data := s.filesMap[buildID]
delete(s.filesMap, buildID)
return data
} }
// Create inserts a new build record and enqueues it to Redis. // Create inserts a new build record and enqueues it to Redis.
@ -117,6 +144,11 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
return db.TemplateBuild{}, fmt.Errorf("enqueue build: %w", err) return db.TemplateBuild{}, fmt.Errorf("enqueue build: %w", err)
} }
// Store archive for the worker if provided.
if len(p.Archive) > 0 {
s.storeArchive(buildIDStr, p.Archive)
}
return build, nil return build, nil
} }
@ -303,6 +335,16 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
HostID: host.ID, HostID: host.ID,
}) })
// Upload and extract build archive if provided.
archive := s.takeArchive(buildIDStr)
if len(archive) > 0 {
if err := s.uploadAndExtractArchive(buildCtx, agent, sandboxIDStr, archive, buildIDStr); err != nil {
s.destroySandbox(buildCtx, agent, sandboxIDStr)
s.failBuild(buildCtx, buildID, fmt.Sprintf("archive upload failed: %v", err))
return
}
}
// Parse recipe steps. preBuildCmds and postBuildCmds are hardcoded and always // Parse recipe steps. preBuildCmds and postBuildCmds are hardcoded and always
// valid; panic on error is appropriate here since it would be a programmer mistake. // valid; panic on error is appropriate here since it would be a programmer mistake.
preBuildSteps, err := recipe.ParseRecipe(preBuildCmds) preBuildSteps, err := recipe.ParseRecipe(preBuildCmds)
@ -331,10 +373,18 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
"HOME": "/root", "HOME": "/root",
} }
} }
bctx := &recipe.ExecContext{EnvVars: envVars} bctx := &recipe.ExecContext{EnvVars: envVars, User: "root"}
// Per-step progress callback for live UI updates.
progressFn := func(currentStep int, allEntries []recipe.BuildLogEntry) {
s.updateLogs(buildCtx, buildID, currentStep, allEntries)
}
runPhase := func(phase string, steps []recipe.Step, defaultTimeout time.Duration) bool { runPhase := func(phase string, steps []recipe.Step, defaultTimeout time.Duration) bool {
newEntries, nextStep, ok := recipe.Execute(buildCtx, phase, steps, sandboxIDStr, step, defaultTimeout, bctx, agent.Exec) newEntries, nextStep, ok := recipe.Execute(buildCtx, phase, steps, sandboxIDStr, step, defaultTimeout, bctx, agent.Exec, func(currentStep int, phaseEntries []recipe.BuildLogEntry) {
// Progress callback: combine prior logs with current phase entries.
progressFn(currentStep, append(logs, phaseEntries...))
})
logs = append(logs, newEntries...) logs = append(logs, newEntries...)
step = nextStep step = nextStep
s.updateLogs(buildCtx, buildID, step, logs) s.updateLogs(buildCtx, buildID, step, logs)
@ -344,24 +394,40 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
if buildCtx.Err() != nil { if buildCtx.Err() != nil {
return false return false
} }
last := newEntries[len(newEntries)-1] reason := "unknown error"
reason := last.Stderr if len(newEntries) > 0 {
if reason == "" { last := newEntries[len(newEntries)-1]
reason = fmt.Sprintf("exit code %d", last.Exit) reason = last.Stderr
if reason == "" {
reason = fmt.Sprintf("exit code %d", last.Exit)
}
} }
s.failBuild(buildCtx, buildID, fmt.Sprintf("%s step %d failed: %s", phase, step, reason)) s.failBuild(buildCtx, buildID, fmt.Sprintf("%s step %d failed: %s", phase, step, reason))
} }
return ok return ok
} }
// Phase 1: Pre-build (as root) — creates wrenn-user, updates apt.
if !build.SkipPrePost { if !build.SkipPrePost {
if !runPhase("pre-build", preBuildSteps, 0) { if !runPhase("pre-build", preBuildSteps, 0) {
return return
} }
} }
// Phase 2: User recipe — starts as wrenn-user (set by USER in pre-build)
// or root if skip_pre_post.
if !runPhase("recipe", userRecipeSteps, buildCommandTimeout) { if !runPhase("recipe", userRecipeSteps, buildCommandTimeout) {
return return
} }
// Capture the final user and env vars as template defaults.
// Filter out user-specific and runtime vars that should be resolved at
// sandbox creation time, not baked in from the build environment.
templateDefaultUser := bctx.User
templateDefaultEnv := filterBuildEnv(bctx.EnvVars)
// Phase 3: Post-build (as root) — cleanup.
bctx.User = "root"
if !build.SkipPrePost { if !build.SkipPrePost {
if !runPhase("post-build", postBuildSteps, 0) { if !runPhase("post-build", postBuildSteps, 0) {
return return
@ -430,19 +496,34 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
templateType = "snapshot" templateType = "snapshot"
} }
// Serialize env vars for DB storage.
defaultEnvJSON, err := json.Marshal(templateDefaultEnv)
if err != nil {
defaultEnvJSON = []byte("{}")
}
if _, err := s.DB.InsertTemplate(buildCtx, db.InsertTemplateParams{ if _, err := s.DB.InsertTemplate(buildCtx, db.InsertTemplateParams{
ID: build.TemplateID, ID: build.TemplateID,
Name: build.Name, Name: build.Name,
Type: templateType, Type: templateType,
Vcpus: build.Vcpus, Vcpus: build.Vcpus,
MemoryMb: build.MemoryMb, MemoryMb: build.MemoryMb,
SizeBytes: sizeBytes, SizeBytes: sizeBytes,
TeamID: id.PlatformTeamID, TeamID: id.PlatformTeamID,
DefaultUser: templateDefaultUser,
DefaultEnv: defaultEnvJSON,
}); err != nil { }); err != nil {
log.Error("failed to insert template record", "error", err) log.Error("failed to insert template record", "error", err)
// Build succeeded on disk, just DB record failed — don't mark as failed. // Build succeeded on disk, just DB record failed — don't mark as failed.
} }
// Record defaults on the build record for inspection.
_ = s.DB.UpdateBuildDefaults(buildCtx, db.UpdateBuildDefaultsParams{
ID: buildID,
DefaultUser: templateDefaultUser,
DefaultEnv: defaultEnvJSON,
})
// For CreateSnapshot, the sandbox is already destroyed by the snapshot process. // For CreateSnapshot, the sandbox is already destroyed by the snapshot process.
// For FlattenRootfs, the sandbox is already destroyed by the flatten process. // For FlattenRootfs, the sandbox is already destroyed by the flatten process.
// No additional destroy needed. // No additional destroy needed.
@ -603,3 +684,87 @@ func parseSandboxEnv(raw string) map[string]string {
return envVars return envVars
} }
// uploadAndExtractArchive writes the archive to the sandbox and extracts it
// to /tmp/build-files/. Detects format from content (tar.gz, tar, zip).
func (s *BuildService) uploadAndExtractArchive(
ctx context.Context,
agent buildAgentClient,
sandboxID string,
archive []byte,
buildID string,
) error {
// Detect archive type from magic bytes.
var archivePath, extractCmd string
switch {
case len(archive) >= 2 && archive[0] == 0x1f && archive[1] == 0x8b:
// gzip (tar.gz)
archivePath = "/tmp/build-files.tar.gz"
extractCmd = "mkdir -p /tmp/build-files && tar xzf /tmp/build-files.tar.gz -C /tmp/build-files"
case len(archive) >= 4 && string(archive[:4]) == "PK\x03\x04":
// zip
archivePath = "/tmp/build-files.zip"
extractCmd = "mkdir -p /tmp/build-files && unzip -o /tmp/build-files.zip -d /tmp/build-files"
case len(archive) >= 262 && string(archive[257:262]) == "ustar":
// tar (ustar magic at offset 257)
archivePath = "/tmp/build-files.tar"
extractCmd = "mkdir -p /tmp/build-files && tar xf /tmp/build-files.tar -C /tmp/build-files"
default:
// Fallback: try tar.gz
archivePath = "/tmp/build-files.tar.gz"
extractCmd = "mkdir -p /tmp/build-files && tar xzf /tmp/build-files.tar.gz -C /tmp/build-files"
}
slog.Info("uploading build archive", "build_id", buildID, "path", archivePath, "size", len(archive))
// Write archive to VM.
if _, err := agent.WriteFile(ctx, connect.NewRequest(&pb.WriteFileRequest{
SandboxId: sandboxID,
Path: archivePath,
Content: archive,
})); err != nil {
return fmt.Errorf("write archive: %w", err)
}
// Extract and ensure files are readable.
fullCmd := extractCmd + " && chmod -R a+rX /tmp/build-files"
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", fullCmd},
TimeoutSec: 120,
}))
if err != nil {
return fmt.Errorf("extract archive: %w", err)
}
if resp.Msg.ExitCode != 0 {
return fmt.Errorf("extract archive: exit code %d: %s", resp.Msg.ExitCode, string(resp.Msg.Stderr))
}
return nil
}
// runtimeEnvVars lists env vars that are user- or session-specific and should
// not be persisted into template defaults. These are resolved at runtime by
// envd based on the actual user and sandbox context.
var runtimeEnvVars = map[string]bool{
"HOME": true, "USER": true, "LOGNAME": true, "SHELL": true,
"PWD": true, "OLDPWD": true, "HOSTNAME": true, "TERM": true,
"SHLVL": true, "_": true,
// Per-sandbox identifiers set by envd at boot via MMDS.
"WRENN_SANDBOX_ID": true, "WRENN_TEMPLATE_ID": true,
}
// filterBuildEnv returns a copy of envVars with runtime/user-specific
// variables removed so they don't override envd's per-user resolution.
func filterBuildEnv(envVars map[string]string) map[string]string {
filtered := make(map[string]string, len(envVars))
for k, v := range envVars {
if runtimeEnvVars[k] {
continue
}
filtered[k] = v
}
return filtered
}

View File

@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
@ -85,6 +86,8 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
// Resolve template name → (teamID, templateID). // Resolve template name → (teamID, templateID).
templateTeamID := id.PlatformTeamID templateTeamID := id.PlatformTeamID
templateID := id.MinimalTemplateID templateID := id.MinimalTemplateID
var templateDefaultUser string
var templateDefaultEnv map[string]string
if p.Template != "minimal" { if p.Template != "minimal" {
tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}) tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID})
if err != nil { if err != nil {
@ -92,6 +95,11 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
} }
templateTeamID = tmpl.TeamID templateTeamID = tmpl.TeamID
templateID = tmpl.ID templateID = tmpl.ID
templateDefaultUser = tmpl.DefaultUser
// Parse default_env JSONB into a map.
if len(tmpl.DefaultEnv) > 0 {
_ = json.Unmarshal(tmpl.DefaultEnv, &templateDefaultEnv)
}
// If the template is a snapshot, use its baked-in vcpus/memory. // If the template is a snapshot, use its baked-in vcpus/memory.
if tmpl.Type == "snapshot" { if tmpl.Type == "snapshot" {
p.VCPUs = tmpl.Vcpus p.VCPUs = tmpl.Vcpus
@ -140,14 +148,16 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
} }
resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
Template: p.Template, Template: p.Template,
TeamId: id.UUIDString(templateTeamID), TeamId: id.UUIDString(templateTeamID),
TemplateId: id.UUIDString(templateID), TemplateId: id.UUIDString(templateID),
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec, TimeoutSec: p.TimeoutSec,
DiskSizeMb: p.DiskSizeMB, DiskSizeMb: p.DiskSizeMB,
DefaultUser: templateDefaultUser,
DefaultEnv: templateDefaultEnv,
})) }))
if err != nil { if err != nil {
if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
@ -249,9 +259,24 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU
sandboxIDStr := id.FormatSandboxID(sandboxID) sandboxIDStr := id.FormatSandboxID(sandboxID)
// Look up template defaults for resume.
var resumeDefaultUser string
var resumeDefaultEnv map[string]string
if sb.TemplateID.Valid {
tmpl, err := s.DB.GetTemplate(ctx, sb.TemplateID)
if err == nil {
resumeDefaultUser = tmpl.DefaultUser
if len(tmpl.DefaultEnv) > 0 {
_ = json.Unmarshal(tmpl.DefaultEnv, &resumeDefaultEnv)
}
}
}
resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
TimeoutSec: sb.TimeoutSec, TimeoutSec: sb.TimeoutSec,
DefaultUser: resumeDefaultUser,
DefaultEnv: resumeDefaultEnv,
})) }))
if err != nil { if err != nil {
return db.Sandbox{}, fmt.Errorf("agent resume: %w", err) return db.Sandbox{}, fmt.Errorf("agent resume: %w", err)

View File

@ -40,7 +40,11 @@ type CreateSandboxRequest struct {
// Team UUID that owns the template (hex string). All-zeros = platform. // Team UUID that owns the template (hex string). All-zeros = platform.
TeamId string `protobuf:"bytes,7,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` TeamId string `protobuf:"bytes,7,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
// Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel. // Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel.
TemplateId string `protobuf:"bytes,8,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` TemplateId string `protobuf:"bytes,8,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
// Default unix user for the sandbox (set in envd via PostInit).
DefaultUser string `protobuf:"bytes,9,opt,name=default_user,json=defaultUser,proto3" json:"default_user,omitempty"`
// Default environment variables (set in envd via PostInit).
DefaultEnv map[string]string `protobuf:"bytes,10,rep,name=default_env,json=defaultEnv,proto3" json:"default_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -131,6 +135,20 @@ func (x *CreateSandboxRequest) GetTemplateId() string {
return "" return ""
} }
func (x *CreateSandboxRequest) GetDefaultUser() string {
if x != nil {
return x.DefaultUser
}
return ""
}
func (x *CreateSandboxRequest) GetDefaultEnv() map[string]string {
if x != nil {
return x.DefaultEnv
}
return nil
}
type CreateSandboxResponse struct { type CreateSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
@ -356,7 +374,11 @@ type ResumeSandboxRequest struct {
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
// TTL in seconds restored from the DB so the reaper can auto-pause // TTL in seconds restored from the DB so the reaper can auto-pause
// the sandbox again after inactivity. 0 means no auto-pause. // the sandbox again after inactivity. 0 means no auto-pause.
TimeoutSec int32 `protobuf:"varint,2,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` TimeoutSec int32 `protobuf:"varint,2,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
// Default unix user for the sandbox (set in envd via PostInit on resume).
DefaultUser string `protobuf:"bytes,3,opt,name=default_user,json=defaultUser,proto3" json:"default_user,omitempty"`
// Default environment variables (set in envd via PostInit on resume).
DefaultEnv map[string]string `protobuf:"bytes,4,rep,name=default_env,json=defaultEnv,proto3" json:"default_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -405,6 +427,20 @@ func (x *ResumeSandboxRequest) GetTimeoutSec() int32 {
return 0 return 0
} }
func (x *ResumeSandboxRequest) GetDefaultUser() string {
if x != nil {
return x.DefaultUser
}
return ""
}
func (x *ResumeSandboxRequest) GetDefaultEnv() map[string]string {
if x != nil {
return x.DefaultEnv
}
return nil
}
type ResumeSandboxResponse struct { type ResumeSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
@ -3429,7 +3465,7 @@ var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" + const file_hostagent_proto_rawDesc = "" +
"\n" + "\n" +
"\x0fhostagent.proto\x12\fhostagent.v1\"\x81\x02\n" + "\x0fhostagent.proto\x12\fhostagent.v1\"\xb8\x03\n" +
"\x14CreateSandboxRequest\x12\x1d\n" + "\x14CreateSandboxRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x05 \x01(\tR\tsandboxId\x12\x1a\n" + "sandbox_id\x18\x05 \x01(\tR\tsandboxId\x12\x1a\n" +
@ -3442,7 +3478,14 @@ const file_hostagent_proto_rawDesc = "" +
"diskSizeMb\x12\x17\n" + "diskSizeMb\x12\x17\n" +
"\ateam_id\x18\a \x01(\tR\x06teamId\x12\x1f\n" + "\ateam_id\x18\a \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\b \x01(\tR\n" + "\vtemplate_id\x18\b \x01(\tR\n" +
"templateId\"g\n" + "templateId\x12!\n" +
"\fdefault_user\x18\t \x01(\tR\vdefaultUser\x12S\n" +
"\vdefault_env\x18\n" +
" \x03(\v22.hostagent.v1.CreateSandboxRequest.DefaultEnvEntryR\n" +
"defaultEnv\x1a=\n" +
"\x0fDefaultEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"g\n" +
"\x15CreateSandboxResponse\x12\x1d\n" + "\x15CreateSandboxResponse\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
@ -3455,12 +3498,18 @@ const file_hostagent_proto_rawDesc = "" +
"\x13PauseSandboxRequest\x12\x1d\n" + "\x13PauseSandboxRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x16\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x16\n" +
"\x14PauseSandboxResponse\"V\n" + "\x14PauseSandboxResponse\"\x8d\x02\n" +
"\x14ResumeSandboxRequest\x12\x1d\n" + "\x14ResumeSandboxRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" +
"\vtimeout_sec\x18\x02 \x01(\x05R\n" + "\vtimeout_sec\x18\x02 \x01(\x05R\n" +
"timeoutSec\"g\n" + "timeoutSec\x12!\n" +
"\fdefault_user\x18\x03 \x01(\tR\vdefaultUser\x12S\n" +
"\vdefault_env\x18\x04 \x03(\v22.hostagent.v1.ResumeSandboxRequest.DefaultEnvEntryR\n" +
"defaultEnv\x1a=\n" +
"\x0fDefaultEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"g\n" +
"\x15ResumeSandboxResponse\x12\x1d\n" + "\x15ResumeSandboxResponse\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
@ -3719,7 +3768,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
return file_hostagent_proto_rawDescData return file_hostagent_proto_rawDescData
} }
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 61) var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 63)
var file_hostagent_proto_goTypes = []any{ var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest (*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse (*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
@ -3781,79 +3830,83 @@ var file_hostagent_proto_goTypes = []any{
(*PtyResizeResponse)(nil), // 57: hostagent.v1.PtyResizeResponse (*PtyResizeResponse)(nil), // 57: hostagent.v1.PtyResizeResponse
(*PtyKillRequest)(nil), // 58: hostagent.v1.PtyKillRequest (*PtyKillRequest)(nil), // 58: hostagent.v1.PtyKillRequest
(*PtyKillResponse)(nil), // 59: hostagent.v1.PtyKillResponse (*PtyKillResponse)(nil), // 59: hostagent.v1.PtyKillResponse
nil, // 60: hostagent.v1.PtyAttachRequest.EnvsEntry nil, // 60: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
nil, // 61: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
nil, // 62: hostagent.v1.PtyAttachRequest.EnvsEntry
} }
var file_hostagent_proto_depIdxs = []int32{ var file_hostagent_proto_depIdxs = []int32{
16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo 60, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
23, // 1: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart 61, // 1: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
24, // 2: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData 16, // 2: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
25, // 3: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd 23, // 3: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart
27, // 4: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta 24, // 4: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
33, // 5: hostagent.v1.ListDirResponse.entries:type_name -> hostagent.v1.FileEntry 25, // 5: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
33, // 6: hostagent.v1.MakeDirResponse.entry:type_name -> hostagent.v1.FileEntry 27, // 6: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta
42, // 7: hostagent.v1.GetSandboxMetricsResponse.points:type_name -> hostagent.v1.MetricPoint 33, // 7: hostagent.v1.ListDirResponse.entries:type_name -> hostagent.v1.FileEntry
42, // 8: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint 33, // 8: hostagent.v1.MakeDirResponse.entry:type_name -> hostagent.v1.FileEntry
42, // 9: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint 42, // 9: hostagent.v1.GetSandboxMetricsResponse.points:type_name -> hostagent.v1.MetricPoint
42, // 10: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint 42, // 10: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
60, // 11: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry 42, // 11: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
51, // 12: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted 42, // 12: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
52, // 13: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput 62, // 13: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
53, // 14: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited 51, // 14: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted
0, // 15: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest 52, // 15: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput
2, // 16: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest 53, // 16: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited
4, // 17: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest 0, // 17: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
6, // 18: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest 2, // 18: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
12, // 19: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest 4, // 19: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
14, // 20: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest 6, // 20: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
17, // 21: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest 12, // 21: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
19, // 22: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest 14, // 22: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
31, // 23: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest 17, // 23: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
34, // 24: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest 19, // 24: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
36, // 25: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest 31, // 25: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
8, // 26: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest 34, // 26: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
10, // 27: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest 36, // 27: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
21, // 28: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest 8, // 28: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
26, // 29: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest 10, // 29: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
29, // 30: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest 21, // 30: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
38, // 31: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest 26, // 31: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
40, // 32: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest 29, // 32: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
43, // 33: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest 38, // 33: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
45, // 34: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest 40, // 34: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
47, // 35: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest 43, // 35: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
49, // 36: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest 45, // 36: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
54, // 37: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest 47, // 37: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
56, // 38: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest 49, // 38: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
58, // 39: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest 54, // 39: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
1, // 40: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse 56, // 40: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
3, // 41: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse 58, // 41: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
5, // 42: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse 1, // 42: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
7, // 43: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse 3, // 43: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
13, // 44: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse 5, // 44: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
15, // 45: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse 7, // 45: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
18, // 46: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse 13, // 46: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
20, // 47: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse 15, // 47: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
32, // 48: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse 18, // 48: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
35, // 49: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse 20, // 49: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
37, // 50: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse 32, // 50: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
9, // 51: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse 35, // 51: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
11, // 52: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse 37, // 52: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
22, // 53: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse 9, // 53: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
28, // 54: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse 11, // 54: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
30, // 55: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse 22, // 55: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
39, // 56: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse 28, // 56: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
41, // 57: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse 30, // 57: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
44, // 58: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse 39, // 58: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
46, // 59: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse 41, // 59: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
48, // 60: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse 44, // 60: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
50, // 61: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse 46, // 61: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
55, // 62: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse 48, // 62: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
57, // 63: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse 50, // 63: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
59, // 64: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse 55, // 64: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
40, // [40:65] is the sub-list for method output_type 57, // 65: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
15, // [15:40] is the sub-list for method input_type 59, // 66: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
15, // [15:15] is the sub-list for extension type_name 42, // [42:67] is the sub-list for method output_type
15, // [15:15] is the sub-list for extension extendee 17, // [17:42] is the sub-list for method input_type
0, // [0:15] is the sub-list for field type_name 17, // [17:17] is the sub-list for extension type_name
17, // [17:17] is the sub-list for extension extendee
0, // [0:17] is the sub-list for field type_name
} }
func init() { file_hostagent_proto_init() } func init() { file_hostagent_proto_init() }
@ -3886,7 +3939,7 @@ func file_hostagent_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 61, NumMessages: 63,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@ -119,6 +119,12 @@ message CreateSandboxRequest {
// Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel. // Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel.
string template_id = 8; string template_id = 8;
// Default unix user for the sandbox (set in envd via PostInit).
string default_user = 9;
// Default environment variables (set in envd via PostInit).
map<string, string> default_env = 10;
} }
message CreateSandboxResponse { message CreateSandboxResponse {
@ -145,6 +151,12 @@ message ResumeSandboxRequest {
// TTL in seconds restored from the DB so the reaper can auto-pause // TTL in seconds restored from the DB so the reaper can auto-pause
// the sandbox again after inactivity. 0 means no auto-pause. // the sandbox again after inactivity. 0 means no auto-pause.
int32 timeout_sec = 2; int32 timeout_sec = 2;
// Default unix user for the sandbox (set in envd via PostInit on resume).
string default_user = 3;
// Default environment variables (set in envd via PostInit on resume).
map<string, string> default_env = 4;
} }
message ResumeSandboxResponse { message ResumeSandboxResponse {