diff --git a/docs/anleitung/bilder/host-detail-desktop.png b/docs/anleitung/bilder/host-detail-desktop.png new file mode 100644 index 0000000..b15493b Binary files /dev/null and b/docs/anleitung/bilder/host-detail-desktop.png differ diff --git a/docs/anleitung/bilder/host-detail-mobil.png b/docs/anleitung/bilder/host-detail-mobil.png new file mode 100644 index 0000000..7f4e23c Binary files /dev/null and b/docs/anleitung/bilder/host-detail-mobil.png differ diff --git a/docs/anleitung/bilder/hosts-desktop.png b/docs/anleitung/bilder/hosts-desktop.png new file mode 100644 index 0000000..55217cd Binary files /dev/null and b/docs/anleitung/bilder/hosts-desktop.png differ diff --git a/docs/anleitung/bilder/hosts-mobil.png b/docs/anleitung/bilder/hosts-mobil.png new file mode 100644 index 0000000..f513d08 Binary files /dev/null and b/docs/anleitung/bilder/hosts-mobil.png differ diff --git a/docs/issues/008-hosts-gruppen.md b/docs/issues/008-hosts-gruppen.md index 4fcc785..d972475 100644 --- a/docs/issues/008-hosts-gruppen.md +++ b/docs/issues/008-hosts-gruppen.md @@ -1,22 +1,56 @@ # #008 Hosts und Hostgruppen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** backend, ui -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0,5 h ## Ziel Hosts und Hostgruppen anlegen, bearbeiten, archivieren; Kunden und Tags. ## Aufgaben -- [ ] Liste mit Status (erreichbar, letzter Abgleich, Drift) -- [ ] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`) -- [ ] Hostgruppen mit Mitgliedern -- [ ] Hostdetail-Gerüst +- [x] Liste mit Status (erreichbar, letzter Abgleich, Drift) +- [x] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`) +- [x] Hostgruppen mit Mitgliedern +- [x] Hostdetail-Gerüst ## Akzeptanzkriterien -- [ ] Leser sieht, Operator/Admin bearbeiten +- [x] Leser sieht, Operator/Admin bearbeiten ## Abhängigkeiten #004 ## Notizen / Entscheidungen +- **Schichtung:** Regeln (zod-Schemas, Status-Ableitung) framework-frei in `src/lib/hosts.ts`; + Services `src/server/services/hosts.ts` / `hostgruppen.ts`, Lesemodelle + `src/server/queries/hosts.ts`, Aktionen `src/server/actions/hosts.ts` / `hostgruppen.ts` + (gemeinsame Hilfen in `actions/formular.ts`, ohne `"use server"`). Die Action prüft vorab für + Feldmeldungen, der Service prüft erneut (Seed, Skripte, spätere Assistenten). +- **Archivieren statt Löschen** (`archiviertAm`). Dabei fallen die Gruppenmitgliedschaften weg + (im Protokoll festgehalten), damit Zuweisungen an Gruppen nie einen archivierten Host + treffen. Archivierte Hosts sind schreibgeschützt und erscheinen in keiner Liste; der Name + bleibt belegt, damit das Protokoll eindeutig bleibt. Wiederherstellen gibt es noch nicht. +- **Hostgruppen löschen** bewusst noch nicht: `PublicVerwendung` hängt per Cascade an der + Gruppe; das Löschen klärt #016 zusammen mit den Verwendungen. +- **Kunde** per Name: unbekannter Name legt den Kunden an (`kunde.angelegt` im Protokoll), + leer = kein Kunde. Gruppen- und Kundennamen erlauben Leerzeichen/Umlaute (reine Anzeige). +- **Protokoll:** `host.angelegt`, `host.geaendert` (nur geänderte Felder als + `{ vorher, nachher }`, ohne Änderung kein Eintrag), `host.archiviert`, + `hostgruppe.angelegt`, `hostgruppe.geaendert` (Mitglieder als `hinzu`/`entfernt`), + `kunde.angelegt`. Demo-Seed schreibt bewusst keine Einträge. +- **Status in der Liste:** nicht erreichbar > Drift (auch bei offenen Abweichungen) > + synchron > unbekannt. Host-Key: ein ausstehender Key geht vor einem vertrauten, damit ein + geänderter Key nicht hinter „bestätigt“ verschwindet. +- **Formulare** senden per `onSubmit` + `startTransition` statt `
`: sonst setzt + React das Formular nach jeder Antwort zurück und Eingaben gingen bei Feldfehlern verloren. + `Feld` hat dafür `fehler` bekommen; neu sind `Textbereich`, `Auswahlliste`, `Ankreuzfeld`, + `Feldgruppe`. +- **Mobil:** Hostliste unter `md` als Kartenliste statt Tabelle (Status und Host-Key ohne + seitliches Wischen sichtbar); Gruppenliste zeigt die Beschreibung unter dem Namen. +- **Platzhalter für Folge-Issues:** `src/app/(app)/hosts/[id]/HostKeyAktionen.tsx` + (`hostId`, `darfEntscheiden`, `status: HostKeyZusammenfassung`, #009) im Kopf der Karte + „Host-Key“ und `BootstrapAktionen.tsx` (`hostId`, `darfVerwalten`, `eingerichtet`, + `hostKeyVertraut`, #010) unter der Info-Leiste – beide geben vorerst `null` zurück. +- **Demo-Daten:** `SSHKV_SEED_DEMO=1 pnpm db:seed` legt 6 Hosts, 3 Gruppen und einen Kunden an + (Upsert nach Name, idempotent). +- **Screenshots:** `scripts/screenshots.ts` kennt `hosts` und `host-detail` (folgt dem ersten + Host der Liste). diff --git a/prisma/seed.ts b/prisma/seed.ts index 809ff6b..8764e19 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -5,7 +5,7 @@ import { PASSWORT_MINDESTLAENGE, passwortHashen } from "../src/lib/passwort-hash import { EINGEBAUTE_VORLAGEN } from "./vorlagen"; /** - * Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen. + * Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen; optional Demo-Hosts (#008). * * Laeuft bei jedem Start des migrate-Dienstes und ist deshalb idempotent. Ein bestehender * Notfall-Admin wird nie angefasst – sonst setzte jeder Neustart ein in der Oberflaeche @@ -45,9 +45,89 @@ async function vorlagen(): Promise { console.log(`${EINGEBAUTE_VORLAGEN.length} eingebaute Vorlagen geprüft.`); } +/** + * Demo-Hosts fuer Entwicklung, Screenshots und Handbuch (#008) – nur mit SSHKV_SEED_DEMO=1, + * damit eine echte Instanz nie Phantom-Hosts bekommt. Upsert nach Name: ein zweiter Lauf + * aendert nichts, und in der Oberflaeche geaenderte Demo-Hosts bleiben, wie sie sind. + * Bewusst ohne Protokolleintraege: Seed-Daten sind keine Handlung eines Menschen. + */ +const DEMO_GRUPPEN = [ + { name: "Proxmox", beschreibung: "Virtualisierungs-Cluster im Rechenzentrum" }, + { name: "DMZ", beschreibung: "Von außen erreichbare Systeme" }, + { name: "Kunde X", beschreibung: "Systeme, die wir für Kunde X betreiben" }, +] as const; + +const DEMO_HOSTS: { + name: string; + adresse: string; + port?: number; + tags: string[]; + beschreibung?: string; + anmeldeart?: "DIENSTBENUTZER" | "ROOT"; + kunde?: string; + gruppen: string[]; +}[] = [ + { name: "srv-pve01", adresse: "10.0.10.11", tags: ["pve", "produktion"], gruppen: ["Proxmox"] }, + { name: "srv-pve02", adresse: "10.0.10.12", tags: ["pve", "produktion"], gruppen: ["Proxmox"] }, + { + name: "srv-backup01", + adresse: "10.0.10.30", + tags: ["backup"], + beschreibung: "Proxmox Backup Server, zieht nachts von pve01/pve02", + gruppen: ["Proxmox"], + }, + { + name: "srv-app01", + adresse: "app01.kunde-x.example", + port: 2222, + tags: ["web"], + kunde: "Kunde X", + gruppen: ["Kunde X"], + }, + { + name: "fw-edge01", + adresse: "192.168.1.1", + tags: ["firewall"], + anmeldeart: "ROOT", + beschreibung: "Appliance ohne sudo – Anmeldung direkt als root", + gruppen: ["DMZ"], + }, + { name: "srv-rz-claude-linux", adresse: "fd00:10::42", tags: ["test"], gruppen: [] }, +]; + +async function demoDaten(): Promise { + if (process.env["SSHKV_SEED_DEMO"] !== "1") return; + const gruppen = new Map(); + for (const g of DEMO_GRUPPEN) { + const gruppe = await prisma.hostgruppe.upsert({ where: { name: g.name }, create: g, update: {} }); + gruppen.set(g.name, gruppe.id); + } + for (const h of DEMO_HOSTS) { + const kunde = h.kunde + ? await prisma.kunde.upsert({ where: { name: h.kunde }, create: { name: h.kunde }, update: {} }) + : null; + await prisma.host.upsert({ + where: { name: h.name }, + update: {}, + create: { + name: h.name, + adresse: h.adresse, + port: h.port ?? 22, + tags: h.tags, + beschreibung: h.beschreibung ?? null, + anmeldeart: h.anmeldeart ?? "DIENSTBENUTZER", + kundeId: kunde?.id ?? null, + gruppen: { create: h.gruppen.map((name) => ({ hostgruppeId: gruppen.get(name) ?? "" })) }, + }, + }); + } + console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`); +} + try { await notfallAdmin(); await vorlagen(); + await demoDaten(); } finally { await prisma.$disconnect(); } diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index f9a0e01..1b806cd 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -19,11 +19,17 @@ import { chromium } from "@playwright/test"; const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, ""); const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder"); -/** `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). */ -const SEITEN: { name: string; pfad: string; oeffentlich?: boolean }[] = [ +/** + * `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). + * `folgen`: Seiten mit wechselnder ID (#008) – erst `pfad` laden, dann dem ersten Link folgen, + * der zum Selektor passt (z. B. der erste Host der Liste, bei SSHKV_SEED_DEMO=1 ein Demo-Host). + */ +const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [ { name: "anmelden", pfad: "/anmelden", oeffentlich: true }, { name: "uebersicht", pfad: "/" }, { name: "schluessel-detail", pfad: "/schluessel/beispiel" }, + { name: "hosts", pfad: "/hosts" }, + { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, ]; const ANSICHTEN = [ @@ -62,7 +68,16 @@ async function main() { await seite.waitForURL(`${BASIS}/`); angemeldet = true; } - const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); + let antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); + if (s.folgen && antwort?.ok()) { + const links = seite.locator(s.folgen); + const ziel = (await links.count()) > 0 ? await links.first().getAttribute("href") : null; + if (!ziel) { + fehler.push(`${s.pfad}: kein Link für „${s.name}“ (${s.folgen}) – Demo-Daten geseedet?`); + continue; + } + antwort = await seite.goto(`${BASIS}${ziel}`, { waitUntil: "networkidle" }); + } if (!antwort?.ok()) { fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`); continue;