Phase 25: templates as stack folders (0.31.0)

Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 07:29:54 +00:00
co-authored by Claude Fable 5
parent 34cb215266
commit 1609b8bcc3
29 changed files with 564 additions and 329 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.30.0",
"version": "0.31.0",
"type": "module",
"scripts": {
"dev": "vite",
+15 -17
View File
@@ -1,11 +1,5 @@
import api from "./client";
export interface TemplateVariable {
name: string;
description: string;
default: string;
}
export interface TemplateSummary {
id: string;
name: string;
@@ -16,28 +10,32 @@ export interface TemplateSummary {
}
export interface TemplateDetail extends TemplateSummary {
yaml: string;
variables: TemplateVariable[];
compose: string;
env: string;
files: string[];
}
export const templatesApi = {
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
get: (id: string) =>
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
instantiate: (
id: string,
name: string,
values: Record<string, string>,
agentId?: number | null
) =>
instantiate: (id: string, name: string, agentId?: number | null) =>
api
.post<{ id: string; name: string; agent_id: number | null }>(
`/api/templates/${id}/instantiate`,
{ name, values, agent_id: agentId ?? null }
{ name, agent_id: agentId ?? null }
)
.then((r) => r.data),
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) =>
api.post("/api/templates", body).then((r) => r.data),
save: (body: {
name: string;
description?: string;
tags: string[];
gpu?: string | null;
compose: string;
env?: string;
}) => api.post("/api/templates", body).then((r) => r.data),
saveFromStack: (body: { stack_id: string; name: string; description?: string }) =>
api.post<{ id: string; name: string }>("/api/templates/from-stack", body).then((r) => r.data),
remove: (slug: string) =>
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
};
+50 -1
View File
@@ -10,9 +10,10 @@ import {
Pencil,
Power,
Trash2,
LayoutTemplate,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
@@ -20,6 +21,7 @@ import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { templatesApi } from "@/api/templates";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -78,6 +80,7 @@ export function StackDetail() {
<Power className="h-4 w-4" /> Down
</Button>
<BackupButton stackId={id} />
<SaveAsTemplateButton stackId={id} defaultName={data.name} />
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
@@ -230,3 +233,49 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
</>
);
}
function SaveAsTemplateButton({ stackId, defaultName }: { stackId: string; defaultName: string }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const save = async () => {
if (!name.trim()) {
toast.error("Template name required");
return;
}
setBusy(true);
try {
await templatesApi.saveFromStack({ stack_id: stackId, name: name.trim() });
toast.success(`Saved template “${name.trim()}`);
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<LayoutTemplate className="h-4 w-4" /> Save as template
</Button>
{open && (
<ConfirmDialog
title="Save as template"
message="Snapshots this stack's compose and .env into a reusable custom template."
confirmLabel="Save template"
busy={busy}
onConfirm={save}
onCancel={() => setOpen(false)}
>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Template name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
</ConfirmDialog>
)}
</>
);
}
+82 -23
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { LayoutTemplate, Cpu, Package } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
@@ -14,7 +15,9 @@ const selectClass =
export function Templates() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<TemplateDetail | null>(null);
const [toDelete, setToDelete] = useState<TemplateSummary | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
const open = async (t: TemplateSummary) => {
@@ -25,10 +28,28 @@ export function Templates() {
}
};
const remove = async () => {
if (!toDelete) return;
const slug = toDelete.id.replace(/^custom:/, "");
try {
await templatesApi.remove(slug);
toast.success(`Template '${toDelete.name}' deleted`);
queryClient.invalidateQueries({ queryKey: ["templates"] });
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setToDelete(null);
}
};
if (isLoading) return <Spinner />;
return (
<div className="space-y-4">
<p className="text-sm text-slate-500">
Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack,
then edit it like any other.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data?.map((t) => (
<Card key={t.id} className="flex flex-col gap-2">
@@ -55,9 +76,16 @@ export function Templates() {
)}
</div>
{isAdmin && (
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
Use template
</Button>
<div className="mt-2 flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => open(t)}>
Use template
</Button>
{t.source === "custom" && (
<Button variant="outline" onClick={() => setToDelete(t)} title="Delete template">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
)}
</Card>
))}
@@ -66,6 +94,17 @@ export function Templates() {
{selected && (
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
)}
{toDelete && (
<ConfirmDialog
title={`Delete template “${toDelete.name}”?`}
message="This removes the custom template folder. Existing stacks are not affected."
confirmLabel="Delete"
danger
onConfirm={remove}
onCancel={() => setToDelete(null)}
/>
)}
</div>
);
}
@@ -79,9 +118,6 @@ function UseTemplateDialog({
}) {
const navigate = useNavigate();
const [name, setName] = useState(template.name);
const [values, setValues] = useState<Record<string, string>>(
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
);
const [host, setHost] = useState("local");
const [busy, setBusy] = useState(false);
@@ -96,7 +132,7 @@ function UseTemplateDialog({
setBusy(true);
try {
const agentId = host === "local" ? null : Number(host);
const res = await templatesApi.instantiate(template.id, name, values, agentId);
const res = await templatesApi.instantiate(template.id, name, agentId);
toast.success(`Stack '${res.name}' created`);
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
else navigate(`/stacks/${res.id}/edit`);
@@ -109,7 +145,7 @@ function UseTemplateDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="max-h-[85vh] w-full max-w-2xl overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<h2 className="mb-3 sp-heading text-lg">Use {template.name}</h2>
<div className="space-y-3">
<label className="block space-y-1">
@@ -129,22 +165,45 @@ function UseTemplateDialog({
</select>
</label>
)}
{template.variables.map((v) => (
<label key={v.name} className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
{v.name}
{v.description && <span className="ml-1 font-normal text-slate-400"> {v.description}</span>}
</span>
<Input
value={values[v.name] ?? ""}
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
/>
</label>
))}
{template.files.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">Files</span>
<div className="flex flex-wrap gap-1">
{template.files.map((f) => (
<span
key={f}
className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
<FileCode className="h-3 w-3" /> {f}
</span>
))}
</div>
</div>
)}
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">compose.yaml</span>
<pre className="max-h-64 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
{template.compose}
</pre>
</div>
{template.env.trim() && (
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">.env (defaults)</span>
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
{template.env}
</pre>
</div>
)}
</div>
<p className="mt-3 text-xs text-slate-400">
The whole folder is copied into a new stack you can edit and deploy afterwards.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={create} loading={busy}>Create stack</Button>
<Button onClick={create} loading={busy}>Pull into stack</Button>
</div>
</div>
</div>