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
+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>
)}
</>
);
}