diff --git a/db/migrations/20260411182550_template_defaults.sql b/db/migrations/20260411182550_template_defaults.sql new file mode 100644 index 0000000..3378453 --- /dev/null +++ b/db/migrations/20260411182550_template_defaults.sql @@ -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; diff --git a/db/queries/template_builds.sql b/db/queries/template_builds.sql index 1fb07be..1a0e3b0 100644 --- a/db/queries/template_builds.sql +++ b/db/queries/template_builds.sql @@ -31,3 +31,8 @@ WHERE id = $1; UPDATE template_builds SET error = $2, status = 'failed', completed_at = NOW() WHERE id = $1; + +-- name: UpdateBuildDefaults :exec +UPDATE template_builds +SET default_user = $2, default_env = $3 +WHERE id = $1; diff --git a/db/queries/templates.sql b/db/queries/templates.sql index de4d6f2..fbea228 100644 --- a/db/queries/templates.sql +++ b/db/queries/templates.sql @@ -1,6 +1,6 @@ -- name: InsertTemplate :one -INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id) -VALUES ($1, $2, $3, $4, $5, $6, $7) +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, $8, $9) RETURNING *; -- name: GetTemplate :one diff --git a/frontend/src/lib/api/builds.ts b/frontend/src/lib/api/builds.ts index 1de23b8..fe9fa1f 100644 --- a/frontend/src/lib/api/builds.ts +++ b/frontend/src/lib/api/builds.ts @@ -1,4 +1,4 @@ -import { apiFetch, type ApiResult } from '$lib/api/client'; +import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client'; export type BuildLogEntry = { step: number; @@ -26,6 +26,8 @@ export type Build = { error?: string; sandbox_id?: string; host_id?: string; + default_user: string; + default_env: Record; created_at: string; started_at?: string; completed_at?: string; @@ -39,9 +41,18 @@ export type CreateBuildParams = { vcpus?: number; memory_mb?: number; skip_pre_post?: boolean; + archive?: File; }; export async function createBuild(params: CreateBuildParams): Promise> { + 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); } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 00fa381..d6e6459 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -22,3 +22,24 @@ export async function apiFetch(method: string, path: string, body?: unknown): return { ok: false, error: 'Unable to connect to the server' }; } } + +export async function apiFetchMultipart(method: string, path: string, formData: FormData): Promise> { + try { + const headers: Record = {}; + 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' }; + } +} diff --git a/frontend/src/lib/components/AdminSidebar.svelte b/frontend/src/lib/components/AdminSidebar.svelte index ebf4b64..d01e857 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -22,8 +22,8 @@ }; 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 { diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b5a56c1..c6c45dc 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/routes/admin/templates/+page.svelte b/frontend/src/routes/admin/templates/+page.svelte index 39b62da..67c3b0a 100644 --- a/frontend/src/routes/admin/templates/+page.svelte +++ b/frontend/src/routes/admin/templates/+page.svelte @@ -56,7 +56,8 @@ memory_mb: 512, recipe: '', healthcheck: '', - skip_pre_post: false + skip_pre_post: false, + archive: null as File | null }); let creating = $state(false); let createError = $state(null); @@ -131,12 +132,13 @@ healthcheck: createForm.healthcheck.trim() || undefined, vcpus: createForm.vcpus, 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) { 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]; activeTab = 'builds'; expandedBuildId = result.data.id; @@ -235,6 +237,8 @@ case 'RUN': return 'var(--color-blue)'; case 'START': return 'var(--color-accent-bright)'; 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)'; default: return 'var(--color-text-muted)'; } @@ -277,7 +281,7 @@

+ + {:else} + tar, tar.gz, or zip + {/if} + + +