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:
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
17
db/migrations/20260411182550_template_defaults.sql
Normal file
17
db/migrations/20260411182550_template_defaults.sql
Normal 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;
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]">
|
</div>
|
||||||
{type === 'platform' ? 'No platform hosts yet.' : 'No BYOC hosts across any team.'}
|
<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'}
|
||||||
</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>
|
||||||
@ -503,6 +527,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Token reveal -->
|
<!-- Token reveal -->
|
||||||
@ -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,13 +587,14 @@
|
|||||||
<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}
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
<!-- Delete confirmation -->
|
||||||
@ -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>
|
||||||
@ -633,6 +667,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
<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">
|
||||||
{#if type === 'templates'}
|
{#if type === 'templates'}
|
||||||
<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>
|
<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}
|
{:else}
|
||||||
<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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]">
|
</div>
|
||||||
{type === 'templates' ? 'No templates yet.' : 'No builds yet.'}
|
<p class="font-serif text-heading leading-snug text-[var(--color-text-secondary)]">
|
||||||
|
{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)]">
|
||||||
|
{build.current_step}/{build.total_steps}
|
||||||
</span>
|
</span>
|
||||||
{#if build.status === 'running' && build.total_steps > 0}
|
{#if build.total_steps > 0}
|
||||||
<div class="mt-1.5 h-1 w-20 overflow-hidden rounded-full bg-[var(--color-bg-4)]">
|
<div class="relative h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-bg-4)]">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-[var(--color-blue)] transition-all duration-500"
|
class="h-full rounded-full transition-all duration-700 ease-out {build.status === 'running' ? 'progress-bar-glow' : ''}"
|
||||||
style="width: {(build.current_step / build.total_steps) * 100}%"
|
style="width: {(build.current_step / build.total_steps) * 100}%; background: {statusColor(build.status)}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
||||||
|
<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>
|
<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>
|
||||||
@ -842,6 +915,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Delete Template Confirmation ────────────────────────────────── -->
|
<!-- ── Delete Template Confirmation ────────────────────────────────── -->
|
||||||
@ -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>
|
||||||
@ -894,6 +972,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -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">
|
||||||
@ -578,6 +577,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Launch Dialog -->
|
<!-- Launch Dialog -->
|
||||||
@ -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">
|
||||||
@ -708,6 +709,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,12 +107,55 @@ 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
|
||||||
|
var archive []byte
|
||||||
|
var archiveName string
|
||||||
|
|
||||||
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
|
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
|
||||||
@ -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)
|
||||||
|
|||||||
@ -217,6 +217,8 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
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)
|
||||||
|
|||||||
@ -160,6 +160,8 @@ type Template struct {
|
|||||||
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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,14 +139,16 @@ 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 {
|
||||||
@ -151,6 +159,8 @@ type InsertTemplateParams struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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:
|
||||||
bctx.WorkDir = st.Path
|
// Create the directory if it doesn't exist.
|
||||||
entries = append(entries, BuildLogEntry{Step: step, Phase: phase, Cmd: st.Raw, Ok: true})
|
mkdirEntry := execRawShell(ctx, st.Raw, sandboxID, phase, step, 10*time.Second, execFn,
|
||||||
|
"mkdir -p "+shellescape(st.Path))
|
||||||
case KindUSER, KindCOPY:
|
if !mkdirEntry.Ok {
|
||||||
verb := strings.ToUpper(strings.Fields(st.Raw)[0])
|
entries = append(entries, mkdirEntry)
|
||||||
entries = append(entries, BuildLogEntry{
|
|
||||||
Step: step,
|
|
||||||
Phase: phase,
|
|
||||||
Cmd: st.Raw,
|
|
||||||
Stderr: verb + " is not yet supported",
|
|
||||||
Ok: false,
|
|
||||||
})
|
|
||||||
return entries, step, false
|
return entries, step, false
|
||||||
|
}
|
||||||
|
bctx.WorkDir = st.Path
|
||||||
|
mkdirEntry.Ok = true
|
||||||
|
entries = append(entries, mkdirEntry)
|
||||||
|
|
||||||
|
case KindUSER:
|
||||||
|
entry, succeeded := execUser(ctx, st, sandboxID, phase, step, bctx, execFn)
|
||||||
|
entries = append(entries, entry)
|
||||||
|
if !succeeded {
|
||||||
|
return entries, step, false
|
||||||
|
}
|
||||||
|
|
||||||
|
case KindCOPY:
|
||||||
|
entry, succeeded := execCopy(ctx, st, sandboxID, phase, step, bctx, execFn)
|
||||||
|
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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import (
|
|||||||
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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
reason := "unknown error"
|
||||||
|
if len(newEntries) > 0 {
|
||||||
last := newEntries[len(newEntries)-1]
|
last := newEntries[len(newEntries)-1]
|
||||||
reason := last.Stderr
|
reason = last.Stderr
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = fmt.Sprintf("exit code %d", last.Exit)
|
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,6 +496,12 @@ 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,
|
||||||
@ -438,11 +510,20 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -148,6 +156,8 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
|||||||
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)
|
||||||
|
|||||||
@ -41,6 +41,10 @@ type CreateSandboxRequest struct {
|
|||||||
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"`
|
||||||
@ -357,6 +375,10 @@ type ResumeSandboxRequest struct {
|
|||||||
// 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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user