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 @@