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 ( +
    + + +