feat(vorlagen): Vorlagen (Presets) für Assistent, API und CLI

Gespeicherte Kombinationen aus Distribution, Ressourcen und Profilen.
Im Assistenten mit einem Klick vorbelegt oder dort gespeichert; die API
nimmt „vorlage“ entgegen (Grundlage für --preset der CLI). Eine Vorlage
liegt unter der Anfrage – was die Anfrage selbst sagt, gewinnt.

Beim ersten Start wird das Beispiel „Docker-Host klein“ aus der Vorgabe
angelegt, einmalig.

Closes #012

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:44:33 +02:00
co-authored by Claude Opus 5.5
parent e4306a6a53
commit 1b1ee4b876
17 changed files with 737 additions and 8 deletions
+164 -1
View File
@@ -11,6 +11,7 @@ import { Status } from "@/components/status";
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
@@ -47,6 +48,7 @@ export type AssistentDaten = {
}>;
benutzer: string;
version: string;
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
};
type Plan = {
@@ -95,6 +97,7 @@ type Werte = {
profile: string[];
variablen: Record<string, Record<string, string | boolean>>;
templateBauen: boolean;
vorlage: string;
};
const SCHRITTE = [
@@ -163,6 +166,52 @@ function anfrageAus(w: Werte): AusrollEingabe {
profile: w.profile,
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
templateBauen: w.templateBauen,
vorlage: w.vorlage || undefined,
};
}
/** Eine Vorlage in die Formularwerte übernehmen – nur, was sie festlegt. */
function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: AssistentDaten): Werte {
const s = (zahl: number | undefined, alt: string) => (zahl === undefined ? alt : String(zahl));
const server = p.server && daten.server.some((x) => x.name === p.server) ? p.server : w.server;
return {
...w,
vorlage: name,
server,
typ: p.typ ?? w.typ,
distribution: p.distribution ?? w.distribution,
cores: s(p.cores, w.cores),
speicherMb: s(p.speicherMb, w.speicherMb),
diskGb: s(p.diskGb, w.diskGb),
storage: p.storage ?? w.storage,
swapMb: s(p.swapMb, w.swapMb),
unprivilegiert: p.unprivilegiert ?? w.unprivilegiert,
nesting: p.features?.nesting ?? w.nesting,
bridge: p.netz?.bridge ?? w.bridge,
vlan: s(p.netz?.vlan, w.vlan),
profile: p.profile ?? w.profile,
variablen: (p.variablen as Werte["variablen"] | undefined) ?? w.variablen,
tags: p.tags ? p.tags.join(",") : w.tags,
};
}
function werteZuVorlage(w: Werte): VorlagenParameter {
const a = anfrageAus(w);
return {
typ: a.typ,
server: a.server,
distribution: a.distribution,
cores: Number(a.cores),
speicherMb: Number(a.speicherMb),
diskGb: Number(a.diskGb),
storage: a.storage,
...(a.typ === "lxc"
? { swapMb: Number(a.swapMb), unprivilegiert: a.unprivilegiert, features: a.features }
: {}),
netz: { bridge: a.netz.bridge, ...(a.netz.vlan ? { vlan: Number(a.netz.vlan) } : {}) },
profile: a.profile,
variablen: a.variablen as VorlagenParameter["variablen"],
tags: a.tags,
};
}
@@ -210,10 +259,29 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
profile: ["base"],
variablen: {},
templateBauen: false,
vorlage: "",
};
}, [daten, vorauswahl]);
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
const [werte, setWerte] = useState<Werte>(() => {
const ausUrl = { ...anfang, ...dekodieren(parameter.get("d")) };
// ?vorlage=<name> von der Vorlagenseite: einmal anwenden, danach steht alles in d.
const vorlage = !parameter.get("d")
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
: undefined;
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
});
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
name: string;
titel: string;
offen: boolean;
meldung: string | null;
}>({
name: "",
titel: "",
offen: false,
meldung: null,
});
const [plan, setPlan] = useState<Plan | null>(null);
const [fehler, setFehler] = useState<{
text: string;
@@ -388,6 +456,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
<div className="flex min-w-0 flex-col gap-6">
{SCHRITTE[schritt] === "Server" ? (
<Karte titel="Server und Node">
{daten.vorlagen.length > 0 ? (
<div className="mb-5">
<Feld
id="vorlage"
label="Vorlage"
hilfe="Belegt Distribution, Ressourcen und Profile vor – alles bleibt änderbar."
>
<select
id="vorlage"
className={EINGABE_KLASSE}
value={werte.vorlage}
onChange={(e) => {
const v = daten.vorlagen.find((x) => x.name === e.target.value);
if (v) setWerte((alt) => vorlageUebernehmen(alt, v.name, v.parameter, daten));
else setze({ vorlage: "" });
setPlan(null);
}}
>
<option value="">keine</option>
{daten.vorlagen.map((v) => (
<option key={v.name} value={v.name}>
{v.titel}
</option>
))}
</select>
</Feld>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="server" label="Server">
<select
@@ -977,6 +1073,73 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
</Knopf>
</div>
</div>
<div className="flex flex-col gap-3">
{!vorlageSpeichern.offen ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => setVorlageSpeichern((v) => ({ ...v, offen: true, meldung: null }))}
>
Als Vorlage speichern
</Knopf>
) : (
<Karte titel="Als Vorlage speichern">
<div className="grid gap-4 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<Feld id="vorlage-name" label="Kurzname (für --preset)">
<input
id="vorlage-name"
className={`${EINGABE_KLASSE} font-mono`}
value={vorlageSpeichern.name}
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, name: e.target.value }))}
placeholder="docker-host-klein"
/>
</Feld>
<Feld id="vorlage-titel" label="Titel">
<input
id="vorlage-titel"
className={EINGABE_KLASSE}
value={vorlageSpeichern.titel}
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, titel: e.target.value }))}
placeholder="Docker-Host klein"
/>
</Feld>
<Knopf
onClick={async () => {
try {
await apiAufruf("POST", "/api/v1/vorlagen", {
name: vorlageSpeichern.name,
titel: vorlageSpeichern.titel,
parameter: werteZuVorlage(werte),
});
setVorlageSpeichern({
name: "",
titel: "",
offen: false,
meldung: `Vorlage „${vorlageSpeichern.titel}“ gespeichert.`,
});
} catch (ursache) {
setVorlageSpeichern((v) => ({
...v,
meldung:
ursache instanceof ApiFehler ? ursache.message : "Fehler beim Speichern.",
}));
}
}}
>
Speichern
</Knopf>
</div>
<p className="text-muted mt-3 text-sm">
Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage.
</p>
</Karte>
)}
{vorlageSpeichern.meldung ? (
<Status art={vorlageSpeichern.meldung.startsWith("Vorlage") ? "erledigt" : "fehler"}>
{vorlageSpeichern.meldung}
</Status>
) : null}
</div>
</>
) : null}
</div>
+2
View File
@@ -8,6 +8,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { versionText } from "@/lib/version";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { distributionenListe, profileListe } from "@/server/services/katalog";
import { vorlagenListe } from "@/server/services/vorlagen";
import { Assistent, type AssistentDaten } from "./assistent";
export const metadata: Metadata = { title: "Ausrollen" };
@@ -73,6 +74,7 @@ export default async function Ausrollen() {
})),
benutzer: benutzer.benutzername,
version: versionText(),
vorlagen: await vorlagenListe(),
};
const vorauswahl = aktiverServer(
daten.server.map((s) => s.name),
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { vorlagenListe } from "@/server/services/vorlagen";
import { VorlageLoeschen } from "./vorlage-loeschen";
export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() {
const benutzer = await seiteSchuetzen("lesen", "/vorlagen");
const liste = await vorlagenListe();
const pflegen = darf(benutzer.rolle, "vorlagenPflegen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
<p className="text-muted text-sm">
Gespeicherte Kombinationen für den Assistenten und die CLI (
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
Vorlage speichern“.
</p>
</header>
{liste.length === 0 ? (
<Karte>
<Status art="offen">Noch keine Vorlage.</Status>
</Karte>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{liste.map((v) => {
const p = v.parameter;
const eckdaten = [
p.typ?.toUpperCase(),
p.distribution,
p.cores ? `${p.cores} CPU` : null,
p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null,
p.diskGb ? `${p.diskGb} GB Disk` : null,
].filter(Boolean);
return (
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
<div>
<p className="font-semibold">{v.titel}</p>
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
</div>
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
{p.profile && p.profile.length > 0 ? (
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
) : null}
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
<Link
href={`/ausrollen?vorlage=${v.name}`}
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
>
Ausrollen mit dieser Vorlage
</Link>
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { Knopf } from "@/components/knopf";
import { apiAufruf } from "@/lib/api-client";
export function VorlageLoeschen({ name }: { name: string }) {
const router = useRouter();
return (
<Knopf
variante="gefahr"
onClick={async () => {
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
router.refresh();
}}
>
Löschen
</Knopf>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageHolen, vorlageLoeschen, vorlageSpeichern } from "@/server/services/vorlagen";
type Kontext = { params: Promise<{ name: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlageHolen((await params).name));
});
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
(await params).name,
),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
await vorlageLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageSpeichern, vorlagenListe } from "@/server/services/vorlagen";
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlagenListe());
});
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+2
View File
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog");
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
try {
schluesselPruefen();
await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren();
await beispielVorlageAnlegen();
logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen",
+1 -1
View File
@@ -25,7 +25,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{
+3
View File
@@ -105,6 +105,8 @@ export const AusrollAnfrage = z
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
templateBauen: z.boolean().default(false),
startBeiBoot: z.boolean().default(true),
/** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */
vorlage: z.string().optional(),
})
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
message: "Mindestens 4 GB für eine VM.",
@@ -120,6 +122,7 @@ export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
*/
export function cliBefehl(a: AusrollAnfrage): string {
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
if (a.vorlage) teile.push(`--preset ${a.vorlage}`);
if (a.knoten) teile.push(`--node ${a.knoten}`);
teile.push(`--distro ${a.distribution}`);
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
+78
View File
@@ -0,0 +1,78 @@
import { z } from "@/lib/validierung";
/**
* Was eine Vorlage festlegen darf. Bewusst ohne Name, VMID, IP und Schlüssel – das ist je Gast
* verschieden. Server und Storage dürfen drin stehen, sind aber optional.
*/
export const VorlagenParameter = z
.object({
typ: z.enum(["vm", "lxc"]).optional(),
server: z.string().trim().optional(),
distribution: z.string().trim().optional(),
cores: z.coerce.number().int().min(1).max(128).optional(),
speicherMb: z.coerce.number().int().min(256).optional(),
diskGb: z.coerce.number().int().min(1).optional(),
storage: z.string().trim().optional(),
swapMb: z.coerce.number().int().min(0).optional(),
unprivilegiert: z.boolean().optional(),
features: z.object({ nesting: z.boolean(), keyctl: z.boolean() }).partial().optional(),
netz: z
.object({ bridge: z.string().trim(), vlan: z.coerce.number().int().min(1).max(4094) })
.partial()
.optional(),
profile: z.array(z.string()).optional(),
variablen: z
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
.optional(),
tags: z.array(z.string()).optional(),
})
.strict();
export type VorlagenParameter = z.infer<typeof VorlagenParameter>;
export const VorlageEingabe = z.object({
name: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9][a-z0-9-]{0,47}$/, "Kurzname: Kleinbuchstaben, Ziffern und Bindestrich."),
titel: z.string().trim().min(1, "Titel fehlt.").max(80),
beschreibung: z.string().trim().max(500).optional(),
parameter: VorlagenParameter,
});
/**
* Legt eine Vorlage unter eine Anfrage: was die Anfrage selbst angibt, gewinnt. Objekte (netz,
* features, variablen) werden feldweise gemischt, Listen ersetzt.
*/
export function vorlageAnwenden(
vorlage: VorlagenParameter,
anfrage: Record<string, unknown>,
): Record<string, unknown> {
const ergebnis: Record<string, unknown> = { ...vorlage };
for (const [schluessel, wert] of Object.entries(anfrage)) {
if (wert === undefined) continue;
const basis = ergebnis[schluessel];
if (
basis &&
typeof basis === "object" &&
!Array.isArray(basis) &&
wert &&
typeof wert === "object" &&
!Array.isArray(wert)
) {
ergebnis[schluessel] =
schluessel === "variablen"
? Object.fromEntries(
[...new Set([...Object.keys(basis), ...Object.keys(wert)])].map((p) => [
p,
{ ...(basis as Record<string, object>)[p], ...(wert as Record<string, object>)[p] },
]),
)
: { ...basis, ...wert };
} else {
ergebnis[schluessel] = wert;
}
}
return ergebnis;
}
+10 -1
View File
@@ -8,6 +8,8 @@ import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufl
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
@@ -29,7 +31,14 @@ export type Vorbereitet = {
};
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
const anfrage = AusrollAnfrage.parse(roh);
// Eine Vorlage (Preset) liegt unter der Anfrage: was die Anfrage selbst sagt, gewinnt.
const vorlageName =
roh && typeof roh === "object" && "vorlage" in roh ? (roh as { vorlage?: unknown }).vorlage : undefined;
const mitVorlage =
typeof vorlageName === "string" && vorlageName
? vorlageAnwenden((await vorlageHolen(vorlageName)).parameter, roh as Record<string, unknown>)
: roh;
const anfrage = AusrollAnfrage.parse(mitVorlage);
const server = await serverHolen(anfrage.server);
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
+128
View File
@@ -0,0 +1,128 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { VorlageEingabe, VorlagenParameter } from "@/lib/schemas/vorlage";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
export type VorlageSicht = {
name: string;
titel: string;
beschreibung: string | null;
parameter: VorlagenParameter;
geaendertAm: string;
};
const sicht = (v: {
name: string;
titel: string;
beschreibung: string | null;
parameter: unknown;
geaendertAm: Date;
}): VorlageSicht => ({
name: v.name,
titel: v.titel,
beschreibung: v.beschreibung,
parameter: v.parameter as VorlagenParameter,
geaendertAm: v.geaendertAm.toISOString(),
});
export async function vorlagenListe(): Promise<VorlageSicht[]> {
return (await prisma.vorlage.findMany({ orderBy: { titel: "asc" } })).map(sicht);
}
export async function vorlageHolen(name: string): Promise<VorlageSicht> {
const v = await prisma.vorlage.findUnique({ where: { name } });
if (!v) throw nichtGefunden(`Die Vorlage „${name}“`);
return sicht(v);
}
export async function vorlageSpeichern(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
bisher?: string,
): Promise<VorlageSicht> {
const eingabe = VorlageEingabe.parse(roh);
if (bisher && bisher !== eingabe.name) {
throw new AnwendungsFehler(400, "ungueltig", "Der Kurzname einer Vorlage lässt sich nicht ändern.");
}
const vorhanden = await prisma.vorlage.findUnique({ where: { name: eingabe.name } });
if (vorhanden && !bisher)
throw new AnwendungsFehler(409, "vergeben", `Die Vorlage „${eingabe.name}“ gibt es schon.`);
if (!vorhanden && bisher) throw nichtGefunden(`Die Vorlage „${bisher}“`);
const daten = {
titel: eingabe.titel,
beschreibung: eingabe.beschreibung ?? null,
parameter: eingabe.parameter as Prisma.InputJsonValue,
};
const v = await prisma.$transaction(async (tx) => {
const gespeichert = await tx.vorlage.upsert({
where: { name: eingabe.name },
create: { name: eingabe.name, ...daten, erstelltVonId: aufrufer.benutzer.id },
update: daten,
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: bisher ? "Vorlage geändert" : "Vorlage angelegt",
ziel: `vorlage:${eingabe.name}`,
ip,
},
tx,
);
return gespeichert;
});
return sicht(v);
}
export async function vorlageLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
await vorlageHolen(name);
await prisma.$transaction(async (tx) => {
await tx.vorlage.delete({ where: { name } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Vorlage gelöscht",
ziel: `vorlage:${name}`,
ip,
},
tx,
);
});
}
/**
* Beim ersten Start ein Beispiel aus der Vorgabe: „Docker-Host klein“. Nur einmal – wer es löscht,
* bekommt es nicht zurück (gemerkt in den Einstellungen).
*/
export async function beispielVorlageAnlegen(): Promise<void> {
const merker = "vorlagen.beispielAngelegt";
if (await prisma.einstellung.findUnique({ where: { schluessel: merker } })) return;
await prisma.$transaction([
prisma.vorlage.upsert({
where: { name: "docker-host-klein" },
create: {
name: "docker-host-klein",
titel: "Docker-Host klein",
beschreibung: "Debian 13 als VM mit 2 CPU, 4 GB RAM, 40 GB Disk – base und docker",
parameter: {
typ: "vm",
distribution: "debian-13",
cores: 2,
speicherMb: 4096,
diskGb: 40,
profile: ["base", "docker"],
},
},
update: {},
}),
prisma.einstellung.upsert({
where: { schluessel: merker },
create: { schluessel: merker, wert: true },
update: {},
}),
]);
}