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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof hostEingabeSchema>;
|
||||
|
||||
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<typeof hostgruppeEingabeSchema>;
|
||||
|
||||
/** Zod-Fehler als `{ feld: erste Meldung }` fuer die Anzeige unter dem Feld. */
|
||||
export function feldfehlerAus(fehler: z.ZodError): Record<string, string> {
|
||||
const ergebnis: Record<string, string> = {};
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user