From 4f9d5d9680009d855b6942be2e3a4165eaaaef2e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:42:56 +0200 Subject: [PATCH 1/3] feat(hosts): Services, Lesemodelle und Aktionen fuer Hosts (#008) Hosts und Hostgruppen brauchen eine Schreibschicht, bevor Host-Key-Pruefung (#009) und Bootstrap (#010) darauf aufsetzen. Die Regeln (Name, Adresse wie der DB-CHECK, Port, Tags, Anmeldeart) liegen framework-frei in src/lib/hosts.ts, damit Action (Feldmeldungen) und Service (Schutz fuer Seed und Assistenten) dieselben pruefen. Archivieren statt Loeschen haelt Protokollbezuege lesbar; dabei fallen die Gruppenmitgliedschaften weg, damit Zuweisungen an eine Gruppe nie einen archivierten Host treffen. Jede Aenderung landet mit den geaenderten Feldern im Protokoll, Eindeutigkeitsverletzungen kommen als deutsche HostFehler. Co-Authored-By: Claude Opus 5.5 --- src/lib/hosts.ts | 155 +++++++++++++++++ src/server/actions/formular.ts | 37 ++++ src/server/actions/hostgruppen.ts | 68 ++++++++ src/server/actions/hosts.ts | 93 ++++++++++ src/server/queries/hosts.ts | 252 +++++++++++++++++++++++++++ src/server/services/hostgruppen.ts | 147 ++++++++++++++++ src/server/services/hosts.ts | 217 +++++++++++++++++++++++ tests/hosts.test.ts | 268 +++++++++++++++++++++++++++++ 8 files changed, 1237 insertions(+) create mode 100644 src/lib/hosts.ts create mode 100644 src/server/actions/formular.ts create mode 100644 src/server/actions/hostgruppen.ts create mode 100644 src/server/actions/hosts.ts create mode 100644 src/server/queries/hosts.ts create mode 100644 src/server/services/hostgruppen.ts create mode 100644 src/server/services/hosts.ts create mode 100644 tests/hosts.test.ts diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts new file mode 100644 index 0000000..066550c --- /dev/null +++ b/src/lib/hosts.ts @@ -0,0 +1,155 @@ +import { z } from "@/lib/validierung"; + +/** + * Eingaben und abgeleitete Zustaende fuer Hosts und Hostgruppen (#008). + * + * Framework-frei, damit Service, Action und Tests dieselben Regeln teilen: die Action + * prueft vorab fuer Feldmeldungen im Formular, der Service prueft noch einmal, weil er + * auch von Seed, Skripten und spaeteren Assistenten gerufen wird. + */ + +/** Anzeigename eines Hosts: landet in Dateinamen, Kommentaren und ssh-config-Aliassen. */ +export const HOST_NAME = /^[A-Za-z0-9._-]{1,64}$/; +/** Wie der DB-CHECK `host_adresse_gueltig` – Hostname, IPv4 oder IPv6 ohne Klammern. */ +export const HOST_ADRESSE = /^[A-Za-z0-9._:-]{1,253}$/; +export const TAG = /^[a-z0-9._-]{1,32}$/; +export const MAX_TAGS = 20; +/** Gruppen- und Kundennamen sind reine Anzeige; Leerzeichen und Umlaute sind erlaubt. */ +export const ANZEIGE_NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._&()/-]{0,63}$/u; + +/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */ +const optionalerText = (max: number) => + z + .string() + .trim() + .max(max, `Höchstens ${max} Zeichen.`) + .nullish() + .transform((wert) => (wert ? wert : null)); + +/** Mehrfachauswahl kommt aus FormData als String oder Liste – beides wird zur Liste. */ +const idListe = z.preprocess( + (wert) => (wert === undefined || wert === "" ? [] : Array.isArray(wert) ? wert : [wert]), + z.array(z.string().min(1).max(64)).max(500), +); + +export const hostEingabeSchema = z.object({ + name: z + .string() + .trim() + .regex(HOST_NAME, "1–64 Zeichen: Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."), + adresse: z + .string() + .trim() + .regex(HOST_ADRESSE, "Hostname oder IP-Adresse (ohne Leerzeichen, Klammern oder Benutzer@)."), + port: z.coerce + .number({ error: "Bitte eine Zahl angeben." }) + .int("Bitte eine ganze Zahl angeben.") + .min(1, "Port 1–65535.") + .max(65535, "Port 1–65535.") + .default(22), + // Tags kommen im Formular als ein Feld („pve, produktion“); kleingeschrieben, damit + // „PVE“ und „pve“ nicht zwei Filter ergeben. + tags: z + .preprocess( + (wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])), + z.array( + z + .string() + .trim() + .toLowerCase() + .regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."), + ), + ) + .transform((tags) => [...new Set(tags)]) + .refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`), + beschreibung: optionalerText(500), + kundeName: optionalerText(64).refine( + (wert) => wert === null || ANZEIGE_NAME.test(wert), + "Kundenname: 1–64 Zeichen, beginnt mit Buchstabe oder Ziffer.", + ), + anmeldeart: z.enum(["DIENSTBENUTZER", "ROOT"]).default("DIENSTBENUTZER"), + gruppenIds: idListe.transform((ids) => [...new Set(ids)]), +}); + +/** + * Was Aufrufer uebergeben – von Hand statt `z.input`, weil die Vorverarbeitung (Tags als + * Text, Mehrfachauswahl) sonst nur `unknown` als Eingabetyp hinterliesse. + */ +export type HostEingabe = { + name: string; + adresse: string; + port?: number | string; + /** Liste oder Text „pve, produktion“. */ + tags?: string | string[]; + beschreibung?: string | null; + kundeName?: string | null; + anmeldeart?: "DIENSTBENUTZER" | "ROOT"; + gruppenIds?: string[]; +}; +export type HostDaten = z.output; + +export const hostgruppeEingabeSchema = z.object({ + name: z.string().trim().regex(ANZEIGE_NAME, "1–64 Zeichen, beginnt mit Buchstabe oder Ziffer."), + beschreibung: optionalerText(500), + hostIds: idListe.transform((ids) => [...new Set(ids)]), +}); + +export type HostgruppeEingabe = { + name: string; + beschreibung?: string | null; + hostIds?: string[]; +}; +export type HostgruppeDaten = z.output; + +/** Zod-Fehler als `{ feld: erste Meldung }` fuer die Anzeige unter dem Feld. */ +export function feldfehlerAus(fehler: z.ZodError): Record { + const ergebnis: Record = {}; + for (const problem of fehler.issues) { + const feld = String(problem.path[0] ?? "formular"); + ergebnis[feld] ??= problem.message; + } + return ergebnis; +} + +// ---------------------------------------------------------------- Abgeleitete Zustaende + +export type HostStatusArt = "synchron" | "drift" | "unerreichbar" | "unbekannt"; + +/** + * Ein Wort fuer die Liste. Unerreichbar schlaegt alles: ein Abgleichsergebnis von gestern + * sagt nichts, wenn der Host heute nicht antwortet. Offene Abweichungen zaehlen als Drift, + * auch wenn der letzte Abgleich den Zustand noch nicht nachgezogen hat. + */ +export function hostStatus(host: { + erreichbar: boolean | null; + driftZustand: "UNBEKANNT" | "SYNCHRON" | "DRIFT"; + offeneAbweichungen: number; +}): HostStatusArt { + if (host.erreichbar === false) return "unerreichbar"; + if (host.driftZustand === "DRIFT" || host.offeneAbweichungen > 0) return "drift"; + if (host.driftZustand === "SYNCHRON") return "synchron"; + return "unbekannt"; +} + +export type HostKeyZustand = "bestaetigt" | "ausstehend" | "keiner"; + +/** Zusammenfassung der Host-Keys eines Hosts – auch Props fuer HostKeyAktionen (#009). */ +export type HostKeyZusammenfassung = { + readonly zustand: HostKeyZustand; + readonly vertraut: number; + readonly ausstehend: number; + readonly abgelehnt: number; +}; + +/** + * Ein ausstehender Key verlangt eine Entscheidung und geht deshalb vor einem vertrauten – + * sonst verschwaende ein geaenderter Host-Key hinter dem gruenen „bestaetigt“. + */ +export function hostKeyZusammenfassen(stati: readonly string[]): HostKeyZusammenfassung { + const zaehlen = (s: string) => stati.filter((x) => x === s).length; + const vertraut = zaehlen("VERTRAUT"); + const ausstehend = zaehlen("AUSSTEHEND"); + const abgelehnt = zaehlen("ABGELEHNT"); + const zustand: HostKeyZustand = ausstehend > 0 ? "ausstehend" : vertraut > 0 ? "bestaetigt" : "keiner"; + return { zustand, vertraut, ausstehend, abgelehnt }; +} diff --git a/src/server/actions/formular.ts b/src/server/actions/formular.ts new file mode 100644 index 0000000..f91d8fd --- /dev/null +++ b/src/server/actions/formular.ts @@ -0,0 +1,37 @@ +import type { Benutzer } from "@/generated/prisma/client"; +import { BestaetigungFehler, RechteFehler } from "@/server/auth/sitzungspflicht"; +import { HostFehler, type Akteur } from "@/server/services/hosts"; + +/** + * Gemeinsames fuer Formular-Aktionen (#008). Kein "use server": diese Datei exportiert + * Hilfen, keine Aktionen – sonst waeren sie vom Browser aus aufrufbar. + */ +export type FormularZustand = { + readonly fehler?: string; + readonly feldfehler?: Record; +}; + +/** Fachliche Fehler werden zur Meldung im Formular; alles andere bleibt ein echter Fehler. */ +export function fehlerZustand(fehler: unknown): FormularZustand { + if (fehler instanceof HostFehler) { + return { fehler: fehler.message, feldfehler: fehler.feldfehler }; + } + if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler) { + return { fehler: fehler.message }; + } + throw fehler; +} + +export function akteurVon(benutzer: Pick): Akteur { + return { id: benutzer.id, name: benutzer.name }; +} + +/** Einzelnes Textfeld; fehlend bleibt `undefined`, damit Standardwerte des Schemas greifen. */ +export function textfeld(formular: FormData, name: string): string | undefined { + const wert = formular.get(name); + return typeof wert === "string" && wert !== "" ? wert : undefined; +} + +export function listenfeld(formular: FormData, name: string): string[] { + return formular.getAll(name).filter((w): w is string => typeof w === "string" && w !== ""); +} diff --git a/src/server/actions/hostgruppen.ts b/src/server/actions/hostgruppen.ts new file mode 100644 index 0000000..1fbfb39 --- /dev/null +++ b/src/server/actions/hostgruppen.ts @@ -0,0 +1,68 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { feldfehlerAus, hostgruppeEingabeSchema, type HostgruppeEingabe } from "@/lib/hosts"; +import { + akteurVon, + fehlerZustand, + listenfeld, + textfeld, + type FormularZustand, +} from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen"; + +/** Formular-Aktionen fuer Hostgruppen (#008). Duenn wie die fuer Hosts. */ + +function gruppenFelder(formular: FormData): HostgruppeEingabe { + return { + name: textfeld(formular, "name") ?? "", + beschreibung: textfeld(formular, "beschreibung"), + hostIds: listenfeld(formular, "hostIds"), + }; +} + +function vorpruefen(eingabe: HostgruppeEingabe): FormularZustand | undefined { + const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe); + if (ergebnis.success) return undefined; + return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) }; +} + +export async function hostgruppeAnlegenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + let id: string; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = gruppenFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + ({ id } = await hostgruppeAnlegen(eingabe, akteurVon(benutzer))); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hostgruppen"); + revalidatePath("/hosts", "layout"); + redirect(`/hostgruppen/${id}`); +} + +export async function hostgruppeBearbeitenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = gruppenFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + await hostgruppeBearbeiten(id, eingabe, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hostgruppen", "layout"); + revalidatePath("/hosts", "layout"); + redirect(`/hostgruppen/${id}`); +} diff --git a/src/server/actions/hosts.ts b/src/server/actions/hosts.ts new file mode 100644 index 0000000..fae93d8 --- /dev/null +++ b/src/server/actions/hosts.ts @@ -0,0 +1,93 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { feldfehlerAus, hostEingabeSchema, type HostEingabe } from "@/lib/hosts"; +import { + akteurVon, + fehlerZustand, + listenfeld, + textfeld, + type FormularZustand, +} from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts"; + +/** + * Formular-Aktionen fuer Hosts (#008). Duenn: Recht pruefen, Eingabe pruefen, Service rufen, + * Seiten neu laden lassen, weiterleiten. `redirect` steht ausserhalb von try/catch, weil es + * per Ausnahme arbeitet. + */ + +function hostFelder(formular: FormData): HostEingabe { + return { + name: textfeld(formular, "name") ?? "", + adresse: textfeld(formular, "adresse") ?? "", + port: textfeld(formular, "port"), + tags: textfeld(formular, "tags") ?? "", + beschreibung: textfeld(formular, "beschreibung"), + kundeName: textfeld(formular, "kundeName"), + anmeldeart: textfeld(formular, "anmeldeart") as HostEingabe["anmeldeart"], + gruppenIds: listenfeld(formular, "gruppenIds"), + }; +} + +function vorpruefen(eingabe: HostEingabe): FormularZustand | undefined { + const ergebnis = hostEingabeSchema.safeParse(eingabe); + if (ergebnis.success) return undefined; + return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) }; +} + +export async function hostAnlegenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + let id: string; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = hostFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + ({ id } = await hostAnlegen(eingabe, akteurVon(benutzer))); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts"); + revalidatePath("/hostgruppen", "layout"); + redirect(`/hosts/${id}`); +} + +export async function hostBearbeitenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = hostFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + await hostBearbeiten(id, eingabe, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts", "layout"); + revalidatePath("/hostgruppen", "layout"); + redirect(`/hosts/${id}`); +} + +export async function hostArchivierenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + await hostArchivieren(id, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts", "layout"); + revalidatePath("/hostgruppen", "layout"); + redirect("/hosts"); +} diff --git a/src/server/queries/hosts.ts b/src/server/queries/hosts.ts new file mode 100644 index 0000000..80263ad --- /dev/null +++ b/src/server/queries/hosts.ts @@ -0,0 +1,252 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts"; + +/** + * Lesemodelle fuer Hosts und Hostgruppen (#008). + * + * Alles hier darf jede angemeldete Rolle sehen (Recht `lesen`); die Seiten pruefen das per + * `sitzungErforderlich()`. Archivierte Hosts erscheinen in keiner Liste, nur ueber ihre + * Detailadresse – fuer Verweise aus dem Protokoll. + */ + +const OFFEN = { behobenAm: null } satisfies Prisma.AbweichungWhereInput; + +export type HostFilter = { readonly q?: string; readonly gruppeId?: string }; + +export async function hostListe(filter: HostFilter = {}) { + const q = filter.q?.trim().slice(0, 100); + const where: Prisma.HostWhereInput = { + archiviertAm: null, + ...(filter.gruppeId ? { gruppen: { some: { hostgruppeId: filter.gruppeId } } } : {}), + ...(q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { adresse: { contains: q, mode: "insensitive" } }, + { tags: { has: q.toLowerCase() } }, + ], + } + : {}), + }; + const hosts = await prisma.host.findMany({ + where, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + adresse: true, + port: true, + tags: true, + erreichbar: true, + driftZustand: true, + letzterAbgleichAm: true, + kunde: { select: { name: true } }, + gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } }, + hostSchluessel: { + where: { status: { in: ["VERTRAUT", "AUSSTEHEND"] } }, + select: { status: true }, + }, + _count: { select: { abweichungen: { where: OFFEN } } }, + }, + }); + return hosts.map((h) => ({ + id: h.id, + name: h.name, + adresse: h.adresse, + port: h.port, + tags: h.tags, + kunde: h.kunde?.name ?? null, + gruppen: h.gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")), + letzterAbgleichAm: h.letzterAbgleichAm, + offeneAbweichungen: h._count.abweichungen, + status: hostStatus({ + erreichbar: h.erreichbar, + driftZustand: h.driftZustand, + offeneAbweichungen: h._count.abweichungen, + }), + hostKey: hostKeyZusammenfassen(h.hostSchluessel.map((s) => s.status)), + })); +} + +export type HostListenEintrag = Awaited>[number]; + +export async function hostDetail(id: string) { + const host = await prisma.host.findUnique({ + where: { id }, + select: { + id: true, + name: true, + adresse: true, + port: true, + beschreibung: true, + tags: true, + anmeldeart: true, + bootstrapAm: true, + erreichbar: true, + zuletztGeprueftAm: true, + letzterAbgleichAm: true, + driftZustand: true, + sshdBefund: true, + archiviertAm: true, + angelegtAm: true, + kunde: { select: { id: true, name: true } }, + gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } }, + hostSchluessel: { + orderBy: [{ status: "asc" }, { algorithmus: "asc" }], + select: { + id: true, + algorithmus: true, + fingerprint: true, + status: true, + erstmalsGesehen: true, + zuletztGesehen: true, + entschiedenAm: true, + }, + }, + zielkonten: { + where: { entferntAm: null }, + orderBy: { benutzername: "asc" }, + select: { id: true, benutzername: true, sudoModus: true, vonSshkvAngelegt: true }, + }, + _count: { + select: { + abweichungen: { where: OFFEN }, + publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + }, + }, + }, + }); + if (!host) return null; + const { _count, gruppen, ...rest } = host; + return { + ...rest, + gruppen: gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")), + offeneAbweichungen: _count.abweichungen, + publicVerwendungen: _count.publicVerwendungen, + privateVerwendungen: _count.privateVerwendungen, + status: hostStatus({ + erreichbar: host.erreichbar, + driftZustand: host.driftZustand, + offeneAbweichungen: _count.abweichungen, + }), + hostKey: hostKeyZusammenfassen(host.hostSchluessel.map((s) => s.status)), + }; +} + +export type HostDetail = NonNullable>>; + +/** Fuer das Formular „Bearbeiten“: genau die Felder, die das Formular kennt. */ +export async function hostFormularWerte(id: string) { + const host = await prisma.host.findUnique({ + where: { id }, + select: { + id: true, + name: true, + adresse: true, + port: true, + tags: true, + beschreibung: true, + anmeldeart: true, + archiviertAm: true, + kunde: { select: { name: true } }, + gruppen: { select: { hostgruppeId: true } }, + }, + }); + if (!host) return null; + return { + id: host.id, + name: host.name, + adresse: host.adresse, + port: host.port, + tags: host.tags, + beschreibung: host.beschreibung ?? "", + anmeldeart: host.anmeldeart, + kundeName: host.kunde?.name ?? "", + gruppenIds: host.gruppen.map((g) => g.hostgruppeId), + archiviert: host.archiviertAm !== null, + }; +} + +export type HostFormularWerte = NonNullable>>; + +/** Auswahllisten fuer Formulare. */ +export async function gruppenAuswahl() { + return prisma.hostgruppe.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }); +} + +export async function hostAuswahl() { + return prisma.host.findMany({ + where: { archiviertAm: null }, + orderBy: { name: "asc" }, + select: { id: true, name: true, adresse: true }, + }); +} + +export async function kundenNamen() { + const kunden = await prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { name: true } }); + return kunden.map((k) => k.name); +} + +// ---------------------------------------------------------------- Hostgruppen + +export async function gruppenListe() { + const gruppen = await prisma.hostgruppe.findMany({ + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + beschreibung: true, + _count: { select: { mitglieder: { where: { host: { archiviertAm: null } } } } }, + }, + }); + return gruppen.map(({ _count, ...g }) => ({ ...g, mitglieder: _count.mitglieder })); +} + +export async function gruppeDetail(id: string) { + const gruppe = await prisma.hostgruppe.findUnique({ + where: { id }, + select: { + id: true, + name: true, + beschreibung: true, + angelegtAm: true, + mitglieder: { + where: { host: { archiviertAm: null } }, + orderBy: { host: { name: "asc" } }, + select: { + host: { + select: { + id: true, + name: true, + adresse: true, + port: true, + erreichbar: true, + driftZustand: true, + _count: { select: { abweichungen: { where: OFFEN } } }, + }, + }, + }, + }, + }, + }); + if (!gruppe) return null; + return { + id: gruppe.id, + name: gruppe.name, + beschreibung: gruppe.beschreibung, + angelegtAm: gruppe.angelegtAm, + mitglieder: gruppe.mitglieder.map(({ host: { _count, ...h } }) => ({ + id: h.id, + name: h.name, + adresse: h.adresse, + port: h.port, + status: hostStatus({ + erreichbar: h.erreichbar, + driftZustand: h.driftZustand, + offeneAbweichungen: _count.abweichungen, + }), + })), + }; +} diff --git a/src/server/services/hostgruppen.ts b/src/server/services/hostgruppen.ts new file mode 100644 index 0000000..d92ec45 --- /dev/null +++ b/src/server/services/hostgruppen.ts @@ -0,0 +1,147 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + feldfehlerAus, + hostgruppeEingabeSchema, + type HostgruppeDaten, + type HostgruppeEingabe, +} from "@/lib/hosts"; +import { HostFehler, istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Hostgruppen anlegen und bearbeiten, Mitglieder setzen (#008). + * + * Loeschen gibt es bewusst noch nicht: an einer Gruppe haengen spaeter Public-Verwendungen + * (Cascade) – ein Loeschen wuerde Zuweisungen still mitnehmen. Das kommt mit #016, wenn + * klar ist, was mit den Verwendungen passieren soll. + */ + +function pruefen(eingabe: HostgruppeEingabe): HostgruppeDaten { + const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + return ergebnis.data; +} + +function nameVergeben(name: string): HostFehler { + return new HostFehler(`Eine Hostgruppe namens „${name}“ existiert bereits.`, { + name: "Dieser Name ist schon vergeben.", + }); +} + +/** Nur aktive Hosts duerfen Mitglied werden; liefert die Namen fuer das Protokoll. */ +async function hostNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise> { + if (ids.length === 0) return new Map(); + const hosts = await tx.host.findMany({ + where: { id: { in: [...ids] }, archiviertAm: null }, + select: { id: true, name: true }, + }); + if (hosts.length !== ids.length) { + throw new HostFehler("Mindestens ein gewählter Host ist archiviert oder existiert nicht.", { + hostIds: "Bitte die Auswahl prüfen.", + }); + } + return new Map(hosts.map((h) => [h.id, h.name])); +} + +export async function hostgruppeAnlegen(eingabe: HostgruppeEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + if (await prisma.hostgruppe.findUnique({ where: { name: daten.name }, select: { id: true } })) { + throw nameVergeben(daten.name); + } + try { + return await prisma.$transaction(async (tx) => { + const namen = await hostNamen(tx, daten.hostIds); + const gruppe = await tx.hostgruppe.create({ + data: { + name: daten.name, + beschreibung: daten.beschreibung, + mitglieder: { create: daten.hostIds.map((hostId) => ({ hostId })) }, + }, + }); + await protokollieren( + { + akteur, + aktion: "hostgruppe.angelegt", + ergebnis: "ERFOLG", + details: { + name: gruppe.name, + beschreibung: gruppe.beschreibung, + mitglieder: [...namen.values()].sort(), + }, + }, + tx, + ); + return gruppe; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +/** + * Name, Beschreibung und Mitglieder in einem Zug. Mitgliedschaften archivierter Hosts gibt + * es nicht (siehe hostArchivieren), deshalb darf die Auswahl die Liste komplett ersetzen. + */ +export async function hostgruppeBearbeiten(id: string, eingabe: HostgruppeEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + const alt = await prisma.hostgruppe.findUnique({ + where: { id }, + include: { mitglieder: { include: { host: { select: { id: true, name: true } } } } }, + }); + if (!alt) throw new HostFehler("Diese Hostgruppe gibt es nicht."); + + try { + return await prisma.$transaction(async (tx) => { + const neu = await hostNamen(tx, daten.hostIds); + const vorher = new Map(alt.mitglieder.map((m) => [m.host.id, m.host.name])); + const hinzu = [...neu].filter(([hid]) => !vorher.has(hid)); + const entfernt = [...vorher].filter(([hid]) => !neu.has(hid)); + + const geaendert: Record = {}; + if (alt.name !== daten.name) geaendert["name"] = { vorher: alt.name, nachher: daten.name }; + if (alt.beschreibung !== daten.beschreibung) { + geaendert["beschreibung"] = { vorher: alt.beschreibung, nachher: daten.beschreibung }; + } + if (hinzu.length > 0 || entfernt.length > 0) { + geaendert["mitglieder"] = { + hinzu: hinzu.map(([, n]) => n).sort(), + entfernt: entfernt.map(([, n]) => n).sort(), + }; + } + + const gruppe = await tx.hostgruppe.update({ + where: { id }, + data: { name: daten.name, beschreibung: daten.beschreibung }, + }); + if (entfernt.length > 0) { + await tx.hostgruppeMitglied.deleteMany({ + where: { hostgruppeId: id, hostId: { in: entfernt.map(([hid]) => hid) } }, + }); + } + if (hinzu.length > 0) { + await tx.hostgruppeMitglied.createMany({ + data: hinzu.map(([hostId]) => ({ hostId, hostgruppeId: id })), + }); + } + if (Object.keys(geaendert).length > 0) { + await protokollieren( + { + akteur, + aktion: "hostgruppe.geaendert", + ergebnis: "ERFOLG", + details: { gruppe: gruppe.name, ...geaendert }, + }, + tx, + ); + } + return gruppe; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} diff --git a/src/server/services/hosts.ts b/src/server/services/hosts.ts new file mode 100644 index 0000000..420fba3 --- /dev/null +++ b/src/server/services/hosts.ts @@ -0,0 +1,217 @@ +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { feldfehlerAus, hostEingabeSchema, type HostDaten, type HostEingabe } from "@/lib/hosts"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Hosts anlegen, bearbeiten, archivieren (#008). + * + * Archivieren statt Loeschen: am Host haengen Protokoll-Bezuege, Beobachtungen und spaeter + * Verwendungen. Ein geloeschter Host liesse Fragen wie „wer hatte bis Mai Zugang zu + * srv-pve01?“ unbeantwortet. + */ + +export type Akteur = { readonly id: string; readonly name: string }; + +/** Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld. */ +export class HostFehler extends Error { + constructor( + nachricht: string, + readonly feldfehler: Record = {}, + ) { + super(nachricht); + this.name = "HostFehler"; + } +} + +export function istEindeutigkeitsFehler(fehler: unknown): boolean { + return fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002"; +} + +function pruefen(eingabe: HostEingabe): HostDaten { + const ergebnis = hostEingabeSchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + return ergebnis.data; +} + +function nameVergeben(name: string): HostFehler { + return new HostFehler(`Ein Host namens „${name}“ existiert bereits (auch archivierte zählen).`, { + name: "Dieser Name ist schon vergeben.", + }); +} + +/** Kunde per Name finden oder anlegen; `null` heisst „kein Kunde“. */ +async function kundeZuordnen( + tx: Prisma.TransactionClient, + kundeName: string | null, + akteur: Akteur, +): Promise { + if (!kundeName) return null; + const vorhanden = await tx.kunde.findUnique({ where: { name: kundeName }, select: { id: true } }); + if (vorhanden) return vorhanden.id; + const kunde = await tx.kunde.create({ data: { name: kundeName }, select: { id: true } }); + await protokollieren( + { akteur, aktion: "kunde.angelegt", ergebnis: "ERFOLG", details: { name: kundeName } }, + tx, + ); + return kunde.id; +} + +/** Prueft, dass alle Gruppen existieren, und liefert ihre Namen fuer das Protokoll. */ +async function gruppenNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise { + if (ids.length === 0) return []; + const gruppen = await tx.hostgruppe.findMany({ where: { id: { in: [...ids] } }, select: { name: true } }); + if (gruppen.length !== ids.length) { + throw new HostFehler("Mindestens eine gewählte Hostgruppe gibt es nicht mehr.", { + gruppenIds: "Bitte die Auswahl prüfen.", + }); + } + return gruppen.map((g) => g.name).sort(); +} + +export async function hostAnlegen(eingabe: HostEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + if (await prisma.host.findUnique({ where: { name: daten.name }, select: { id: true } })) { + throw nameVergeben(daten.name); + } + try { + return await prisma.$transaction(async (tx) => { + const gruppen = await gruppenNamen(tx, daten.gruppenIds); + const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur); + const host = await tx.host.create({ + data: { + name: daten.name, + adresse: daten.adresse, + port: daten.port, + tags: daten.tags, + beschreibung: daten.beschreibung, + anmeldeart: daten.anmeldeart, + kundeId, + gruppen: { create: daten.gruppenIds.map((hostgruppeId) => ({ hostgruppeId })) }, + }, + }); + await protokollieren( + { + akteur, + aktion: "host.angelegt", + ergebnis: "ERFOLG", + hostName: host.name, + details: { + adresse: host.adresse, + port: host.port, + anmeldeart: host.anmeldeart, + tags: host.tags, + kunde: daten.kundeName, + gruppen, + }, + }, + tx, + ); + return host; + }); + } catch (fehler) { + // Zwischen Vorpruefung und Insert kann jemand denselben Namen angelegt haben. + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +type Aenderung = { vorher: unknown; nachher: unknown }; + +function gleich(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export async function hostBearbeiten(id: string, eingabe: HostEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + const alt = await prisma.host.findUnique({ + where: { id }, + include: { kunde: true, gruppen: { include: { hostgruppe: { select: { name: true } } } } }, + }); + if (!alt) throw new HostFehler("Diesen Host gibt es nicht."); + if (alt.archiviertAm) throw new HostFehler("Archivierte Hosts lassen sich nicht bearbeiten."); + + try { + return await prisma.$transaction(async (tx) => { + const neueGruppen = await gruppenNamen(tx, daten.gruppenIds); + const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur); + + // Nur geaenderte Felder ins Protokoll – „wer hat den Port auf 2222 gestellt?“ soll + // ohne Vergleich zweier Vollstaende beantwortbar sein. + const vergleich: Record = { + name: { vorher: alt.name, nachher: daten.name }, + adresse: { vorher: alt.adresse, nachher: daten.adresse }, + port: { vorher: alt.port, nachher: daten.port }, + tags: { vorher: alt.tags, nachher: daten.tags }, + beschreibung: { vorher: alt.beschreibung, nachher: daten.beschreibung }, + anmeldeart: { vorher: alt.anmeldeart, nachher: daten.anmeldeart }, + kunde: { vorher: alt.kunde?.name ?? null, nachher: daten.kundeName }, + gruppen: { vorher: alt.gruppen.map((g) => g.hostgruppe.name).sort(), nachher: neueGruppen }, + }; + const geaendert = Object.fromEntries( + Object.entries(vergleich).filter(([, a]) => !gleich(a.vorher, a.nachher)), + ); + + const host = await tx.host.update({ + where: { id }, + data: { + name: daten.name, + adresse: daten.adresse, + port: daten.port, + tags: daten.tags, + beschreibung: daten.beschreibung, + anmeldeart: daten.anmeldeart, + kundeId, + }, + }); + if ("gruppen" in geaendert) { + await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } }); + await tx.hostgruppeMitglied.createMany({ + data: daten.gruppenIds.map((hostgruppeId) => ({ hostId: id, hostgruppeId })), + }); + } + if (Object.keys(geaendert).length > 0) { + await protokollieren( + { akteur, aktion: "host.geaendert", ergebnis: "ERFOLG", hostName: host.name, details: geaendert }, + tx, + ); + } + return host; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +/** + * Archiviert einen Host. Gruppenmitgliedschaften fallen dabei weg, damit spaetere + * Zuweisungen an eine Gruppe nie einen archivierten Host treffen; welche es waren, steht + * im Protokoll. + */ +export async function hostArchivieren(id: string, akteur: Akteur) { + const host = await prisma.host.findUnique({ + where: { id }, + include: { gruppen: { include: { hostgruppe: { select: { name: true } } } } }, + }); + if (!host) throw new HostFehler("Diesen Host gibt es nicht."); + if (host.archiviertAm) throw new HostFehler("Dieser Host ist bereits archiviert."); + + return prisma.$transaction(async (tx) => { + await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } }); + const archiviert = await tx.host.update({ where: { id }, data: { archiviertAm: new Date() } }); + await protokollieren( + { + akteur, + aktion: "host.archiviert", + ergebnis: "ERFOLG", + hostName: host.name, + details: { gruppen: host.gruppen.map((g) => g.hostgruppe.name).sort() }, + }, + tx, + ); + return archiviert; + }); +} diff --git a/tests/hosts.test.ts b/tests/hosts.test.ts new file mode 100644 index 0000000..29835cd --- /dev/null +++ b/tests/hosts.test.ts @@ -0,0 +1,268 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts"; +import { gruppeDetail, gruppenListe, hostDetail, hostListe } from "@/server/queries/hosts"; +import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen"; +import { HostFehler, hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts"; +import { datenbankLeeren } from "./hilfen/db"; + +/** Hosts und Hostgruppen (#008): Services gegen die Testdatenbank, Queries, Rechte. */ + +let akteur: { id: string; name: string }; + +beforeEach(async () => { + await datenbankLeeren(); + const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } }); + akteur = { id: benutzer.id, name: benutzer.name }; +}); + +const GRUND = { name: "srv-pve01", adresse: "10.0.0.11" }; + +async function fehlerVon(versuch: Promise): Promise { + const fehler = await versuch.then( + () => undefined, + (f: unknown) => f, + ); + expect(fehler).toBeInstanceOf(HostFehler); + return fehler as HostFehler; +} + +describe("Host anlegen", () => { + it("legt mit Standardwerten an und protokolliert", async () => { + const host = await hostAnlegen({ ...GRUND, tags: "PVE, produktion pve" }, akteur); + expect(host.port).toBe(22); + expect(host.anmeldeart).toBe("DIENSTBENUTZER"); + // kleingeschrieben und ohne Doppelte + expect(host.tags).toEqual(["pve", "produktion"]); + expect(host.beschreibung).toBeNull(); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.angelegt" } }); + expect(eintrag.hostName).toBe("srv-pve01"); + expect(eintrag.akteurId).toBe(akteur.id); + expect(eintrag.akteurName).toBe("Olga Operator"); + expect(eintrag.ergebnis).toBe("ERFOLG"); + }); + + it("legt einen Kunden per Name an und verwendet ihn wieder", async () => { + const a = await hostAnlegen({ ...GRUND, kundeName: "Kunde X" }, akteur); + const b = await hostAnlegen({ name: "srv-app01", adresse: "10.0.0.20", kundeName: "Kunde X" }, akteur); + expect(a.kundeId).not.toBeNull(); + expect(b.kundeId).toBe(a.kundeId); + expect(await prisma.kunde.count()).toBe(1); + expect(await prisma.protokoll.count({ where: { aktion: "kunde.angelegt" } })).toBe(1); + }); + + it.each([ + ["name", { name: "srv pve01" }], + ["name", { name: "srv;rm -rf /" }], + ["name", { name: "x".repeat(65) }], + ["name", { name: "" }], + ["adresse", { adresse: "10.0.0.1; reboot" }], + ["adresse", { adresse: "root@10.0.0.1" }], + ["adresse", { adresse: "$(id)" }], + ["adresse", { adresse: "host name" }], + ["adresse", { adresse: "a".repeat(254) }], + ["port", { port: 0 }], + ["port", { port: 65536 }], + ["port", { port: "zwei" }], + ["tags", { tags: "gut, bö$e" }], + ["tags", { tags: Array.from({ length: 21 }, (_, i) => `t${i}`) }], + ["beschreibung", { beschreibung: "x".repeat(501) }], + ["anmeldeart", { anmeldeart: "ADMIN" }], + ])("weist ein ungueltiges Feld %s ab (%j)", async (feld, abweichung) => { + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, ...abweichung } as never, akteur)); + expect(Object.keys(fehler.feldfehler)).toContain(feld); + expect(await prisma.host.count()).toBe(0); + }); + + it("nimmt IPv6, FQDN und hohe Ports an", async () => { + await hostAnlegen({ name: "v6", adresse: "fd00::11", port: 2222 }, akteur); + await hostAnlegen({ name: "fqdn", adresse: "pve01.intern.example.org", port: "65535" }, akteur); + expect(await prisma.host.count()).toBe(2); + }); + + it("meldet einen vergebenen Namen verstaendlich", async () => { + await hostAnlegen(GRUND, akteur); + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, adresse: "10.0.0.99" }, akteur)); + expect(fehler.message).toMatch(/srv-pve01.*existiert bereits/); + expect(fehler.feldfehler["name"]).toBeDefined(); + }); + + it("verweigert unbekannte Gruppen", async () => { + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, gruppenIds: ["gibt-es-nicht"] }, akteur)); + expect(fehler.feldfehler["gruppenIds"]).toBeDefined(); + expect(await prisma.host.count()).toBe(0); + }); +}); + +describe("Host bearbeiten und archivieren", () => { + it("protokolliert nur geaenderte Felder", async () => { + const host = await hostAnlegen({ ...GRUND, tags: "pve" }, akteur); + await hostBearbeiten(host.id, { ...GRUND, tags: "pve", port: 2222, anmeldeart: "ROOT" }, akteur); + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.geaendert" } }); + expect(eintrag.details).toEqual({ + port: { vorher: 22, nachher: 2222 }, + anmeldeart: { vorher: "DIENSTBENUTZER", nachher: "ROOT" }, + }); + }); + + it("schreibt ohne Aenderung keinen Eintrag", async () => { + const host = await hostAnlegen(GRUND, akteur); + await hostBearbeiten(host.id, GRUND, akteur); + expect(await prisma.protokoll.count({ where: { aktion: "host.geaendert" } })).toBe(0); + }); + + it("meldet beim Umbenennen einen vergebenen Namen", async () => { + await hostAnlegen(GRUND, akteur); + const zweiter = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur); + const fehler = await fehlerVon(hostBearbeiten(zweiter.id, { ...GRUND, adresse: "10.0.0.12" }, akteur)); + expect(fehler.feldfehler["name"]).toBeDefined(); + }); + + it("archiviert statt zu loeschen, nimmt den Host aus Gruppen und Liste", async () => { + const gruppe = await hostgruppeAnlegen({ name: "Proxmox" }, akteur); + const host = await hostAnlegen({ ...GRUND, gruppenIds: [gruppe.id] }, akteur); + await hostArchivieren(host.id, akteur); + + const gespeichert = await prisma.host.findUniqueOrThrow({ where: { id: host.id } }); + expect(gespeichert.archiviertAm).not.toBeNull(); + expect(await prisma.hostgruppeMitglied.count()).toBe(0); + expect(await hostListe()).toHaveLength(0); + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.archiviert" } }); + expect(eintrag.details).toEqual({ gruppen: ["Proxmox"] }); + + await fehlerVon(hostArchivieren(host.id, akteur)); + await fehlerVon(hostBearbeiten(host.id, GRUND, akteur)); + // Der Name bleibt belegt – sonst gaebe es zwei „srv-pve01“ im Protokoll. + await fehlerVon(hostAnlegen(GRUND, akteur)); + }); +}); + +describe("Hostgruppen", () => { + it("legt mit Mitgliedern an und setzt sie neu", async () => { + const a = await hostAnlegen(GRUND, akteur); + const b = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur); + const gruppe = await hostgruppeAnlegen( + { name: "Proxmox", beschreibung: "Cluster", hostIds: [a.id] }, + akteur, + ); + expect((await gruppeDetail(gruppe.id))?.mitglieder.map((m) => m.name)).toEqual(["srv-pve01"]); + + await hostgruppeBearbeiten(gruppe.id, { name: "Proxmox VE", hostIds: [b.id] }, akteur); + const detail = await gruppeDetail(gruppe.id); + expect(detail?.name).toBe("Proxmox VE"); + expect(detail?.beschreibung).toBeNull(); + expect(detail?.mitglieder.map((m) => m.name)).toEqual(["srv-pve02"]); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "hostgruppe.geaendert" } }); + expect(eintrag.details).toMatchObject({ + name: { vorher: "Proxmox", nachher: "Proxmox VE" }, + mitglieder: { hinzu: ["srv-pve02"], entfernt: ["srv-pve01"] }, + }); + expect(await prisma.protokoll.count({ where: { aktion: "hostgruppe.angelegt" } })).toBe(1); + }); + + it("meldet doppelte Namen und ungueltige Mitglieder", async () => { + await hostgruppeAnlegen({ name: "DMZ" }, akteur); + const doppelt = await fehlerVon(hostgruppeAnlegen({ name: "DMZ" }, akteur)); + expect(doppelt.message).toMatch(/existiert bereits/); + + const host = await hostAnlegen(GRUND, akteur); + await hostArchivieren(host.id, akteur); + const archiviert = await fehlerVon(hostgruppeAnlegen({ name: "Neu", hostIds: [host.id] }, akteur)); + expect(archiviert.feldfehler["hostIds"]).toBeDefined(); + + const leer = await fehlerVon(hostgruppeAnlegen({ name: " " }, akteur)); + expect(leer.feldfehler["name"]).toBeDefined(); + }); + + it("zaehlt Mitglieder in der Gruppenliste", async () => { + const a = await hostAnlegen(GRUND, akteur); + await hostgruppeAnlegen({ name: "Proxmox", hostIds: [a.id] }, akteur); + await hostgruppeAnlegen({ name: "DMZ" }, akteur); + expect((await gruppenListe()).map((g) => [g.name, g.mitglieder])).toEqual([ + ["DMZ", 0], + ["Proxmox", 1], + ]); + }); +}); + +describe("Lesemodelle", () => { + it("filtert nach Name, Adresse, Tag und Gruppe", async () => { + const gruppe = await hostgruppeAnlegen({ name: "DMZ" }, akteur); + await hostAnlegen({ ...GRUND, tags: "pve" }, akteur); + await hostAnlegen({ name: "fw-edge01", adresse: "192.168.1.1", gruppenIds: [gruppe.id] }, akteur); + + expect((await hostListe({ q: "PVE01" })).map((h) => h.name)).toEqual(["srv-pve01"]); + expect((await hostListe({ q: "192.168" })).map((h) => h.name)).toEqual(["fw-edge01"]); + expect((await hostListe({ q: "pve" })).map((h) => h.name)).toEqual(["srv-pve01"]); + expect((await hostListe({ gruppeId: gruppe.id })).map((h) => h.name)).toEqual(["fw-edge01"]); + expect(await hostListe()).toHaveLength(2); + }); + + it("fasst Host-Keys und Abweichungen zusammen", async () => { + const host = await hostAnlegen(GRUND, akteur); + await prisma.hostSchluessel.create({ + data: { + hostId: host.id, + algorithmus: "ssh-ed25519", + publicKey: "AAAA", + fingerprint: "SHA256:a", + status: "VERTRAUT", + }, + }); + let [eintrag] = await hostListe(); + expect(eintrag?.hostKey.zustand).toBe("bestaetigt"); + expect(eintrag?.status).toBe("unbekannt"); + + await prisma.hostSchluessel.create({ + data: { hostId: host.id, algorithmus: "ssh-rsa", publicKey: "BBBB", fingerprint: "SHA256:b" }, + }); + await prisma.abweichung.create({ data: { hostId: host.id, art: "UNBEKANNTER_SCHLUESSEL" } }); + [eintrag] = await hostListe(); + expect(eintrag?.hostKey.zustand).toBe("ausstehend"); + expect(eintrag?.offeneAbweichungen).toBe(1); + expect(eintrag?.status).toBe("drift"); + + const detail = await hostDetail(host.id); + expect(detail?.hostSchluessel).toHaveLength(2); + expect(detail?.hostKey).toMatchObject({ vertraut: 1, ausstehend: 1 }); + }); +}); + +describe("Abgeleitete Zustaende", () => { + it("unerreichbar schlaegt Drift", () => { + expect(hostStatus({ erreichbar: false, driftZustand: "DRIFT", offeneAbweichungen: 3 })).toBe( + "unerreichbar", + ); + expect(hostStatus({ erreichbar: true, driftZustand: "SYNCHRON", offeneAbweichungen: 0 })).toBe( + "synchron", + ); + expect(hostStatus({ erreichbar: null, driftZustand: "UNBEKANNT", offeneAbweichungen: 0 })).toBe( + "unbekannt", + ); + }); + + it("ein ausstehender Host-Key geht vor einem vertrauten", () => { + expect(hostKeyZusammenfassen([]).zustand).toBe("keiner"); + expect(hostKeyZusammenfassen(["VERTRAUT", "ERSETZT"]).zustand).toBe("bestaetigt"); + expect(hostKeyZusammenfassen(["VERTRAUT", "AUSSTEHEND"]).zustand).toBe("ausstehend"); + }); +}); + +describe("Rechte der Aktionen", () => { + // Strukturell wie tests/zugang.test.ts: jede exportierte Aktion prueft das Recht selbst. + it.each(["src/server/actions/hosts.ts", "src/server/actions/hostgruppen.ts"])( + "%s verlangt hostsVerwalten in jeder Aktion", + (datei) => { + const inhalt = readFileSync(datei, "utf8"); + expect(inhalt.startsWith('"use server";')).toBe(true); + const aktionen = inhalt.split(/^export async function /m).slice(1); + expect(aktionen.length).toBeGreaterThanOrEqual(2); + for (const rumpf of aktionen) { + expect(rumpf, rumpf.slice(0, 40)).toContain('rechtErforderlich("hostsVerwalten")'); + } + }, + ); +}); From 90a41dc55d0ed5e04a42636114201a1975ae50bc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:50:34 +0200 Subject: [PATCH 2/3] feat(hosts): Seiten fuer Hosts und Hostgruppen (#008) Ersetzt die Platzhalter: Hostliste mit Suche und Gruppenfilter per GET (teilbare Links), Formulare fuer Hosts und Gruppen, Hostdetail als Geruest und Gruppendetail mit Mitgliederauswahl. Bearbeiten-Knoepfe entscheidet der Server per darf(); jede Seite prueft die Sitzung selbst. Die Detailseite rendert HostKeyAktionen (#009) und BootstrapAktionen (#010) als feste Platzhalter mit fertigen Props, damit die Folge-Issues nur ihre eigene Datei ersetzen. Formulare senden per onSubmit, weil React ein
nach jeder Antwort zuruecksetzt und Eingaben bei Feldfehlern sonst verloren gingen. Unter md zeigt die Hostliste Karten statt Tabelle, damit Status und Host-Key auf 390 px ohne Wischen sichtbar bleiben. Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/hostgruppen/[id]/page.tsx | 95 ++++++++ src/app/(app)/hostgruppen/neu/page.tsx | 31 +++ src/app/(app)/hostgruppen/page.tsx | 68 +++++- .../(app)/hosts/[id]/BootstrapAktionen.tsx | 19 ++ src/app/(app)/hosts/[id]/HostKeyAktionen.tsx | 19 ++ src/app/(app)/hosts/[id]/bearbeiten/page.tsx | 44 ++++ src/app/(app)/hosts/[id]/page.tsx | 227 ++++++++++++++++++ src/app/(app)/hosts/neu/page.tsx | 23 ++ src/app/(app)/hosts/page.tsx | 220 ++++++++++++++++- src/components/hosts/Anzeige.tsx | 47 ++++ src/components/hosts/ArchivierenDialog.tsx | 51 ++++ src/components/hosts/GruppenFormular.tsx | 79 ++++++ src/components/hosts/HostFormular.tsx | 197 +++++++++++++++ src/components/hosts/useFormularAktion.ts | 21 ++ src/components/ui/Feld.tsx | 157 +++++++++++- src/components/ui/index.ts | 2 +- 16 files changed, 1272 insertions(+), 28 deletions(-) create mode 100644 src/app/(app)/hostgruppen/[id]/page.tsx create mode 100644 src/app/(app)/hostgruppen/neu/page.tsx create mode 100644 src/app/(app)/hosts/[id]/BootstrapAktionen.tsx create mode 100644 src/app/(app)/hosts/[id]/HostKeyAktionen.tsx create mode 100644 src/app/(app)/hosts/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/hosts/[id]/page.tsx create mode 100644 src/app/(app)/hosts/neu/page.tsx create mode 100644 src/components/hosts/Anzeige.tsx create mode 100644 src/components/hosts/ArchivierenDialog.tsx create mode 100644 src/components/hosts/GruppenFormular.tsx create mode 100644 src/components/hosts/HostFormular.tsx create mode 100644 src/components/hosts/useFormularAktion.ts diff --git a/src/app/(app)/hostgruppen/[id]/page.tsx b/src/app/(app)/hostgruppen/[id]/page.tsx new file mode 100644 index 0000000..79627a3 --- /dev/null +++ b/src/app/(app)/hostgruppen/[id]/page.tsx @@ -0,0 +1,95 @@ +import { Server } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { adresseMitPort } from "@/components/hosts/Anzeige"; +import { GruppenFormular } from "@/components/hosts/GruppenFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datum } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppeDetail, hostAuswahl } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Hostgruppe" }; + +/** Gruppendetail mit Mitgliedern; wer Hosts verwalten darf, bearbeitet hier auch (#008). */ +export default async function HostgruppeSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/hostgruppen/${id}`); + const gruppe = await gruppeDetail(id); + if (!gruppe) notFound(); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const hosts = verwalten ? await hostAuswahl() : []; + const anzahl = gruppe.mitglieder.length; + + return ( +
+ } + titel={gruppe.name} + untertitel={ + <> + {gruppe.beschreibung && {gruppe.beschreibung}} + {anzahl === 1 ? "1 Mitglied" : `${anzahl} Mitglieder`} · angelegt am {datum(gruppe.angelegtAm)} + + } + /> + +
+ + {anzahl === 0 ? ( + } + titel="Keine Mitglieder" + text={ + verwalten + ? "Hosts lassen sich unter „Bearbeiten“ auswählen." + : "Dieser Gruppe ist noch kein Host zugeordnet." + } + /> + ) : ( + + {gruppe.mitglieder.map((h) => ( + + + + {h.name} + +

+ {adresseMitPort(h.adresse, h.port)} +

+
+ + + +
+ ))} +
+ )} +
+ + {verwalten && ( + + m.id), + }} + hosts={hosts} + /> + + )} +
+
+ ); +} diff --git a/src/app/(app)/hostgruppen/neu/page.tsx b/src/app/(app)/hostgruppen/neu/page.tsx new file mode 100644 index 0000000..71f4f86 --- /dev/null +++ b/src/app/(app)/hostgruppen/neu/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { GruppenFormular } from "@/components/hosts/GruppenFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Karte } from "@/components/ui/Karte"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostAuswahl } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Hostgruppe anlegen" }; + +export default async function HostgruppeNeu() { + await sitzungErforderlich("hostsVerwalten", "/hostgruppen/neu"); + const hosts = await hostAuswahl(); + return ( +
+ + } + titel="Hostgruppe anlegen" + /> + + + +
+ ); +} diff --git a/src/app/(app)/hostgruppen/page.tsx b/src/app/(app)/hostgruppen/page.tsx index 055f298..26748c4 100644 --- a/src/app/(app)/hostgruppen/page.tsx +++ b/src/app/(app)/hostgruppen/page.tsx @@ -1,18 +1,70 @@ +import { Boxes, Plus } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenListe } from "@/server/queries/hosts"; export const metadata: Metadata = { title: "Hostgruppen" }; +/** Hostgruppen mit Mitgliederzahl (#008). */ export default async function Hostgruppen() { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + const { benutzer } = await sitzungErforderlich("lesen", "/hostgruppen"); + const gruppen = await gruppenListe(); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const anlegen = verwalten && ( + + ); + return ( - +
+ + {gruppen.length === 0 ? ( + } + titel="Noch keine Hostgruppen" + text="Gruppen wie „Proxmox“ oder „DMZ“ fassen Hosts zusammen – ein Schlüssel lässt sich dann einer ganzen Gruppe zuweisen." + aktion={anlegen} + /> + ) : ( + + + {gruppen.map((g) => ( + + {/* Beschreibung unter dem Namen statt als eigene Spalte: so bleibt die + Mitgliederzahl auch auf 390 px sichtbar (#008). */} + + + {g.name} + + {g.beschreibung &&

{g.beschreibung}

} +
+ + {g.mitglieder} + +
+ ))} +
+
+ )} +
); } diff --git a/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx new file mode 100644 index 0000000..245f258 --- /dev/null +++ b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx @@ -0,0 +1,19 @@ +export type BootstrapAktionenProps = { + readonly hostId: string; + /** Recht `hostsVerwalten` – serverseitig per `darf()` ermittelt. */ + readonly darfVerwalten: boolean; + /** `bootstrapAm` ist gesetzt: sshkv arbeitet bereits schluesselbasiert. */ + readonly eingerichtet: boolean; + /** Mindestens ein vertrauter Host-Key – Voraussetzung fuer jede Verbindung. */ + readonly hostKeyVertraut: boolean; +}; + +/** + * Platzhalter fuer „Zugang einrichten“ (Bootstrap, #010). Die Detailseite rendert diese + * Komponente unter der Info-Leiste; #010 ersetzt nur diese Datei. + */ +export function BootstrapAktionen(props: BootstrapAktionenProps) { + // Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. + void props; + return null; +} diff --git a/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx new file mode 100644 index 0000000..0086179 --- /dev/null +++ b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx @@ -0,0 +1,19 @@ +import type { HostKeyZusammenfassung } from "@/lib/hosts"; + +export type HostKeyAktionenProps = { + readonly hostId: string; + /** Recht `hostKeyEntscheiden` – serverseitig per `darf()` ermittelt. */ + readonly darfEntscheiden: boolean; + /** Zaehlung der Host-Keys nach Status und zusammengefasster Zustand. */ + readonly status: HostKeyZusammenfassung; +}; + +/** + * Platzhalter fuer „Host-Key abrufen“ / „Bestätigen“ / „Ablehnen“ (#009). Die Detailseite + * rendert diese Komponente im Kopf der Karte „Host-Key“; #009 ersetzt nur diese Datei. + */ +export function HostKeyAktionen(props: HostKeyAktionenProps) { + // Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. + void props; + return null; +} diff --git a/src/app/(app)/hosts/[id]/bearbeiten/page.tsx b/src/app/(app)/hosts/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..a0f7ab4 --- /dev/null +++ b/src/app/(app)/hosts/[id]/bearbeiten/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { HostFormular } from "@/components/hosts/HostFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, hostFormularWerte, kundenNamen } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Host bearbeiten" }; + +export default async function HostBearbeiten({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("hostsVerwalten", `/hosts/${id}/bearbeiten`); + const [werte, gruppen, kunden] = await Promise.all([ + hostFormularWerte(id), + gruppenAuswahl(), + kundenNamen(), + ]); + if (!werte) notFound(); + // Archivierte Hosts sind schreibgeschuetzt; die Detailseite sagt warum. + if (werte.archiviert) redirect(`/hosts/${id}`); + + return ( +
+ + } + titel={ + <> + {werte.name} bearbeiten + + } + /> + +
+ ); +} diff --git a/src/app/(app)/hosts/[id]/page.tsx b/src/app/(app)/hosts/[id]/page.tsx new file mode 100644 index 0000000..368720a --- /dev/null +++ b/src/app/(app)/hosts/[id]/page.tsx @@ -0,0 +1,227 @@ +import { KeyRound, Pencil, ShieldQuestion } from "lucide-react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { ArchivierenDialog } from "@/components/hosts/ArchivierenDialog"; +import { adresseMitPort, AnmeldeartBadge, HostSchluesselStatusBadge } from "@/components/hosts/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { datum, datumZeit, relativ } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostDetail, type HostDetail } from "@/server/queries/hosts"; +import { BootstrapAktionen } from "./BootstrapAktionen"; +import { HostKeyAktionen } from "./HostKeyAktionen"; + +export const metadata: Metadata = { title: "Host" }; + +function Zeitpunkt({ wert, jetzt, sonst }: { wert: Date | null; jetzt: Date; sonst: string }) { + if (!wert) return {sonst}; + return ( + + ); +} + +function Info({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
+
{titel}
+
{children}
+
+ ); +} + +function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) { + const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : ""; + if (host.erreichbar === true) return ; + if (host.erreichbar === false) + return ; + return ; +} + +/** `sshd -T`-Auszug (#010 fuellt ihn); hier nur flach als Schluessel/Wert gezeigt. */ +function SshdBefund({ befund }: { befund: unknown }) { + if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null; + const eintraege = Object.entries(befund as Record); + if (eintraege.length === 0) return null; + return ( +
+

sshd-Befund

+
+ {eintraege.map(([k, v]) => ( +
+
{k}
+
{String(v)}
+
+ ))} +
+
+ ); +} + +/** Hostdetail (#008): Gerüst, in das #009 (Host-Key), #010 (Bootstrap) und #016 einziehen. */ +export default async function HostSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/hosts/${id}`); + const host = await hostDetail(id); + if (!host) notFound(); + + const archiviert = host.archiviertAm !== null; + const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; + const jetzt = new Date(); + + return ( +
+ } + titel={{host.name}} + untertitel={ + <> + + {adresseMitPort(host.adresse, host.port)} + + {host.beschreibung && {host.beschreibung}} + + } + badges={ + <> + {archiviert && host.archiviertAm && ( + archiviert am {datum(host.archiviertAm)} + )} + + {host.gruppen.map((g) => ( + {g.name} + ))} + {host.tags.map((t) => ( + + #{t} + + ))} + + } + aktionen={ + verwalten && ( + + ) + } + /> + + +
+ + + + + {host.bootstrapAm ? ( + + ) : ( + noch nicht eingerichtet + )} + + + + + {host.letzterAbgleichAm && } + + + {host.kunde ? host.kunde.name : –} +
+ + 0} + /> +
+ + + } + > + {host.hostSchluessel.length === 0 ? ( + } + titel="Noch kein Host-Key abgerufen" + text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host." + /> + ) : ( +
    + {host.hostSchluessel.map((k) => ( +
  • + + {k.algorithmus} + + + + + + + + zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)} + + + + + +
  • + ))} +
+ )} +
+ + + {host.zielkonten.length > 0 && ( +

+ Zielkonten:{" "} + {host.zielkonten.map((z, i) => ( + + {i > 0 && ", "} + {z.benutzername} + + ))} +

+ )} + } + titel="Folgt mit #016" + text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet." + /> +
+ + {verwalten && ( + +
+

+ Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten. +

+ +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/hosts/neu/page.tsx b/src/app/(app)/hosts/neu/page.tsx new file mode 100644 index 0000000..fb44f90 --- /dev/null +++ b/src/app/(app)/hosts/neu/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { HostFormular, LEERER_HOST } from "@/components/hosts/HostFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, kundenNamen } from "@/server/queries/hosts"; + +export const metadata: Metadata = { title: "Host anlegen" }; + +export default async function HostNeu() { + await sitzungErforderlich("hostsVerwalten", "/hosts/neu"); + const [gruppen, kunden] = await Promise.all([gruppenAuswahl(), kundenNamen()]); + return ( +
+ } + titel="Host anlegen" + untertitel="Danach ruft sshkv den Host-Key ab; eingerichtet wird der Zugang im nächsten Schritt." + /> + +
+ ); +} diff --git a/src/app/(app)/hosts/page.tsx b/src/app/(app)/hosts/page.tsx index 654d3e0..a85efcc 100644 --- a/src/app/(app)/hosts/page.tsx +++ b/src/app/(app)/hosts/page.tsx @@ -1,18 +1,218 @@ +import { Plus, Search, Server } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { adresseMitPort, HostKeyBadge } from "@/components/hosts/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Auswahlliste, Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datumZeit, relativ } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { gruppenAuswahl, hostListe, type HostListenEintrag } from "@/server/queries/hosts"; export const metadata: Metadata = { title: "Hosts" }; -export default async function Hosts() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); +type Suchparameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string { + return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? ""; +} + +function statusText(h: HostListenEintrag): string | undefined { + if (h.status !== "drift" || h.offeneAbweichungen === 0) return undefined; + return h.offeneAbweichungen === 1 ? "1 Abweichung" : `${h.offeneAbweichungen} Abweichungen`; +} + +function Abgleich({ wert, jetzt }: { wert: Date | null; jetzt: Date }) { + if (!wert) return <>noch nie; return ( - + + ); +} + +/** Hostliste mit Suche und Gruppenfilter (#008). Filter per GET, damit Links teilbar sind. */ +export default async function Hosts({ searchParams }: { searchParams: Suchparameter }) { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + const { benutzer } = await sitzungErforderlich("lesen", "/hosts"); + const parameter = await searchParams; + const q = einzeln(parameter["q"]); + const gruppeId = einzeln(parameter["gruppe"]); + const [hosts, gruppen] = await Promise.all([ + hostListe({ q, gruppeId: gruppeId || undefined }), + gruppenAuswahl(), + ]); + const verwalten = darf(benutzer.rolle, "hostsVerwalten"); + const gefiltert = q !== "" || gruppeId !== ""; + const jetzt = new Date(); + + return ( +
+ }> + Host anlegen + + ) + } + /> + + + + + + {gruppen.map((g) => ( + + ))} + +
+ + {gefiltert && ( + + )} +
+ + + {hosts.length === 0 ? ( + gefiltert ? ( + } + titel="Keine Treffer" + text="Kein Host passt zu Suche und Gruppe." + aktion={} + /> + ) : ( + } + titel="Noch keine Hosts" + text="Legen Sie den ersten Server an. Danach ruft sshkv seinen Host-Key ab und richtet den Zugang ein." + aktion={ + verwalten && ( + + ) + } + /> + ) + ) : ( + + {/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px, und + Status und Host-Key sollen ohne seitliches Wischen sichtbar sein (#008). */} +
    + {hosts.map((h) => ( +
  • + + + + {h.name} + + + {adresseMitPort(h.adresse, h.port)} + + + + + + {h.gruppen.map((g) => ( + {g.name} + ))} + + + Letzter Abgleich: + + +
  • + ))} +
+ + {hosts.map((h) => ( + + + + {h.name} + + + + {adresseMitPort(h.adresse, h.port)} + + + {h.gruppen.length === 0 ? ( + – + ) : ( + + {h.gruppen.map((g) => ( + {g.name} + ))} + + )} + + + + + + + + + + + + ))} + +
+ )} +
); } diff --git a/src/components/hosts/Anzeige.tsx b/src/components/hosts/Anzeige.tsx new file mode 100644 index 0000000..6871ffc --- /dev/null +++ b/src/components/hosts/Anzeige.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@/components/ui/Badge"; +import type { HostKeyZustand } from "@/lib/hosts"; + +/** Kleine Anzeigebausteine fuer Hosts (#008) – reine Darstellung. */ + +const HOSTKEY: Record = { + bestaetigt: { text: "bestätigt", variante: "ok" }, + // Messing statt Koralle: eine offene Entscheidung, noch kein Fehler. + ausstehend: { text: "Bestätigung ausstehend", variante: "messing" }, + keiner: { text: "nicht abgerufen", variante: "neutral" }, +}; + +export function HostKeyBadge({ zustand }: { zustand: HostKeyZustand }) { + const d = HOSTKEY[zustand]; + return {d.text}; +} + +const HOSTSCHLUESSEL_STATUS: Record< + string, + { text: string; variante: "ok" | "messing" | "neutral" | "gefahr" } +> = { + VERTRAUT: { text: "vertraut", variante: "ok" }, + AUSSTEHEND: { text: "ausstehend", variante: "messing" }, + ABGELEHNT: { text: "abgelehnt", variante: "gefahr" }, + ERSETZT: { text: "ersetzt", variante: "neutral" }, +}; + +export function HostSchluesselStatusBadge({ status }: { status: string }) { + const d = HOSTSCHLUESSEL_STATUS[status] ?? { text: status, variante: "neutral" as const }; + return {d.text}; +} + +export function AnmeldeartBadge({ anmeldeart }: { anmeldeart: "DIENSTBENUTZER" | "ROOT" }) { + return anmeldeart === "ROOT" ? ( + + root + + ) : ( + sshkv + sudo + ); +} + +/** `host:port`, IPv6 in Klammern – so, wie man es in ssh-Befehlen wiedererkennt. */ +export function adresseMitPort(adresse: string, port: number): string { + const host = adresse.includes(":") ? `[${adresse}]` : adresse; + return `${host}:${port}`; +} diff --git a/src/components/hosts/ArchivierenDialog.tsx b/src/components/hosts/ArchivierenDialog.tsx new file mode 100644 index 0000000..cea1db6 --- /dev/null +++ b/src/components/hosts/ArchivierenDialog.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { useActionState, useRef } from "react"; +import { Button, Formularfehler } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { hostArchivierenAktion } from "@/server/actions/hosts"; + +/** + * Archivieren mit Rueckfrage (#008). Ein nativer `` bringt Fokusfalle, Escape und + * Hintergrundsperre mit – ohne eigene Dialog-Bibliothek. + */ +export function ArchivierenDialog({ hostId, hostName }: { hostId: string; hostName: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(hostArchivierenAktion, {}); + + return ( + <> + + +
+ +

+ {hostName} archivieren? +

+

+ Der Host verschwindet aus Listen und Hostgruppen. sshkv verbindet sich nicht mehr mit ihm; auf dem + Host selbst ändert sich nichts. Das Protokoll bleibt erhalten. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/components/hosts/GruppenFormular.tsx b/src/components/hosts/GruppenFormular.tsx new file mode 100644 index 0000000..a359291 --- /dev/null +++ b/src/components/hosts/GruppenFormular.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Textbereich } from "@/components/ui"; +import { hostgruppeAnlegenAktion, hostgruppeBearbeitenAktion } from "@/server/actions/hostgruppen"; +import { useFormularAktion } from "./useFormularAktion"; + +export type GruppenFormularVorgabe = { + id?: string; + name: string; + beschreibung: string; + hostIds: string[]; +}; + +/** Name, Beschreibung und Mitglieder einer Hostgruppe (#008). */ +export function GruppenFormular({ + vorgabe, + hosts, + abbrechenZiel, +}: { + vorgabe: GruppenFormularVorgabe; + hosts: { id: string; name: string; adresse: string }[]; + abbrechenZiel?: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? hostgruppeBearbeitenAktion : hostgruppeAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + + return ( +
+ {vorgabe.id && } + + + + + {hosts.length > 0 && ( +
+ {hosts.map((h) => ( + {h.name}} + erklaerung={{h.adresse}} + /> + ))} +
+ )} +
+
+ + {abbrechenZiel && } +
+ + ); +} diff --git a/src/components/hosts/HostFormular.tsx b/src/components/hosts/HostFormular.tsx new file mode 100644 index 0000000..976c273 --- /dev/null +++ b/src/components/hosts/HostFormular.tsx @@ -0,0 +1,197 @@ +"use client"; + +import Link from "next/link"; +import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import { hostAnlegenAktion, hostBearbeitenAktion } from "@/server/actions/hosts"; +import { useFormularAktion } from "./useFormularAktion"; + +export type HostFormularVorgabe = { + id?: string; + name: string; + adresse: string; + port: number; + tags: string[]; + beschreibung: string; + kundeName: string; + anmeldeart: "DIENSTBENUTZER" | "ROOT"; + gruppenIds: string[]; +}; + +export const LEERER_HOST: HostFormularVorgabe = { + name: "", + adresse: "", + port: 22, + tags: [], + beschreibung: "", + kundeName: "", + anmeldeart: "DIENSTBENUTZER", + gruppenIds: [], +}; + +/** Formular fuer neue und bestehende Hosts (#008). Regeln prueft der Server. */ +export function HostFormular({ + vorgabe, + gruppen, + kunden, + abbrechenZiel, +}: { + vorgabe: HostFormularVorgabe; + gruppen: { id: string; name: string }[]; + kunden: string[]; + abbrechenZiel: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? hostBearbeitenAktion : hostAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + + return ( +
+ {vorgabe.id && } + + + +
+ +
+ + +
+
+
+ + + + + Dienstbenutzer sshkv mit sudo – empfohlen + + } + erklaerung="Ein eigener Benutzer mit /etc/sudoers.d/sshkv; root-Login per SSH bleibt aus." + /> + + + + + +
+ + Noch keine Hostgruppen –{" "} + + Gruppe anlegen + + . + + ) : undefined + } + > + {gruppen.length > 0 && ( +
+ {gruppen.map((g) => ( + + ))} +
+ )} +
+
+ + + + {kunden.map((k) => ( + +
+ +
+
+ +
+ + +
+ + ); +} diff --git a/src/components/hosts/useFormularAktion.ts b/src/components/hosts/useFormularAktion.ts new file mode 100644 index 0000000..f1c6021 --- /dev/null +++ b/src/components/hosts/useFormularAktion.ts @@ -0,0 +1,21 @@ +"use client"; + +import { startTransition, useActionState, type FormEvent } from "react"; +import type { FormularZustand } from "@/server/actions/formular"; + +type Aktion = (vorher: FormularZustand, formular: FormData) => Promise; + +/** + * `useActionState` mit eigenem Absenden (#008). Ueber `
` setzte React das + * Formular nach jeder Antwort zurueck – bei einer Feldmeldung waeren alle Eingaben weg. + * Mit `onSubmit` und `startTransition` bleiben sie stehen. + */ +export function useFormularAktion(aktion: Aktion) { + const [zustand, ausfuehren, laeuft] = useActionState(aktion, {}); + function absenden(ereignis: FormEvent) { + ereignis.preventDefault(); + const daten = new FormData(ereignis.currentTarget); + startTransition(() => ausfuehren(daten)); + } + return { zustand, absenden, laeuft } as const; +} diff --git a/src/components/ui/Feld.tsx b/src/components/ui/Feld.tsx index 22b72ea..6ba5a5c 100644 --- a/src/components/ui/Feld.tsx +++ b/src/components/ui/Feld.tsx @@ -1,37 +1,176 @@ -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { clsx } from "clsx"; +const EINGABE_KLASSEN = clsx( + "bg-bg border-border-strong text-text rounded-feld min-h-11 w-full min-w-0 border px-3 text-[15px]", + "placeholder:text-muted/70 focus-visible:border-accent", + "aria-[invalid=true]:border-danger-border", +); + +/** Hinweis und Fehlermeldung unter einem Feld; die Ids verknuepft `aria-describedby` (#008). */ +function FeldFuss({ feldId, hinweis, fehler }: { feldId: string; hinweis?: ReactNode; fehler?: string }) { + return ( + <> + {/* Bei einem Fehler ersetzt die Meldung den Hinweis – meist sagt sie dasselbe genauer. */} + {hinweis && !fehler && ( +

+ {hinweis} +

+ )} + {fehler && ( +

+ {fehler} +

+ )} + + ); +} + +function beschreibtDurch(feldId: string, hinweis?: ReactNode, fehler?: string): string | undefined { + if (fehler) return `${feldId}-fehler`; + return hinweis ? `${feldId}-hinweis` : undefined; +} + /** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */ export function Feld({ beschriftung, hinweis, + fehler, mono = false, className, id, name, ...rest -}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) { +}: ComponentProps<"input"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string; mono?: boolean }) { const feldId = id ?? `feld-${name}`; return ( -
+
- {hinweis &&

{hinweis}

} +
); } +/** Mehrzeiliges Textfeld, z. B. fuer Beschreibungen (#008). */ +export function Textbereich({ + beschriftung, + hinweis, + fehler, + className, + id, + name, + ...rest +}: ComponentProps<"textarea"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string }) { + const feldId = id ?? `feld-${name}`; + return ( +
+ +