diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1f6a8..c9a754f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt -- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004) -- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004) -- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018) ### Geändert @@ -20,6 +17,26 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.2.0] – 2026-10-12 + +### Hinzugefügt +- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004) +- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004) +- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018) +- Provisionierungs-Adapter `debian` und Profil-Engine: Profile werden zu einem idempotenten Skript; geprüft mit shellcheck und echt in Containern für alle fünf Distributionen (`pnpm test:container`) (#005) +- `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil (#005) +- Aufträge im worker (pg-boss) mit Phasen, Detail-Log und Live-Fortschritt per Server-Sent Events; Live-Ansicht im Aufbau der CLI-Ausgabe, Verlaufsseite (#006) +- Template-Bau: Cloud-Image mit Prüfsumme laden, VM mit q35/UEFI anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren; Templates-Seite (#007) +- VM ausrollen: Klonen, Hardware, cloud-init, Start, Guest-Agent, Provisionierung der Profile über den Guest-Agent – ohne SSH (#008) +- Deploy-Assistent mit 9 Schritten, Dry-Run mit allen geplanten Proxmox-Aufrufen und passendem CLI-Befehl (#008) + +### Behoben +- Provisionierungsskripte mit Umlauten scheiterten an Proxmox' file-write; sie werden jetzt base64-kodiert übertragen (#008) + +### Sicherheit +- Profil-Variablen werden in Shell-Befehlen immer gequotet; Zeitzone, Locale und Tastatur gegen strenge Muster geprüft (#005) +- VMIDs werden unter einer Sperre je Server vergeben – parallele Aufträge bekommen nie dieselbe ID (#006) + ## [0.1.0] – 2026-10-12 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 7888868..0d3fc21 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 1 h 24 min (laufende Sitzung) +**Gesamt:** 2 h 50 min (Sitzung 1) Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten. @@ -12,17 +12,21 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld | #002 Server-Verwaltung | 0 h 11 min | | #003 Verbindungstest | 0 h 25 min | | #004 Katalog | 0 h 09 min | +| #005 Adapter und Profile | 0 h 20 min | +| #006 Aufträge und Live-Fortschritt | 0 h 15 min | +| #007 Template-Bau | 0 h 12 min | +| #008 VM ausrollen und Assistent | 0 h 36 min | | Meilenstein | Zeit | |---|---| | v0.1.0 | 1 h 15 min | **abgeschlossen** -| v0.2.0 | – | in Arbeit +| v0.2.0 | 1 h 32 min | **abgeschlossen** --- -## 2026-10-12 · Sitzung 1 · 08:45–laufend +## 2026-10-12 · Sitzung 1 · 08:45–11:35 **Issue:** #001 -**Version:** 0.1.0-dev +**Version:** 0.1.0, 0.2.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go) @@ -35,6 +39,11 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld - Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet - Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht - README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“) +- #005 abgeschlossen: Adapter debian, Profil-Engine; base+docker+zabbix-agent echt in Containern auf Debian 12/13 und Ubuntu 22.04/24.04/26.04 inkl. Idempotenz +- #006 abgeschlossen: worker, Phasen, SSE, Live-Ansicht, VMID-Sperre +- #007 abgeschlossen: erstes echtes Template (9000, Debian 13) auf pve-demo in 5 min 26 s +- #008 abgeschlossen: erste echte VMs auf pve-demo – pd-test01 per API (base, docker), pd-test02 über den Assistenten (base, zabbix-agent), per SSH geprüft; 219 Tests, 12 Playwright-Abläufe +- Meilenstein v0.2.0 abgeschlossen, getaggt und gepusht - #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe - #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe - Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft) @@ -52,4 +61,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld - **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1. - **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat. - **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg. +- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen. +- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH. +- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy). +- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet. - **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe). diff --git a/README.md b/README.md index be92f25..071481e 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der | Etappe | Inhalt | Stand | |---|---|---| | 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft | -| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt | ◐ in Arbeit | -| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ○ geplant | +| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft | +| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes | | 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant | | 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant | | 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant | diff --git a/docs/issues/007-template-bau.md b/docs/issues/007-template-bau.md index 70efe3b..fff2bbf 100644 --- a/docs/issues/007-template-bau.md +++ b/docs/issues/007-template-bau.md @@ -2,7 +2,7 @@ **Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** feature, proxmox -**Geschätzt:** 8 h **Tatsächlich:** 0 h 20 min +**Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min ## Ziel Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren. diff --git a/docs/issues/008-vm-ausrollen.md b/docs/issues/008-vm-ausrollen.md index 3d50087..22ebd0e 100644 --- a/docs/issues/008-vm-ausrollen.md +++ b/docs/issues/008-vm-ausrollen.md @@ -1,24 +1,31 @@ # #008 Deploy-Assistent und VM ausrollen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** feature -**Geschätzt:** 10 h **Tatsächlich:** – +**Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min ## Ziel Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl. ## Aufgaben -- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan` -- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent -- [ ] Fehlendes Template → automatisch bauen anbieten -- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich -- [ ] Aufräumen nach Bestätigung +- [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus +- [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt +- [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan` +- [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag +- [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich +- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten ## Akzeptanzkriterien -- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis +- [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12 +- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code) ## Abhängigkeiten #007 ## Notizen / Entscheidungen -– +- **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes. +- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`). +- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil. +- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll. +- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“. +- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`). diff --git a/docs/issues/README.md b/docs/issues/README.md index bafbe62..a12fb46 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -10,8 +10,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min | | [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min | -| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 20 min | -| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | +| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min | +| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min | | [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | diff --git a/package.json b/package.json index 7e6b05e..79548e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxdeploy", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI", diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx new file mode 100644 index 0000000..7599f3e --- /dev/null +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -0,0 +1,982 @@ +"use client"; + +import { clsx } from "clsx"; +import { ArrowLeft, ArrowRight, Copy } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { Code, EINGABE_KLASSE, Feld, Schalter } from "@/components/formular"; +import { Karte } from "@/components/karte"; +import { Knopf } from "@/components/knopf"; +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"; + +// ── Daten vom Server ──────────────────────────────────────────────────────────────────────────── + +type ProfilVariable = { + name: string; + label: string; + type: "string" | "number" | "boolean" | "select"; + required: boolean; + default?: string | number | boolean; + options?: string[]; + help?: string; +}; + +export type AssistentDaten = { + server: Array<{ + name: string; + titel: string; + knoten: Array<{ + name: string; + speicherFrei: number; + storages: Array<{ name: string; vm: boolean; lxc: boolean; frei: number | null }>; + bridges: Array<{ name: string; vlan: boolean }>; + }>; + templates: Array<{ distribution: string; knoten: string; vmid: number }>; + }>; + distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>; + profile: Array<{ + id: string; + name: string; + beschreibung: string | null; + familien: string[]; + requires: string[]; + variablen: ProfilVariable[]; + }>; + benutzer: string; + version: string; +}; + +type Plan = { + kopf: string; + phasen: Array<{ + schluessel: string; + titel: string; + status: string; + detail: string | null; + ursache: string | null; + hinweis: string | null; + }>; + aufrufe: Array<{ methode: string; pfad: string; parameter?: Record }>; + skript: string | null; + cliBefehl: string; + automatisch: string[]; + ok: boolean; +}; + +// ── Zustand ───────────────────────────────────────────────────────────────────────────────────── + +type Werte = { + server: string; + knoten: string; + typ: "vm" | "lxc"; + distribution: string; + name: string; + vmid: string; + tags: string; + cores: string; + speicherMb: string; + diskGb: string; + storage: string; + swapMb: string; + unprivilegiert: boolean; + nesting: boolean; + bridge: string; + vlan: string; + modus: "dhcp" | "statisch"; + ip: string; + gateway: string; + dns: string; + suchdomain: string; + benutzer: string; + schluessel: string; + profile: string[]; + variablen: Record>; + templateBauen: boolean; +}; + +const SCHRITTE = [ + "Server", + "Typ", + "Distribution", + "Name", + "Ressourcen", + "Netzwerk", + "Zugang", + "Profile", + "Zusammenfassung", +] as const; + +function kodieren(werte: Werte): string { + return btoa(unescape(encodeURIComponent(JSON.stringify(werte)))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function dekodieren(text: string | null): Partial { + if (!text) return {}; + try { + return JSON.parse(decodeURIComponent(escape(atob(text.replace(/-/g, "+").replace(/_/g, "/"))))); + } catch { + return {}; + } +} + +const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel"; + +function anfrageAus(w: Werte): AusrollEingabe { + const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text)); + return { + typ: w.typ, + server: w.server, + knoten: w.knoten || undefined, + distribution: w.distribution, + name: w.name, + vmid: zahl(w.vmid), + tags: w.tags.split(/[\s,;]+/).filter(Boolean), + cores: Number(w.cores), + speicherMb: Number(w.speicherMb), + diskGb: Number(w.diskGb), + storage: w.storage, + swapMb: Number(w.swapMb), + unprivilegiert: w.unprivilegiert, + features: { nesting: w.nesting, keyctl: false }, + netz: { + bridge: w.bridge, + vlan: zahl(w.vlan), + modus: w.modus, + ip: w.modus === "statisch" ? w.ip : undefined, + gateway: w.modus === "statisch" && w.gateway ? w.gateway : undefined, + dns: w.dns.split(/[\s,;]+/).filter(Boolean), + suchdomain: w.suchdomain || undefined, + }, + zugang: { + benutzer: w.benutzer, + sshSchluessel: w.schluessel + .split("\n") + .map((z) => z.trim()) + .filter(Boolean), + }, + profile: w.profile, + variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))), + templateBauen: w.templateBauen, + }; +} + +// ── Komponente ────────────────────────────────────────────────────────────────────────────────── + +/** + * Der Deploy-Assistent. Der Zustand steht in der URL (`?s=&d=`): Zurückspringen, + * Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird + * endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt. + */ +export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) { + const router = useRouter(); + const pfad = usePathname(); + const parameter = useSearchParams(); + const schritt = Math.min(Math.max(Number(parameter.get("s") ?? 0), 0), SCHRITTE.length - 1); + + const anfang = useMemo(() => { + const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!; + const knoten = server.knoten[0]; + return { + server: server.name, + knoten: knoten?.name ?? "", + typ: "vm", + distribution: + daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "", + name: "", + vmid: "", + tags: "", + cores: "2", + speicherMb: "2048", + diskGb: "20", + storage: knoten?.storages.find((s) => s.vm)?.name ?? "", + swapMb: "512", + unprivilegiert: true, + nesting: false, + bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "", + vlan: "", + modus: "dhcp", + ip: "", + gateway: "", + dns: "", + suchdomain: "", + benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin", + schluessel: "", + profile: ["base"], + variablen: {}, + templateBauen: false, + }; + }, [daten, vorauswahl]); + + const [werte, setWerte] = useState(() => ({ ...anfang, ...dekodieren(parameter.get("d")) })); + const [plan, setPlan] = useState(null); + const [fehler, setFehler] = useState<{ + text: string; + hinweis?: string; + felder?: Record; + } | null>(null); + const [laeuft, setLaeuft] = useState(false); + const [kopiert, setKopiert] = useState(false); + + const server = daten.server.find((s) => s.name === werte.server)!; + const knoten = server.knoten.find((k) => k.name === werte.knoten) ?? server.knoten[0]; + const distribution = daten.distributionen.find((d) => d.id === werte.distribution); + const template = server.templates.find( + (t) => t.distribution === werte.distribution && (!werte.knoten || t.knoten === werte.knoten), + ); + const bridge = knoten?.bridges.find((b) => b.name === werte.bridge); + + const setze = (aenderung: Partial) => { + setWerte((alt) => ({ ...alt, ...aenderung })); + setPlan(null); + }; + + const gehe = (ziel: number) => { + let naechste = werte; + // Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015). + if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) { + try { + const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER); + if (gemerkt) { + naechste = { ...werte, schluessel: gemerkt }; + setWerte(naechste); + } + } catch { + // kein Speicher verfügbar – dann eben nicht + } + } + if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste); + const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) }); + router.replace(`${pfad}?${neu.toString()}`, { scroll: false }); + setFehler(null); + window.scrollTo({ top: 0 }); + }; + + // Was einen Schritt blockiert – als Text, damit „Weiter“ sagt, warum es nicht geht. + const sperre = (nr: number): string | null => { + switch (SCHRITTE[nr]) { + case "Server": + return !werte.server ? "Server wählen." : null; + case "Distribution": + if (!distribution) return "Distribution wählen."; + if (!distribution[werte.typ]) + return `${distribution.name} gibt es nicht als ${werte.typ.toUpperCase()}.`; + if (werte.typ === "vm" && !template && !werte.templateBauen) + return "Template fehlt – „Template vorher bauen“ wählen."; + return null; + case "Name": + if (!GAST_NAME.test(werte.name)) return "Name: Kleinbuchstaben, Ziffern und Bindestrich."; + if (werte.vmid && (!/^\d+$/.test(werte.vmid) || Number(werte.vmid) < 100)) + return "VMID ab 100 oder leer lassen."; + return null; + case "Ressourcen": + return !werte.storage ? "Storage wählen." : null; + case "Netzwerk": + if (!werte.bridge) return "Bridge wählen."; + if (werte.vlan && bridge && !bridge.vlan) return `Bridge ${bridge.name} ist nicht VLAN-fähig.`; + if (werte.modus === "statisch" && !/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(werte.ip)) + return "IP mit Präfix angeben, z. B. 10.0.20.15/24."; + return null; + case "Zugang": { + const schluessel = werte.schluessel + .split("\n") + .map((z) => z.trim()) + .filter(Boolean); + if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root") + return "Gültigen Benutzernamen angeben (nicht root)."; + if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben."; + if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z))) + return "Eine Zeile ist kein öffentlicher SSH-Schlüssel."; + return null; + } + default: + return null; + } + }; + const ersteSperre = SCHRITTE.findIndex((_, nr) => nr < schritt && sperre(nr) !== null); + + async function planen(stand: Werte = werte) { + setLaeuft(true); + setFehler(null); + try { + setPlan(await apiAufruf("POST", "/api/v1/auftraege/plan", anfrageAus(stand))); + } catch (ursache) { + setFehler( + ursache instanceof ApiFehler + ? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder } + : { text: "Unerwarteter Fehler." }, + ); + } finally { + setLaeuft(false); + } + } + + async function ausrollen() { + setLaeuft(true); + setFehler(null); + try { + try { + localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel); + } catch { + // egal + } + const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte)); + router.push(`/auftraege/${kurzId}`); + } catch (ursache) { + setFehler( + ursache instanceof ApiFehler + ? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder } + : { text: "Unerwarteter Fehler." }, + ); + setLaeuft(false); + } + } + + // Direkt geöffneter Link auf die Zusammenfassung: nach dem ersten Rendern planen. + useEffect(() => { + if (SCHRITTE[schritt] !== "Zusammenfassung" || ersteSperre >= 0) return; + const uhr = setTimeout(() => void planen(), 0); + return () => clearTimeout(uhr); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const aktuelleSperre = sperre(schritt); + + return ( +
+
+

Ausrollen

+

+ Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]} +

+
+ +
+ {/* Schrittleiste: jederzeit zurückspringbar, nach vorne nur bis zum ersten offenen Schritt. */} + + +
+ {SCHRITTE[schritt] === "Server" ? ( + +
+ + + + + + +
+
+ ) : null} + + {SCHRITTE[schritt] === "Typ" ? ( + +
+ Typ + {( + [ + [ + "vm", + "VM", + "Eigener Kernel, aus Cloud-Image-Template, Provisionierung über den Guest-Agent.", + ], + [ + "lxc", + "LXC-Container", + "Leichtgewichtig, aus Proxmox-Container-Template. Folgt mit #009.", + ], + ] as const + ).map(([wert, titel, text]) => ( + + ))} +
+
+ ) : null} + + {SCHRITTE[schritt] === "Distribution" ? ( + +
+
+ Distribution + {daten.distributionen + .filter((d) => d[werte.typ]) + .map((d) => { + const hatTemplate = server.templates.some((t) => t.distribution === d.id); + return ( + + ); + })} +
+ {werte.typ === "vm" && distribution && !template ? ( + setze({ templateBauen: an })} + /> + ) : template ? ( + + Template {template.vmid} auf {template.knoten} + + ) : null} +
+
+ ) : null} + + {SCHRITTE[schritt] === "Name" ? ( + +
+ + setze({ name: e.target.value.toLowerCase() })} + placeholder="web01" + autoComplete="off" + spellCheck={false} + autoFocus + /> + + + setze({ vmid: e.target.value })} + placeholder="automatisch" + /> + + + setze({ tags: e.target.value })} + placeholder="web,produktion" + /> + +
+
+ ) : null} + + {SCHRITTE[schritt] === "Ressourcen" ? ( + +
+ + setze({ cores: e.target.value })} + /> + + + setze({ speicherMb: e.target.value })} + /> + + + setze({ diskGb: e.target.value })} + /> + + + + +
+
+ ) : null} + + {SCHRITTE[schritt] === "Netzwerk" ? ( + +
+
+ + + + + setze({ vlan: e.target.value })} + placeholder="–" + disabled={Boolean(bridge && !bridge.vlan)} + /> + +
+
+ Adresse + {(["dhcp", "statisch"] as const).map((m) => ( + + ))} +
+ {werte.modus === "statisch" ? ( +
+ + setze({ ip: e.target.value })} + placeholder="10.0.20.15/24" + /> + + + setze({ gateway: e.target.value })} + placeholder="10.0.20.1" + /> + +
+ ) : null} +
+ + setze({ dns: e.target.value })} + placeholder="10.0.20.1" + /> + + + setze({ suchdomain: e.target.value })} + placeholder="example.lan" + /> + +
+
+
+ ) : null} + + {SCHRITTE[schritt] === "Zugang" ? ( + +
+ + setze({ benutzer: e.target.value })} + autoComplete="off" + /> + + +