Files
proxdeploy/src/app/(app)/templates/template-bauen.tsx
T
florian.wildeandClaude Opus 5.5 00712dc6ce feat(templates): Profile ins Template einbacken
Beim Template-Bau lassen sich Profile wählen (nur solche ohne
Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den
Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID
und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten
passenden Profilen und überspringt, was schon eingebacken ist.

Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit
Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in
die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch
einmal (idempotent, schnell).

Closes #022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:20:32 +02:00

181 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Knoten = { name: string; storages: string[]; bridges: string[] };
export function TemplateBauen({
server,
distributionen,
knoten,
faehigkeit,
profile,
}: {
server: string;
distributionen: Array<{ id: string; name: string; familie: string }>;
profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>;
knoten: Knoten[];
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
}) {
const router = useRouter();
const [werte, setWerte] = useState({
distribution: distributionen.find((d) => d.id === "debian-13")?.id ?? distributionen[0]?.id ?? "",
knoten: knoten[0]?.name ?? "",
storage: knoten[0]?.storages[0] ?? "",
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
profile: [] as string[],
});
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
const [sendet, setSendet] = useState(false);
const aktuell = knoten.find((k) => k.name === werte.knoten);
async function bauen(ereignis: React.FormEvent) {
ereignis.preventDefault();
setSendet(true);
setFehler(null);
try {
const { kurzId } = await apiAufruf<{ kurzId: string }>(
"POST",
`/api/v1/server/${server}/templates`,
werte,
);
router.push(`/auftraege/${kurzId}`);
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
? { text: ursache.message, hinweis: ursache.hinweis }
: { text: "Unerwarteter Fehler." },
);
setSendet(false);
}
}
return (
<form onSubmit={bauen} className="flex flex-col gap-4">
<Status art={faehigkeit.weg === "api" ? "erledigt" : faehigkeit.weg === "ssh" ? "laeuft" : "fehler"}>
{faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "}
{faehigkeit.grund}
</Status>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="distribution" label="Distribution">
<select
id="distribution"
className={EINGABE_KLASSE}
value={werte.distribution}
onChange={(e) => setWerte({ ...werte, distribution: e.target.value })}
>
{distributionen.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Feld>
<Feld id="knoten" label="Node">
<select
id="knoten"
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = knoten.find((x) => x.name === e.target.value);
setWerte({
...werte,
knoten: e.target.value,
storage: k?.storages[0] ?? "",
bridge: k?.bridges[0] ?? "",
});
}}
>
{knoten.map((k) => (
<option key={k.name}>{k.name}</option>
))}
</select>
</Feld>
<Feld id="storage" label="Storage">
<select
id="storage"
className={EINGABE_KLASSE}
value={werte.storage}
onChange={(e) => setWerte({ ...werte, storage: e.target.value })}
>
{aktuell?.storages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</Feld>
<Feld id="bridge" label="Bridge (Vorbereitungsstart)">
<select
id="bridge"
className={EINGABE_KLASSE}
value={werte.bridge}
onChange={(e) => setWerte({ ...werte, bridge: e.target.value })}
>
{aktuell?.bridges.map((b) => (
<option key={b}>{b}</option>
))}
</select>
</Feld>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
<p className="text-muted text-sm">
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{profile
.filter((p) =>
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
)
.map((p) => {
const benoetigt = profile.some(
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
);
return (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="accent-akzent h-4 w-4"
checked={werte.profile.includes(p.id) || benoetigt}
disabled={benoetigt && !werte.profile.includes(p.id)}
onChange={(e) =>
setWerte({
...werte,
profile: e.target.checked
? [...werte.profile, p.id]
: werte.profile.filter((x) => x !== p.id),
})
}
/>
{p.name}
</label>
);
})}
</div>
</fieldset>
<p className="text-muted text-sm">
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
installieren.
</p>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={sendet || faehigkeit.weg === "nicht_moeglich" || !werte.distribution}>
{sendet ? "Startet …" : "Template bauen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{fehler ? (
<div>
<Status art="fehler">{fehler.text}</Status>
{fehler.hinweis ? <p className="text-muted">{fehler.hinweis}</p> : null}
</div>
) : null}
</div>
</div>
</form>
);
}