Merge branch 'phase/008-hosts-gruppen'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -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 `<form action>`: 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).
|
||||
|
||||
+81
-1
@@ -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<void> {
|
||||
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<void> {
|
||||
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
|
||||
const gruppen = new Map<string, string>();
|
||||
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();
|
||||
}
|
||||
|
||||
+18
-3
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Hostgruppen", href: "/hostgruppen" }, { titel: gruppe.name }]} />}
|
||||
titel={gruppe.name}
|
||||
untertitel={
|
||||
<>
|
||||
{gruppe.beschreibung && <span className="mb-1 block">{gruppe.beschreibung}</span>}
|
||||
{anzahl === 1 ? "1 Mitglied" : `${anzahl} Mitglieder`} · angelegt am {datum(gruppe.angelegtAm)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||
<Karte titel="Mitglieder">
|
||||
{anzahl === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<Server className="size-5" />}
|
||||
titel="Keine Mitglieder"
|
||||
text={
|
||||
verwalten
|
||||
? "Hosts lassen sich unter „Bearbeiten“ auswählen."
|
||||
: "Dieser Gruppe ist noch kein Host zugeordnet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Tabelle beschriftung="Mitglieder" spalten={[{ titel: "Host" }, { titel: "Status" }]}>
|
||||
{gruppe.mitglieder.map((h) => (
|
||||
<TabellenZeile key={h.id}>
|
||||
<TabellenZelle className="min-w-0">
|
||||
<Link
|
||||
href={`/hosts/${h.id}`}
|
||||
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-mono text-[13.5px] font-medium break-all transition-colors"
|
||||
>
|
||||
{h.name}
|
||||
</Link>
|
||||
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||
{adresseMitPort(h.adresse, h.port)}
|
||||
</p>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={h.status} />
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{verwalten && (
|
||||
<Karte titel="Bearbeiten" untertitel="Name, Beschreibung und Mitglieder">
|
||||
<GruppenFormular
|
||||
vorgabe={{
|
||||
id: gruppe.id,
|
||||
name: gruppe.name,
|
||||
beschreibung: gruppe.beschreibung ?? "",
|
||||
hostIds: gruppe.mitglieder.map((m) => m.id),
|
||||
}}
|
||||
hosts={hosts}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb pfad={[{ titel: "Hostgruppen", href: "/hostgruppen" }, { titel: "Neue Gruppe" }]} />
|
||||
}
|
||||
titel="Hostgruppe anlegen"
|
||||
/>
|
||||
<Karte>
|
||||
<GruppenFormular
|
||||
vorgabe={{ name: "", beschreibung: "", hostIds: [] }}
|
||||
hosts={hosts}
|
||||
abbrechenZiel="/hostgruppen"
|
||||
/>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<Button href="/hostgruppen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Gruppe anlegen
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Platzhalterseite
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Hostgruppen"
|
||||
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
aktionen={anlegen}
|
||||
/>
|
||||
{gruppen.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<Boxes className="size-5" />}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<Karte>
|
||||
<Tabelle
|
||||
beschriftung="Hostgruppen"
|
||||
spalten={[{ titel: "Gruppe" }, { titel: "Mitglieder", rechts: true }]}
|
||||
>
|
||||
{gruppen.map((g) => (
|
||||
<TabellenZeile key={g.id}>
|
||||
{/* Beschreibung unter dem Namen statt als eigene Spalte: so bleibt die
|
||||
Mitgliederzahl auch auf 390 px sichtbar (#008). */}
|
||||
<TabellenZelle>
|
||||
<Link
|
||||
href={`/hostgruppen/${g.id}`}
|
||||
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-medium transition-colors"
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
{g.beschreibung && <p className="text-muted text-[13.5px]">{g.beschreibung}</p>}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts className="tabular-nums">
|
||||
{g.mitglieder}
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb
|
||||
pfad={[
|
||||
{ titel: "Hosts", href: "/hosts" },
|
||||
{ titel: werte.name, href: `/hosts/${id}` },
|
||||
{ titel: "Bearbeiten" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
titel={
|
||||
<>
|
||||
<span className="font-mono">{werte.name}</span> bearbeiten
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<HostFormular vorgabe={werte} gruppen={gruppen} kunden={kunden} abbrechenZiel={`/hosts/${id}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <span className="text-muted">{sonst}</span>;
|
||||
return (
|
||||
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
|
||||
{relativ(wert, jetzt)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) {
|
||||
const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : "";
|
||||
if (host.erreichbar === true) return <Statuspunkt status="synchron" text={`erreichbar${pruefung}`} />;
|
||||
if (host.erreichbar === false)
|
||||
return <Statuspunkt status="unerreichbar" text={`nicht erreichbar${pruefung}`} />;
|
||||
return <Statuspunkt status="unbekannt" text="noch nicht geprüft" />;
|
||||
}
|
||||
|
||||
/** `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<string, unknown>);
|
||||
if (eintraege.length === 0) return null;
|
||||
return (
|
||||
<div className="border-border mt-5 border-t pt-4">
|
||||
<p className="text-muted text-[13px] font-medium">sshd-Befund</p>
|
||||
<dl className="mt-2 grid gap-x-6 gap-y-1 font-mono text-[13px] sm:grid-cols-2">
|
||||
{eintraege.map(([k, v]) => (
|
||||
<div key={k} className="flex min-w-0 gap-2">
|
||||
<dt className="text-muted shrink-0">{k}</dt>
|
||||
<dd className="text-text min-w-0 break-all">{String(v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: host.name }]} />}
|
||||
titel={<span className="font-mono">{host.name}</span>}
|
||||
untertitel={
|
||||
<>
|
||||
<span className="text-text font-mono text-[13.5px] break-all">
|
||||
{adresseMitPort(host.adresse, host.port)}
|
||||
</span>
|
||||
{host.beschreibung && <span className="mt-1.5 block">{host.beschreibung}</span>}
|
||||
</>
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
{archiviert && host.archiviertAm && (
|
||||
<Badge variante="gefahr">archiviert am {datum(host.archiviertAm)}</Badge>
|
||||
)}
|
||||
<AnmeldeartBadge anmeldeart={host.anmeldeart} />
|
||||
{host.gruppen.map((g) => (
|
||||
<Badge key={g.id}>{g.name}</Badge>
|
||||
))}
|
||||
{host.tags.map((t) => (
|
||||
<Badge key={t} mono className="text-muted">
|
||||
#{t}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button href={`/hosts/${host.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte as="div">
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-4">
|
||||
<Info titel="Erreichbar">
|
||||
<Erreichbarkeit host={host} jetzt={jetzt} />
|
||||
</Info>
|
||||
<Info titel="Bootstrap">
|
||||
{host.bootstrapAm ? (
|
||||
<time dateTime={host.bootstrapAm.toISOString()}>{datumZeit(host.bootstrapAm)}</time>
|
||||
) : (
|
||||
<span className="text-accent">noch nicht eingerichtet</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Letzter Abgleich">
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<Zeitpunkt wert={host.letzterAbgleichAm} jetzt={jetzt} sonst="noch nie" />
|
||||
{host.letzterAbgleichAm && <Statuspunkt status={host.status} className="text-[13px]" />}
|
||||
</span>
|
||||
</Info>
|
||||
<Info titel="Kunde">{host.kunde ? host.kunde.name : <span className="text-muted">–</span>}</Info>
|
||||
</dl>
|
||||
<SshdBefund befund={host.sshdBefund} />
|
||||
<BootstrapAktionen
|
||||
hostId={host.id}
|
||||
darfVerwalten={verwalten}
|
||||
eingerichtet={host.bootstrapAm !== null}
|
||||
hostKeyVertraut={host.hostKey.vertraut > 0}
|
||||
/>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Host-Key"
|
||||
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
|
||||
aktionen={
|
||||
<HostKeyAktionen
|
||||
hostId={host.id}
|
||||
darfEntscheiden={darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert}
|
||||
status={host.hostKey}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{host.hostSchluessel.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<ShieldQuestion className="size-5" />}
|
||||
titel="Noch kein Host-Key abgerufen"
|
||||
text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{host.hostSchluessel.map((k) => (
|
||||
<li
|
||||
key={k.id}
|
||||
className="border-border grid gap-x-4 gap-y-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0 md:grid-cols-[12rem_minmax(0,1fr)_auto] md:items-center"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3 md:block">
|
||||
<span className="text-text font-mono text-[13px]">{k.algorithmus}</span>
|
||||
<span className="md:hidden">
|
||||
<HostSchluesselStatusBadge status={k.status} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<MonoKopieren wert={k.fingerprint} beschriftung="Fingerprint" umbrechen />
|
||||
<span className="text-muted block text-[12.5px]">
|
||||
zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden md:block">
|
||||
<HostSchluesselStatusBadge status={k.status} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Schlüssel auf diesem Host"
|
||||
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
|
||||
>
|
||||
{host.zielkonten.length > 0 && (
|
||||
<p className="text-muted mb-4 text-[14px]">
|
||||
Zielkonten:{" "}
|
||||
{host.zielkonten.map((z, i) => (
|
||||
<span key={z.id}>
|
||||
{i > 0 && ", "}
|
||||
<code className="text-text font-mono text-[13px]">{z.benutzername}</code>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Folgt mit #016"
|
||||
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
||||
/>
|
||||
</Karte>
|
||||
|
||||
{verwalten && (
|
||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted max-w-xl text-[14px]">
|
||||
Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten.
|
||||
</p>
|
||||
<ArchivierenDialog hostId={host.id} hostName={host.name} />
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: "Neuer Host" }]} />}
|
||||
titel="Host anlegen"
|
||||
untertitel="Danach ruft sshkv den Host-Key ab; eingerichtet wird der Zugang im nächsten Schritt."
|
||||
/>
|
||||
<HostFormular vorgabe={LEERER_HOST} gruppen={gruppen} kunden={kunden} abbrechenZiel="/hosts" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
|
||||
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 (
|
||||
<Platzhalterseite
|
||||
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
|
||||
{relativ(wert, jetzt)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Hosts"
|
||||
untertitel="Verwaltete Server, Host-Keys und Erreichbarkeit"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
untertitel={
|
||||
gefiltert
|
||||
? `${hosts.length} Treffer`
|
||||
: `${hosts.length} ${hosts.length === 1 ? "verwalteter Server" : "verwaltete Server"}`
|
||||
}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button href="/hosts/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Host anlegen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<form
|
||||
method="get"
|
||||
role="search"
|
||||
className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_14rem_auto]"
|
||||
>
|
||||
<Feld
|
||||
beschriftung="Suche"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={q}
|
||||
placeholder="Name, Adresse oder Tag"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={gruppeId}>
|
||||
<option value="">Alle Gruppen</option>
|
||||
{gruppen.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="flex gap-2.5">
|
||||
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||
Filtern
|
||||
</Button>
|
||||
{gefiltert && (
|
||||
<Button href="/hosts" className="flex-1 sm:flex-none">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
gefiltert ? (
|
||||
<Leerzustand
|
||||
icon={<Search className="size-5" />}
|
||||
titel="Keine Treffer"
|
||||
text="Kein Host passt zu Suche und Gruppe."
|
||||
aktion={<Button href="/hosts">Filter zurücksetzen</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
icon={<Server className="size-5" />}
|
||||
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 && (
|
||||
<Button
|
||||
href="/hosts/neu"
|
||||
variante="primaer"
|
||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||
>
|
||||
Host anlegen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{/* 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). */}
|
||||
<ul className="flex flex-col md:hidden">
|
||||
{hosts.map((h) => (
|
||||
<li key={h.id} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href={`/hosts/${h.id}`}
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-text block truncate font-mono text-[14px] font-medium">
|
||||
{h.name}
|
||||
</span>
|
||||
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||
{adresseMitPort(h.adresse, h.port)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Statuspunkt status={h.status} text={statusText(h)} />
|
||||
<HostKeyBadge zustand={h.hostKey.zustand} />
|
||||
{h.gruppen.map((g) => (
|
||||
<Badge key={g.id}>{g.name}</Badge>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-muted text-[13px]">
|
||||
Letzter Abgleich: <Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Tabelle
|
||||
className="hidden md:block"
|
||||
beschriftung="Hosts"
|
||||
spalten={[
|
||||
{ titel: "Name" },
|
||||
{ titel: "Adresse" },
|
||||
{ titel: "Gruppen" },
|
||||
{ titel: "Status" },
|
||||
{ titel: "Host-Key" },
|
||||
{ titel: "Letzter Abgleich" },
|
||||
]}
|
||||
>
|
||||
{hosts.map((h) => (
|
||||
<TabellenZeile key={h.id}>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Link
|
||||
href={`/hosts/${h.id}`}
|
||||
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-mono text-[13.5px] font-medium transition-colors"
|
||||
>
|
||||
{h.name}
|
||||
</Link>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||
{adresseMitPort(h.adresse, h.port)}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle>
|
||||
{h.gruppen.length === 0 ? (
|
||||
<span className="text-muted">–</span>
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{h.gruppen.map((g) => (
|
||||
<Badge key={g.id}>{g.name}</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={h.status} text={statusText(h)} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<HostKeyBadge zustand={h.hostKey.zustand} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="text-muted whitespace-nowrap">
|
||||
<Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HostKeyZustand, { text: string; variante: "ok" | "messing" | "neutral" }> = {
|
||||
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 <Badge variante={d.variante}>{d.text}</Badge>;
|
||||
}
|
||||
|
||||
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 <Badge variante={d.variante}>{d.text}</Badge>;
|
||||
}
|
||||
|
||||
export function AnmeldeartBadge({ anmeldeart }: { anmeldeart: "DIENSTBENUTZER" | "ROOT" }) {
|
||||
return anmeldeart === "ROOT" ? (
|
||||
<Badge variante="messing" mono>
|
||||
root
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge mono>sshkv + sudo</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** `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}`;
|
||||
}
|
||||
@@ -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 `<dialog>` bringt Fokusfalle, Escape und
|
||||
* Hintergrundsperre mit – ohne eigene Dialog-Bibliothek.
|
||||
*/
|
||||
export function ArchivierenDialog({ hostId, hostName }: { hostId: string; hostName: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(hostArchivierenAktion, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Archive className="size-4" />}
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
Host archivieren
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="archivieren-titel"
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="id" value={hostId} />
|
||||
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||
<span className="font-mono">{hostName}</span> archivieren?
|
||||
</h2>
|
||||
<p className="text-muted text-[14.5px]">
|
||||
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.
|
||||
</p>
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-5">
|
||||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
defaultValue={vorgabe.name}
|
||||
required
|
||||
maxLength={64}
|
||||
autoComplete="off"
|
||||
placeholder="Proxmox"
|
||||
fehler={f["name"]}
|
||||
/>
|
||||
<Textbereich
|
||||
beschriftung="Beschreibung (optional)"
|
||||
name="beschreibung"
|
||||
defaultValue={vorgabe.beschreibung}
|
||||
maxLength={500}
|
||||
fehler={f["beschreibung"]}
|
||||
/>
|
||||
<Feldgruppe
|
||||
legende="Mitglieder"
|
||||
fehler={f["hostIds"]}
|
||||
hinweis={hosts.length === 0 ? "Noch keine Hosts angelegt." : undefined}
|
||||
>
|
||||
{hosts.length > 0 && (
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{hosts.map((h) => (
|
||||
<Ankreuzfeld
|
||||
key={h.id}
|
||||
name="hostIds"
|
||||
value={h.id}
|
||||
defaultChecked={vorgabe.hostIds.includes(h.id)}
|
||||
beschriftung={<span className="font-mono text-[13.5px]">{h.name}</span>}
|
||||
erklaerung={<span className="font-mono text-[12.5px] break-all">{h.adresse}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Gruppe anlegen"}
|
||||
</Button>
|
||||
{abbrechenZiel && <Button href={abbrechenZiel}>Abbrechen</Button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
|
||||
<Karte titel="Verbindung">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
defaultValue={vorgabe.name}
|
||||
mono
|
||||
required
|
||||
maxLength={64}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="srv-pve01"
|
||||
hinweis="Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."
|
||||
fehler={f["name"]}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3 sm:col-span-2">
|
||||
<Feld
|
||||
beschriftung="Adresse"
|
||||
name="adresse"
|
||||
defaultValue={vorgabe.adresse}
|
||||
mono
|
||||
required
|
||||
maxLength={253}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="10.0.0.11 oder pve01.intern"
|
||||
fehler={f["adresse"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
defaultValue={vorgabe.port}
|
||||
mono
|
||||
inputMode="numeric"
|
||||
required
|
||||
fehler={f["port"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Anmeldung" untertitel="Wie sich sshkv auf diesem Host anmeldet">
|
||||
<Feldgruppe legende="Anmeldeart" fehler={f["anmeldeart"]}>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="anmeldeart"
|
||||
value="DIENSTBENUTZER"
|
||||
defaultChecked={vorgabe.anmeldeart === "DIENSTBENUTZER"}
|
||||
beschriftung={
|
||||
<>
|
||||
Dienstbenutzer <code className="font-mono text-[13.5px]">sshkv</code> mit sudo – empfohlen
|
||||
</>
|
||||
}
|
||||
erklaerung="Ein eigener Benutzer mit /etc/sudoers.d/sshkv; root-Login per SSH bleibt aus."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="anmeldeart"
|
||||
value="ROOT"
|
||||
defaultChecked={vorgabe.anmeldeart === "ROOT"}
|
||||
beschriftung="Direkt als root"
|
||||
erklaerung="Nur wenn kein Dienstbenutzer möglich ist (z. B. Appliances). Verlangt PermitRootLogin."
|
||||
/>
|
||||
</Feldgruppe>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Einordnung">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feldgruppe
|
||||
legende="Hostgruppen"
|
||||
fehler={f["gruppenIds"]}
|
||||
hinweis={
|
||||
gruppen.length === 0 ? (
|
||||
<>
|
||||
Noch keine Hostgruppen –{" "}
|
||||
<Link href="/hostgruppen/neu" className="text-accent hover:underline">
|
||||
Gruppe anlegen
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{gruppen.length > 0 && (
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{gruppen.map((g) => (
|
||||
<Ankreuzfeld
|
||||
key={g.id}
|
||||
name="gruppenIds"
|
||||
value={g.id}
|
||||
defaultChecked={vorgabe.gruppenIds.includes(g.id)}
|
||||
beschriftung={g.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Tags"
|
||||
name="tags"
|
||||
defaultValue={vorgabe.tags.join(", ")}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="pve, produktion"
|
||||
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||
fehler={f["tags"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Kunde (optional)"
|
||||
name="kundeName"
|
||||
defaultValue={vorgabe.kundeName}
|
||||
list="kunden-vorschlaege"
|
||||
autoComplete="off"
|
||||
hinweis="Ein neuer Name legt den Kunden an."
|
||||
fehler={f["kundeName"]}
|
||||
/>
|
||||
<datalist id="kunden-vorschlaege">
|
||||
{kunden.map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<Textbereich
|
||||
beschriftung="Beschreibung (optional)"
|
||||
name="beschreibung"
|
||||
defaultValue={vorgabe.beschreibung}
|
||||
maxLength={500}
|
||||
fehler={f["beschreibung"]}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Host anlegen"}
|
||||
</Button>
|
||||
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<FormularZustand>;
|
||||
|
||||
/**
|
||||
* `useActionState` mit eigenem Absenden (#008). Ueber `<form action>` 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<FormularZustand, FormData>(aktion, {});
|
||||
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
startTransition(() => ausfuehren(daten));
|
||||
}
|
||||
return { zustand, absenden, laeuft } as const;
|
||||
}
|
||||
+148
-9
@@ -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 && (
|
||||
<p id={`${feldId}-hinweis`} className="text-muted text-[12.5px]">
|
||||
{hinweis}
|
||||
</p>
|
||||
)}
|
||||
{fehler && (
|
||||
<p id={`${feldId}-fehler`} className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<input
|
||||
id={feldId}
|
||||
name={name}
|
||||
aria-invalid={fehler ? true : undefined}
|
||||
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
|
||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
||||
mono && "font-mono tracking-wide",
|
||||
)}
|
||||
className={clsx(EINGABE_KLASSEN, mono && "font-mono tracking-wide")}
|
||||
/>
|
||||
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
|
||||
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<textarea
|
||||
id={feldId}
|
||||
name={name}
|
||||
rows={3}
|
||||
aria-invalid={fehler ? true : undefined}
|
||||
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||
{...rest}
|
||||
className={clsx(EINGABE_KLASSEN, "py-2.5 leading-normal")}
|
||||
/>
|
||||
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Auswahlliste im Stil der Eingabefelder (#008). */
|
||||
export function Auswahlliste({
|
||||
beschriftung,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
children,
|
||||
...rest
|
||||
}: ComponentProps<"select"> & { beschriftung: string }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<select id={feldId} name={name} {...rest} className={EINGABE_KLASSEN}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox oder Radio mit Beschriftung; die ganze Zeile ist Klickflaeche (44 px), weil das
|
||||
* Kaestchen allein auf dem Telefon zu klein ist (#008).
|
||||
*/
|
||||
export function Ankreuzfeld({
|
||||
beschriftung,
|
||||
erklaerung,
|
||||
className,
|
||||
...rest
|
||||
}: Omit<ComponentProps<"input">, "type"> & {
|
||||
type?: "checkbox" | "radio";
|
||||
beschriftung: ReactNode;
|
||||
erklaerung?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"hover:bg-raised/60 rounded-box flex min-h-11 min-w-0 cursor-pointer items-start gap-3 px-2 py-2.5 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input type="checkbox" {...rest} className="accent-accent mt-0.5 size-[18px] shrink-0 cursor-pointer" />
|
||||
<span className="min-w-0 text-[14.5px] leading-snug">
|
||||
<span className="text-text block break-words">{beschriftung}</span>
|
||||
{erklaerung && <span className="text-muted mt-0.5 block text-[13px]">{erklaerung}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gruppe von Ankreuzfeldern mit Legende und Fehler (#008). */
|
||||
export function Feldgruppe({
|
||||
legende,
|
||||
hinweis,
|
||||
fehler,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
legende: string;
|
||||
hinweis?: ReactNode;
|
||||
fehler?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<legend className="text-muted mb-1.5 text-[13px] font-medium">{legende}</legend>
|
||||
{hinweis && <p className="text-muted -mt-1 text-[12.5px]">{hinweis}</p>}
|
||||
{children}
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
||||
export function Formularfehler({ text }: { text?: string }) {
|
||||
if (!text) return null;
|
||||
|
||||
@@ -9,4 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
|
||||
export { Seitenkopf } from "./Seitenkopf";
|
||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||
export { Feld, Formularfehler } from "./Feld";
|
||||
export { Ankreuzfeld, Auswahlliste, Feld, Feldgruppe, Formularfehler, Textbereich } from "./Feld";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
/** 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<Benutzer, "id" | "name">): 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 !== "");
|
||||
}
|
||||
@@ -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<FormularZustand> {
|
||||
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<FormularZustand> {
|
||||
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}`);
|
||||
}
|
||||
@@ -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<FormularZustand> {
|
||||
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<FormularZustand> {
|
||||
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<FormularZustand> {
|
||||
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");
|
||||
}
|
||||
@@ -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<ReturnType<typeof hostListe>>[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<Awaited<ReturnType<typeof hostDetail>>>;
|
||||
|
||||
/** 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<Awaited<ReturnType<typeof hostFormularWerte>>>;
|
||||
|
||||
/** 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,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<Map<string, string>> {
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {},
|
||||
) {
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
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<string, Aenderung> = {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>): Promise<HostFehler> {
|
||||
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")');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user