feat(hosts): Services, Lesemodelle und Aktionen fuer Hosts (#008)

Hosts und Hostgruppen brauchen eine Schreibschicht, bevor Host-Key-Pruefung
(#009) und Bootstrap (#010) darauf aufsetzen. Die Regeln (Name, Adresse wie
der DB-CHECK, Port, Tags, Anmeldeart) liegen framework-frei in src/lib/hosts.ts,
damit Action (Feldmeldungen) und Service (Schutz fuer Seed und Assistenten)
dieselben pruefen.

Archivieren statt Loeschen haelt Protokollbezuege lesbar; dabei fallen die
Gruppenmitgliedschaften weg, damit Zuweisungen an eine Gruppe nie einen
archivierten Host treffen. Jede Aenderung landet mit den geaenderten Feldern
im Protokoll, Eindeutigkeitsverletzungen kommen als deutsche HostFehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:42:56 +02:00
co-authored by Claude Opus 5.5
parent b194054d30
commit 4f9d5d9680
8 changed files with 1237 additions and 0 deletions
+252
View File
@@ -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,
}),
})),
};
}