From e4306a6a534d72870ad09dd1daaee5d271243a56 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 13:44:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(katalog):=20Profil-Editor=20mit=20Live-Pr?= =?UTF-8?q?=C3=BCfung=20und=20Skriptvorschau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins legen eigene Profile als YAML an; geprüft wird beim Tippen gegen dasselbe Schema wie der Katalog, dazu eine Vorschau des erzeugten Skripts. Katalogprofile bleiben schreibgeschützt und lassen sich als Kopie bearbeiten. Ein Profil, das ein anderes voraussetzt, lässt sich nicht löschen. Closes #011 Co-Authored-By: Claude Opus 5.5 --- docs/issues/011-profile-docker-zabbix.md | 17 +- docs/issues/022-template-profile.md | 26 +++ src/app/(app)/katalog/page.tsx | 16 +- src/app/(app)/katalog/profile/[id]/page.tsx | 45 +++++ src/app/(app)/katalog/profile/neu/page.tsx | 42 +++++ .../(app)/katalog/profile/profil-editor.tsx | 147 ++++++++++++++++ src/app/api/v1/profile/[id]/route.ts | 18 +- src/app/api/v1/profile/pruefen/route.ts | 12 ++ src/app/api/v1/profile/route.ts | 17 +- src/server/services/katalog.ts | 165 +++++++++++++++++- tests/e2e/profil-editor.spec.ts | 38 ++++ 11 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 docs/issues/022-template-profile.md create mode 100644 src/app/(app)/katalog/profile/[id]/page.tsx create mode 100644 src/app/(app)/katalog/profile/neu/page.tsx create mode 100644 src/app/(app)/katalog/profile/profil-editor.tsx create mode 100644 src/app/api/v1/profile/pruefen/route.ts create mode 100644 tests/e2e/profil-editor.spec.ts diff --git a/docs/issues/011-profile-docker-zabbix.md b/docs/issues/011-profile-docker-zabbix.md index 4ca32f4..97f27c6 100644 --- a/docs/issues/011-profile-docker-zabbix.md +++ b/docs/issues/011-profile-docker-zabbix.md @@ -1,22 +1,25 @@ # #011 Profile docker und zabbix-agent, Variablen, Editor -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** feature -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min ## Ziel Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor. ## Aufgaben -- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive) -- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie -- [ ] YAML-Editor mit Validierung und Fehlerstelle +- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive) +- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie +- [x] YAML-Editor mit Validierung und Fehlerstelle ## Akzeptanzkriterien -- [ ] docker run hello-world im ausgerollten Gast erfolgreich +- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02 ## Abhängigkeiten #005 ## Notizen / Entscheidungen -– +- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript. +- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen. +- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus. +- Löschen verweigert, solange ein anderes Profil es per `requires` braucht. diff --git a/docs/issues/022-template-profile.md b/docs/issues/022-template-profile.md new file mode 100644 index 0000000..62d1996 --- /dev/null +++ b/docs/issues/022-template-profile.md @@ -0,0 +1,26 @@ +# #022 Templates mit eingebackenen Profilen +**Status:** offen +**Meilenstein:** v0.3.0 +**Labels:** feature, proxmox +**Geschätzt:** 4 h **Tatsächlich:** – + +## Ziel +Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller +fertig sind. Wunsch von Florian am 2026-10-12. + +## Aufgaben +- [ ] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten) +- [ ] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment) +- [ ] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen +- [ ] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker` +- [ ] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen +- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template (Standard bleibt: Schlüssel je Deployment, siehe #015) + +## Akzeptanzkriterien +- [ ] Template debian-13 + docker auf pve-demo; Deploy daraus mit docker deutlich schneller als ohne + +## Abhängigkeiten +#007, #008 + +## Notizen / Entscheidungen +- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen). diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx index 057b32b..9041c3d 100644 --- a/src/app/(app)/katalog/page.tsx +++ b/src/app/(app)/katalog/page.tsx @@ -1,4 +1,5 @@ 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"; @@ -55,7 +56,16 @@ export default async function KatalogSeite() { - + + Profil anlegen + + ) : null + } + >
    {profile.map((p) => (
  • - {p.name}{" "} + + {p.name} + {" "} {p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"} diff --git a/src/app/(app)/katalog/profile/[id]/page.tsx b/src/app/(app)/katalog/profile/[id]/page.tsx new file mode 100644 index 0000000..190137b --- /dev/null +++ b/src/app/(app)/katalog/profile/[id]/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/db"; +import { darf } from "@/server/auth/rechte"; +import { seiteSchuetzen } from "@/server/auth/zugang"; +import { ProfilEditor } from "../profil-editor"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + return { title: `Profil ${(await params).id}` }; +} + +/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */ +export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`); + const profil = await prisma.profil.findUnique({ where: { id } }); + if (!profil || profil.entferntAm) notFound(); + const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten"); + return ( +

    +
    +
    +

    + {profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"} +

    +

    {profil.name}

    +
    + {profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? ( + + Als Kopie bearbeiten + + ) : null} +
    + +
    + ); +} diff --git a/src/app/(app)/katalog/profile/neu/page.tsx b/src/app/(app)/katalog/profile/neu/page.tsx new file mode 100644 index 0000000..b8b8a2c --- /dev/null +++ b/src/app/(app)/katalog/profile/neu/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { prisma } from "@/lib/db"; +import { seiteSchuetzen } from "@/server/auth/zugang"; +import { ProfilEditor } from "../profil-editor"; + +export const metadata: Metadata = { title: "Profil anlegen" }; + +const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md +id: mein-profil +name: Mein Profil +description: Was das Profil tut +requires: [base] +variables: + - { name: beispiel, label: Beispielwert, default: hallo } +families: + debian: + - packages: [htop] + - file: + path: /etc/motd + content: | + {{ guest.name }} – {{ var.beispiel }} +`; + +/** Neues Profil, leer oder als Kopie (`?von=`). */ +export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) { + await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu"); + const von = (await searchParams).von; + const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null; + const anfang = quelle + ? quelle.quelltext + .replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`) + .replace(/^name:\s*(.+)$/m, "name: $1 (eigen)") + : VORLAGE; + return ( +
    +

    + {quelle ? `${quelle.name} als Kopie` : "Profil anlegen"} +

    + +
    + ); +} diff --git a/src/app/(app)/katalog/profile/profil-editor.tsx b/src/app/(app)/katalog/profile/profil-editor.tsx new file mode 100644 index 0000000..a295229 --- /dev/null +++ b/src/app/(app)/katalog/profile/profil-editor.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { Knopf } from "@/components/knopf"; +import { Karte } from "@/components/karte"; +import { Status } from "@/components/status"; +import { apiAufruf, ApiFehler } from "@/lib/api-client"; + +type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] }; + +/** + * YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts. + * Gespeichert wird nur, was der Server für gültig hält. + */ +export function ProfilEditor({ + id, + anfang, + schreibgeschuetzt, +}: { + id?: string; + anfang: string; + schreibgeschuetzt: boolean; +}) { + const router = useRouter(); + const [text, setText] = useState(anfang); + const [pruefung, setPruefung] = useState(null); + const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null); + const [speichert, setSpeichert] = useState(false); + const zaehler = useRef(0); + + useEffect(() => { + const nummer = ++zaehler.current; + const uhr = setTimeout(async () => { + try { + const ergebnis = await apiAufruf("POST", "/api/v1/profile/pruefen", { quelltext: text }); + // Eine ältere Antwort darf eine neuere nicht überschreiben. + if (nummer === zaehler.current) setPruefung(ergebnis); + } catch { + // Prüfung ist Komfort – beim Speichern prüft der Server ohnehin. + } + }, 400); + return () => clearTimeout(uhr); + }, [text]); + + async function speichern() { + setSpeichert(true); + setMeldung(null); + try { + const gespeichert = id + ? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text }) + : await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text }); + router.push(`/katalog/profile/${gespeichert.id}`); + router.refresh(); + } catch (fehler) { + setMeldung( + fehler instanceof ApiFehler + ? { text: fehler.message, hinweis: fehler.hinweis } + : { text: "Unerwarteter Fehler." }, + ); + } finally { + setSpeichert(false); + } + } + + async function loeschen() { + if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return; + try { + await apiAufruf("DELETE", `/api/v1/profile/${id}`); + router.push("/katalog"); + router.refresh(); + } catch (fehler) { + setMeldung( + fehler instanceof ApiFehler + ? { text: fehler.message, hinweis: fehler.hinweis } + : { text: "Unerwarteter Fehler." }, + ); + } + } + + return ( +
    + + +