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>
181 lines
6.4 KiB
TypeScript
181 lines
6.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|