Phase 3: env masking, image updates, port conflicts, resources, templates (0.3.0)

Backend:
- update_service: registry manifest digest check (Docker Hub/ghcr/lscr/private
  v2 token auth) vs local RepoDigests; in-memory cache + background loop
- port_service: parse compose ports, check /proc/net/tcp[6] + docker bindings
- template_service + bundled templates (jellyfin/vaultwarden/uptime-kuma/
  paperless-ngx/gitea) with {{VAR}} placeholders; custom templates in DB
- compose_edit set_resources (deploy.resources.limits/reservations)
- routers: images, ports, templates, editor/set-resources
- Template model; background update task wired into lifespan

Frontend:
- EnvEditor (table + raw, sensitive masking, quick-insert)
- Images page + UpdateBadge + dashboard 'updates available' banner
- PortConflictDialog pre-deploy check on Deploy
- ResourcePanel (CPU/RAM sliders) as editor Limits tab
- Templates page with per-variable instantiate form

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:49:38 +00:00
co-authored by Claude Opus 4.8
parent b553c1b861
commit 22d9864436
32 changed files with 1728 additions and 21 deletions
+3 -1
View File
@@ -6,7 +6,9 @@ import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { Networks, Images, Templates, Settings } from "@/pages/Placeholder";
import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates";
import { Networks, Settings } from "@/pages/Placeholder";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
+13
View File
@@ -30,4 +30,17 @@ export const editorApi = {
value,
})
.then((r) => r.data.yaml),
setResources: (
yaml: string,
service: string,
res: {
cpus?: number | null;
memory?: string | null;
cpus_reserve?: number | null;
memory_reserve?: string | null;
}
) =>
api
.post<{ yaml: string }>("/api/editor/set-resources", { yaml, service, ...res })
.then((r) => r.data.yaml),
};
+27
View File
@@ -0,0 +1,27 @@
import api from "./client";
export interface UpdateStatus {
image: string;
update_available: boolean;
current_digest: string | null;
remote_digest: string | null;
checked_at: number;
error: string | null;
}
export interface ImageRow {
id: string;
tag: string;
size: number;
created: string;
stacks: string[];
update: UpdateStatus | null;
}
export const imagesApi = {
list: () => api.get<ImageRow[]>("/api/images").then((r) => r.data),
updates: () =>
api.get<Record<string, UpdateStatus>>("/api/images/updates").then((r) => r.data),
check: () =>
api.post<Record<string, UpdateStatus>>("/api/images/check").then((r) => r.data),
};
+18
View File
@@ -0,0 +1,18 @@
import api from "./client";
export interface PortConflict {
port: number;
protocol: string;
service: string | null;
used_by: string;
}
export const portsApi = {
conflicts: (yaml: string, ignore_stack?: string) =>
api
.post<{ conflicts: PortConflict[] }>("/api/ports/conflicts", {
yaml,
ignore_stack,
})
.then((r) => r.data.conflicts),
};
+38
View File
@@ -0,0 +1,38 @@
import api from "./client";
export interface TemplateVariable {
name: string;
description: string;
default: string;
}
export interface TemplateSummary {
id: string;
name: string;
description?: string | null;
tags: string[];
gpu?: string | null;
source: "bundled" | "custom";
}
export interface TemplateDetail extends TemplateSummary {
yaml: string;
variables: TemplateVariable[];
}
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>) =>
api
.post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, {
name,
values,
})
.then((r) => r.data),
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) =>
api.post("/api/templates", body).then((r) => r.data),
remove: (slug: string) =>
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
};
+157
View File
@@ -0,0 +1,157 @@
import { useMemo, useState } from "react";
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
import { Button, Input } from "@/components/ui";
interface Row {
key: string;
value: string;
}
const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i;
function parseEnv(text: string): Row[] {
return text
.split("\n")
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
.map((l) => {
const idx = l.indexOf("=");
return { key: l.slice(0, idx).trim(), value: l.slice(idx + 1) };
});
}
function serialize(rows: Row[]): string {
return rows
.filter((r) => r.key.trim())
.map((r) => `${r.key.trim()}=${r.value}`)
.join("\n")
.concat(rows.length ? "\n" : "");
}
const QUICK = [
{ key: "PUID", value: "1000" },
{ key: "PGID", value: "1000" },
{ key: "TZ", value: "Europe/Berlin" },
];
export function EnvEditor({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [mode, setMode] = useState<"table" | "raw">("table");
const [reveal, setReveal] = useState<Record<number, boolean>>({});
const rows = useMemo(() => parseEnv(value), [value]);
const update = (next: Row[]) => onChange(serialize(next));
const setRow = (i: number, patch: Partial<Row>) =>
update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const addRow = (row: Row = { key: "", value: "" }) => update([...rows, row]);
const delRow = (i: number) => update(rows.filter((_, idx) => idx !== i));
return (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center gap-2">
<button
onClick={() => setMode("table")}
className={
mode === "table"
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
}
>
<Table className="h-3.5 w-3.5" /> Table
</button>
<button
onClick={() => setMode("raw")}
className={
mode === "raw"
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
}
>
<FileText className="h-3.5 w-3.5" /> Raw
</button>
{mode === "table" && (
<div className="ml-auto flex gap-1">
{QUICK.map((q) => (
<button
key={q.key}
onClick={() => addRow(q)}
className="rounded border border-slate-300 px-2 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
+ {q.key}
</button>
))}
</div>
)}
</div>
{mode === "raw" ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs dark:border-slate-600 dark:bg-slate-800"
placeholder="KEY=value"
/>
) : (
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs text-slate-500">
<tr>
<th className="pb-1">Key</th>
<th className="pb-1">Value</th>
<th className="pb-1 w-10" />
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const sensitive = SENSITIVE_RE.test(r.key);
const masked = sensitive && !reveal[i];
return (
<tr key={i}>
<td className="pr-2 py-1">
<Input value={r.key} onChange={(e) => setRow(i, { key: e.target.value })} />
</td>
<td className="pr-2 py-1">
<div className="flex items-center gap-1">
<Input
type={masked ? "password" : "text"}
value={r.value}
onChange={(e) => setRow(i, { value: e.target.value })}
/>
{sensitive && (
<button
onClick={() => setReveal((s) => ({ ...s, [i]: !s[i] }))}
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
title={masked ? "Reveal" : "Hide"}
>
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
)}
</div>
</td>
<td className="py-1">
<button
onClick={() => delRow(i)}
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<Button variant="outline" className="mt-2" onClick={() => addRow()}>
<Plus className="h-4 w-4" /> Add variable
</Button>
</div>
)}
</div>
);
}
@@ -1,13 +1,14 @@
import { useEffect, useState } from "react";
import { RefreshCw, HardDrive, Cpu, Plug } from "lucide-react";
import { RefreshCw, HardDrive, Cpu, Plug, Gauge } from "lucide-react";
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
import { GPUSelector } from "@/components/gpu/GPUSelector";
import { DevicePanel } from "@/components/gpu/DevicePanel";
import { ResourcePanel } from "@/components/stacks/ResourcePanel";
import { editorApi } from "@/api/editor";
import { apiErrorMessage } from "@/api/client";
import { toast } from "sonner";
type Tab = "volumes" | "gpu" | "devices";
type Tab = "volumes" | "gpu" | "devices" | "resources";
export function EditorHelperPanel({
yaml,
@@ -83,6 +84,7 @@ export function EditorHelperPanel({
["volumes", "Volumes", HardDrive],
["gpu", "GPU", Cpu],
["devices", "Devices", Plug],
["resources", "Limits", Gauge],
] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => (
<button
key={id}
@@ -130,6 +132,14 @@ export function EditorHelperPanel({
}}
/>
)}
{tab === "resources" && (
<ResourcePanel
onApply={(res) => {
if (!guard()) return;
run(() => editorApi.setResources(yaml, service, res));
}}
/>
)}
</div>
</div>
);
@@ -0,0 +1,45 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui";
import type { PortConflict } from "@/api/ports";
export function PortConflictDialog({
conflicts,
onContinue,
onCancel,
}: {
conflicts: PortConflict[];
onContinue: () => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
</div>
<ul className="mb-4 space-y-2">
{conflicts.map((c, i) => (
<li
key={i}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm dark:border-slate-700"
>
Port <span className="font-mono font-semibold">{c.port}</span>/
{c.protocol}
{c.service && <span className="text-slate-500"> ({c.service})</span>}
already in use by <span className="font-medium">{c.used_by}</span>
</li>
))}
</ul>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
Edit Compose
</Button>
<Button variant="danger" onClick={onContinue}>
Continue anyway
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import { useState } from "react";
import { Check as CheckIcon } from "lucide-react";
import { Button } from "@/components/ui";
export function ResourcePanel({
onApply,
}: {
onApply: (res: {
cpus?: number | null;
memory?: string | null;
cpus_reserve?: number | null;
memory_reserve?: string | null;
}) => void;
}) {
const [cpuOn, setCpuOn] = useState(false);
const [cpus, setCpus] = useState(1);
const [memOn, setMemOn] = useState(false);
const [mem, setMem] = useState(512); // MB
const apply = () =>
onApply({
cpus: cpuOn ? cpus : null,
memory: memOn ? `${mem}m` : null,
});
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={cpuOn} onChange={(e) => setCpuOn(e.target.checked)} />
CPU limit
</label>
{cpuOn && (
<div className="space-y-1">
<input
type="range"
min={0.25}
max={16}
step={0.25}
value={cpus}
onChange={(e) => setCpus(Number(e.target.value))}
className="w-full accent-sky-500"
/>
<p className="text-xs text-slate-500">{cpus} CPU(s)</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={memOn} onChange={(e) => setMemOn(e.target.checked)} />
Memory limit
</label>
{memOn && (
<div className="space-y-1">
<input
type="range"
min={64}
max={16384}
step={64}
value={mem}
onChange={(e) => setMem(Number(e.target.value))}
className="w-full accent-sky-500"
/>
<p className="text-xs text-slate-500">
{mem >= 1024 ? `${(mem / 1024).toFixed(1)} GB` : `${mem} MB`}
</p>
</div>
)}
</div>
<p className="text-xs text-slate-400">
Generates a <code>deploy.resources.limits</code> block. Disable a checkbox
and apply to remove that limit.
</p>
<Button onClick={apply}>
<CheckIcon className="h-4 w-4" /> Apply to YAML
</Button>
</div>
);
}
+16 -1
View File
@@ -1,9 +1,11 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react";
import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react";
import { Card, Spinner } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { imagesApi } from "@/api/images";
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -15,9 +17,22 @@ export function Dashboard() {
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
const updates = useQuery({ queryKey: ["image-updates"], queryFn: imagesApi.updates, refetchInterval: 60000 });
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
return (
<div className="space-y-6">
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
<ArrowUpCircle className="h-5 w-5" />
{updateCount} image update{updateCount > 1 ? "s" : ""} available view on the Images page.
</Link>
)}
{/* Resource bar */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
+91
View File
@@ -0,0 +1,91 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
import { Button, Card, Spinner } from "@/components/ui";
import { imagesApi, type ImageRow } from "@/api/images";
import { apiErrorMessage } from "@/api/client";
import { formatBytes, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useState } from "react";
import { toast } from "sonner";
function UpdateBadge({ row }: { row: ImageRow }) {
const u = row.update;
if (!u) return <span className="inline-flex items-center gap-1 text-xs text-slate-400"><HelpCircle className="h-3.5 w-3.5" /> not checked</span>;
if (u.error) return <span className="text-xs text-amber-500"> {u.error}</span>;
if (u.update_available)
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<ArrowUpCircle className="h-3.5 w-3.5" /> update available
</span>
);
return (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 className="h-3.5 w-3.5" /> up to date
</span>
);
}
export function Images() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const qc = useQueryClient();
const [checking, setChecking] = useState(false);
const { data, isLoading } = useQuery({ queryKey: ["images"], queryFn: imagesApi.list });
const check = async () => {
setChecking(true);
const t = toast.loading("Checking for updates…");
try {
await imagesApi.check();
await qc.invalidateQueries({ queryKey: ["images"] });
toast.success("Update check complete", { id: t });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setChecking(false);
}
};
if (isLoading) return <Spinner />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
{isAdmin && (
<Button onClick={check} loading={checking}>
<RefreshCw className="h-4 w-4" /> Check updates
</Button>
)}
</div>
<Card className="overflow-x-auto p-0">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
<tr>
<th className="p-3">Image</th>
<th className="p-3">Used by</th>
<th className="p-3">Size</th>
<th className="p-3">Created</th>
<th className="p-3">Status</th>
</tr>
</thead>
<tbody>
{data?.map((row) => (
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
<td className="p-3 font-mono text-xs">{row.tag}</td>
<td className="p-3 text-xs text-slate-500">
{row.stacks.length ? row.stacks.join(", ") : "—"}
</td>
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
<td className="p-3 text-xs text-slate-500">
{row.created ? relativeTime(row.created) : "—"}
</td>
<td className="p-3"><UpdateBadge row={row} /></td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
);
}
+1 -3
View File
@@ -14,7 +14,5 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
);
}
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />;
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
+36 -9
View File
@@ -5,7 +5,10 @@ import Editor from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { EnvEditor } from "@/components/env/EnvEditor";
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
import { stacksApi } from "@/api/stacks";
import { portsApi, type PortConflict } from "@/api/ports";
import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme";
import { toast } from "sonner";
@@ -33,6 +36,8 @@ export function StackEditor() {
const [saving, setSaving] = useState(false);
const [convertOpen, setConvertOpen] = useState(false);
const [runCmd, setRunCmd] = useState("");
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
const [checking, setChecking] = useState(false);
const existing = useQuery({
queryKey: ["stack", id],
@@ -79,6 +84,22 @@ export function StackEditor() {
}
};
const onDeploy = async () => {
setChecking(true);
try {
const found = await portsApi.conflicts(yaml, id);
if (found.length > 0) {
setConflicts(found);
return;
}
} catch {
/* if the check fails, fall through and let compose surface errors */
} finally {
setChecking(false);
}
save(true);
};
const convert = async () => {
try {
const { yaml: converted } = await stacksApi.convert(runCmd);
@@ -145,14 +166,9 @@ export function StackEditor() {
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
<div className="h-full p-3">
<EnvEditor value={env} onChange={setEnv} />
</div>
)}
</div>
@@ -168,10 +184,21 @@ export function StackEditor() {
<Button variant="outline" onClick={() => save(false)} loading={saving}>
<Save className="h-4 w-4" /> Save Draft
</Button>
<Button onClick={() => save(true)} loading={saving}>
<Button onClick={onDeploy} loading={saving || checking}>
<Rocket className="h-4 w-4" /> Deploy
</Button>
</div>
{conflicts && (
<PortConflictDialog
conflicts={conflicts}
onContinue={() => {
setConflicts(null);
save(true);
}}
onCancel={() => setConflicts(null)}
/>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { LayoutTemplate, Cpu, Package } from "lucide-react";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { toast } from "sonner";
export function Templates() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [selected, setSelected] = useState<TemplateDetail | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
const open = async (t: TemplateSummary) => {
try {
setSelected(await templatesApi.get(t.id));
} catch (e) {
toast.error(apiErrorMessage(e));
}
};
if (isLoading) return <Spinner />;
return (
<div className="space-y-4">
<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">
<div className="flex items-center gap-2">
{t.source === "custom" ? (
<Package className="h-5 w-5 text-accent dark:text-accent-dark" />
) : (
<LayoutTemplate className="h-5 w-5 text-accent dark:text-accent-dark" />
)}
<span className="font-semibold">{t.name}</span>
{t.source === "custom" && <Badge>custom</Badge>}
</div>
{t.description && <p className="text-sm text-slate-500">{t.description}</p>}
<div className="flex flex-wrap gap-1">
{t.tags.map((tag) => (
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
{tag}
</span>
))}
{t.gpu && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Cpu className="h-3 w-3" /> {t.gpu}
</span>
)}
</div>
{isAdmin && (
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
Use template
</Button>
)}
</Card>
))}
</div>
{selected && (
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}
function UseTemplateDialog({
template,
onClose,
}: {
template: TemplateDetail;
onClose: () => void;
}) {
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 [busy, setBusy] = useState(false);
const create = async () => {
if (!name.trim()) {
toast.error("Stack name required");
return;
}
setBusy(true);
try {
const res = await templatesApi.instantiate(template.id, name, values);
toast.success(`Stack '${res.name}' created`);
navigate(`/stacks/${res.id}/edit`);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
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">
<h2 className="mb-3 text-lg font-semibold">Use {template.name}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Stack name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</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>
))}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={create} loading={busy}>Create stack</Button>
</div>
</div>
</div>
);
}