feat(hosts): Host-Tabelle mit Filtern, CSV-Export und Detailansicht (#007)

Suche ueber Hostname, Key, Server-ID, IP und Rechnungsnummer, fuenf Filter,
Sortierung mit nulls-last, Seitenblaettern und CSV-Export. Detailansicht mit
sechs Tabs, Notizen, Tags und Token-Widerruf. 59 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:12:41 +02:00
co-authored by Claude Opus 5
parent 6aa6a20f63
commit 0ae96a50cd
18 changed files with 1289 additions and 286 deletions
+61
View File
@@ -0,0 +1,61 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import type { AktionsErgebnis } from "./kunden";
/** Speichert Notizen und Tags eines Hosts. */
export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const hostId = formular.get("hostId")?.toString();
if (!hostId) return { fehler: "Host nicht gefunden." };
const notizen = formular.get("notizen")?.toString().trim() || null;
const tags = (formular.get("tags")?.toString() ?? "")
.split(",")
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean)
.slice(0, 12);
await prisma.host.update({
where: { id: hostId },
data: {
notizen,
tags: {
set: [],
connectOrCreate: tags.map((name) => ({ where: { name }, create: { name } })),
},
},
});
await protokolliere("host.notizen_geaendert", "Host", hostId, { notizen, tags });
revalidatePath(`/hosts/${hostId}`);
return { erfolg: "Gespeichert." };
}
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
export async function hostKundeSetzen(formular: FormData) {
const hostId = formular.get("hostId")?.toString();
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
if (!hostId) return;
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
if (kundeId) {
await prisma.alert.updateMany({
where: { regel: "host-ohne-kunde", hostId, erledigtAm: null },
data: { erledigtAm: new Date() },
});
}
revalidatePath(`/hosts/${hostId}`);
revalidatePath("/hosts");
revalidatePath("/hosts/nicht-zugeordnet");
revalidatePath("/");
}
+169
View File
@@ -0,0 +1,169 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { meldungsGrenze } from "./hosts";
export interface HostFilter {
q?: string;
kunde?: string;
status?: string;
produkt?: string;
ablauf?: "30" | "14" | "abgelaufen";
rechnung?: "offen";
sortierung?: string;
seite?: number;
}
export const SEITENGROESSE = 25;
/** Baut die Where-Bedingung aus den Filtern der Host-Tabelle. */
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: Prisma.HostWhereInput[] = [];
if (filter.q && filter.q.trim().length > 0) {
const suche = filter.q.trim();
und.push({
OR: [
{ hostname: { contains: suche, mode: "insensitive" } },
{ fqdn: { contains: suche, mode: "insensitive" } },
{ serverId: { contains: suche, mode: "insensitive" } },
{ subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } },
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
{
laufzeiten: {
some: {
OR: [
{ proxmoxPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
{ kundenPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
],
},
},
},
],
});
}
if (filter.kunde === "ohne") und.push({ kundeId: null });
else if (filter.kunde) und.push({ kundeId: filter.kunde });
if (filter.status === "keine-meldung") {
und.push({
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
});
} else if (filter.status) {
und.push({ subStatus: filter.status as Prisma.EnumSubscriptionStatusFilter["equals"] });
}
if (filter.produkt) und.push({ produkt: filter.produkt as Prisma.EnumProduktFilter["equals"] });
if (filter.ablauf === "30") {
und.push({ subNextduedate: { gte: jetzt, lte: new Date(jetzt.getTime() + 30 * 86_400_000) } });
} else if (filter.ablauf === "14") {
und.push({ subNextduedate: { gte: jetzt, lte: new Date(jetzt.getTime() + 14 * 86_400_000) } });
} else if (filter.ablauf === "abgelaufen") {
und.push({ subNextduedate: { lt: jetzt } });
}
if (filter.rechnung === "offen") {
und.push({
laufzeiten: {
some: {
status: "BESTAETIGT",
OR: [
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
{ kundenPosition: null },
{ kundenPosition: { rechnung: { erstellt: false } } },
],
},
},
});
}
return und.length > 0 ? { AND: und } : {};
}
// Hosts ohne Ablaufdatum bzw. ohne Meldung stehen immer am Ende, egal in welche Richtung sortiert wird.
const SORTIERUNGEN: Record<string, Prisma.HostOrderByWithRelationInput> = {
hostname: { hostname: "asc" },
"hostname-ab": { hostname: "desc" },
ablauf: { subNextduedate: { sort: "asc", nulls: "last" } },
"ablauf-ab": { subNextduedate: { sort: "desc", nulls: "last" } },
kunde: { kunde: { name: "asc" } },
"kunde-ab": { kunde: { name: "desc" } },
status: { subStatus: "asc" },
"status-ab": { subStatus: "desc" },
meldung: { letzterReportAm: { sort: "desc", nulls: "last" } },
"meldung-ab": { letzterReportAm: { sort: "asc", nulls: "last" } },
};
export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
const where = bedingung(filter, jetzt);
const seite = Math.max(1, filter.seite ?? 1);
const orderBy = SORTIERUNGEN[filter.sortierung ?? "hostname"] ?? SORTIERUNGEN.hostname;
const [gesamt, hosts] = await Promise.all([
prisma.host.count({ where }),
prisma.host.findMany({
where,
orderBy,
skip: (seite - 1) * SEITENGROESSE,
take: SEITENGROESSE,
include: {
kunde: { select: { id: true, name: true } },
standort: { select: { name: true } },
},
}),
]);
const grenze = meldungsGrenze(jetzt);
return {
gesamt,
seite,
seiten: Math.max(1, Math.ceil(gesamt / SEITENGROESSE)),
hosts: hosts.map((host) => ({
...host,
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze,
socketAbweichung:
host.subLizenzierteSockets !== null &&
host.sockets !== null &&
host.subLizenzierteSockets !== host.sockets,
})),
};
}
/** Alle Daten der Host-Detailansicht in einem Rutsch. */
export async function hostMitDetails(id: string) {
return prisma.host.findUnique({
where: { id },
include: {
kunde: { select: { id: true, name: true } },
standort: { select: { id: true, name: true, ort: true } },
tags: true,
alerts: { where: { erledigtAm: null }, orderBy: { geoeffnetAm: "desc" } },
keyHistorie: { orderBy: { letzteMeldung: "desc" } },
serverIdHistorie: { orderBy: { letzteMeldung: "desc" } },
tokens: { orderBy: { erstelltAm: "desc" } },
reports: { orderBy: { erhobenAm: "desc" }, take: 30 },
laufzeiten: {
orderBy: { start: "desc" },
include: {
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
},
},
});
}
export type HostDetail = NonNullable<Awaited<ReturnType<typeof hostMitDetails>>>;
/** Auswahllisten für die Filterleiste. */
export async function filterOptionen() {
const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return { kunden };
}