feat(hosts): Doppelte Hosts erkennen und stilllegen (#041)
Zwei Eintraege desselben Namens beim selben Kunden entstehen, wenn sich die Kennung aus /etc/machine-id aendert - Neuinstallation, geklonter Container, zurueckgespielte Sicherung. Dieselbe Maschine meldet sich dann als neuer Host, der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind aber genauso moeglich. Neue dreizehnte Warnregel meldet den Fall und nennt die IP-Adressen zum Unterscheiden; gleiche Namen bei verschiedenen Kunden bleiben still. Dazu die bis dahin fehlende Moeglichkeit, einen Host loszuwerden: Stilllegen blendet ihn aus Listen, Zahlen, Suche und Warnungen aus und schliesst seine offenen Warnungen, laesst Laufzeiten und Rechnungen aber unberuehrt. Endgueltig loeschen geht nur ohne Laufzeiten - Buchhaltung wirft man nicht weg. Kein automatisches Zusammenfuehren: Es muesste raten, welcher Eintrag welche Rechnung erbt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eigenerPfad } from "@/lib/adresse";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Host still: Er verschwindet aus Listen, Zahlen und Warnungen, bleibt aber mit seiner
|
||||
* Geschichte erhalten (#041). Gedacht für Maschinen, die es nicht mehr gibt – oder für einen
|
||||
* doppelten Eintrag, der entstanden ist, weil sich die Maschinenkennung geändert hat.
|
||||
*/
|
||||
export async function hostArchivieren(formular: FormData) {
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { archiviertAm: true } });
|
||||
if (!host) return;
|
||||
|
||||
const stilllegen = host.archiviertAm === null;
|
||||
await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { archiviertAm: stilllegen ? new Date() : null },
|
||||
});
|
||||
|
||||
// Offene Warnungen eines stillgelegten Hosts schließen – sonst bleiben sie für immer stehen.
|
||||
if (stilllegen) {
|
||||
await prisma.alert.updateMany({
|
||||
where: { hostId, erledigtAm: null },
|
||||
data: { erledigtAm: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliere(stilllegen ? "host.stillgelegt" : "host.reaktiviert", "Host", hostId, {});
|
||||
revalidatePath(`/hosts/${hostId}`);
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Host endgültig – nur, solange keine Laufzeit daran hängt. Sobald Rechnungen im
|
||||
* Spiel sind, bleibt nur das Stilllegen: Buchhaltung wirft man nicht weg.
|
||||
*/
|
||||
export async function hostLoeschen(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
const laufzeiten = await prisma.laufzeit.count({ where: { hostId } });
|
||||
if (laufzeiten > 0) return;
|
||||
|
||||
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { hostname: true } });
|
||||
if (!host) return;
|
||||
|
||||
await prisma.host.delete({ where: { id: hostId } });
|
||||
await protokolliere("host.geloescht", "Host", hostId, { hostname: host.hostname });
|
||||
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/");
|
||||
redirect(await eigenerPfad("/hosts"));
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
|
||||
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
|
||||
const [hosts, laufzeiten, einstellungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
|
||||
where: { archiviertAm: null },
|
||||
include: {
|
||||
kunde: { select: { name: true } },
|
||||
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
|
||||
},
|
||||
}),
|
||||
prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT" },
|
||||
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||
include: {
|
||||
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
@@ -40,6 +42,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
subLizenzierteSockets: host.subLizenzierteSockets,
|
||||
sockets: host.sockets,
|
||||
letzterReportAm: host.letzterReportAm,
|
||||
ips: host.ips,
|
||||
serverIds: host.serverIdHistorie,
|
||||
})),
|
||||
laufzeiten: laufzeiten.map((laufzeit) => ({
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface HostDaten {
|
||||
subLizenzierteSockets: number | null;
|
||||
sockets: number | null;
|
||||
letzterReportAm: Date | null;
|
||||
ips: string[];
|
||||
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
|
||||
}
|
||||
|
||||
@@ -405,6 +406,51 @@ const kundenrechnungUnbezahlt: Regel = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Zwei Hosts desselben Kunden mit demselben Namen (#041).
|
||||
*
|
||||
* Das ist entweder gewollt – zwei Maschinen in getrennten Netzen heißen eben gleich – oder ein
|
||||
* Doppeleintrag: Hat eine Maschine ihre Kennung aus `/etc/machine-id` verloren (Neuinstallation,
|
||||
* geklonter Container), registriert sie sich als neuer Host, und der alte Eintrag bleibt stehen.
|
||||
* Welcher Fall vorliegt, weiß nur der Betreiber – deshalb ein Hinweis mit den IP-Adressen zum
|
||||
* Unterscheiden und nicht mehr.
|
||||
*/
|
||||
const hostnameDoppelt: Regel = {
|
||||
name: "hostname-doppelt",
|
||||
beschreibung: "Mehrere Hosts desselben Kunden tragen denselben Namen",
|
||||
standardSchwellwerte: {},
|
||||
pruefe(kontext) {
|
||||
const gruppen = new Map<string, HostDaten[]>();
|
||||
for (const host of kontext.hosts) {
|
||||
// Ohne Kunde ist der Vergleich wenig wert: Gleiche Namen sind dort der Normalfall.
|
||||
if (!host.kundeId) continue;
|
||||
const schluessel = `${host.kundeId}:${host.hostname.toLowerCase()}`;
|
||||
gruppen.set(schluessel, [...(gruppen.get(schluessel) ?? []), host]);
|
||||
}
|
||||
|
||||
return [...gruppen.values()]
|
||||
.filter((gruppe) => gruppe.length > 1)
|
||||
.map((gruppe) => {
|
||||
const beschreibungen = gruppe.map(
|
||||
(host) => `${host.ips.join(", ") || "keine IP gemeldet"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
// Der Fingerabdruck hängt an Kunde und Name, nicht an den einzelnen Hosts – sonst gäbe
|
||||
// es für jede Maschine eine eigene Warnung über denselben Sachverhalt.
|
||||
fingerprint: `hostname-doppelt:${gruppe[0].kundeId}:${gruppe[0].hostname.toLowerCase()}`,
|
||||
regel: "hostname-doppelt",
|
||||
schwere: "INFO" as AlertSchwere,
|
||||
titel: `${gruppe.length} Hosts heißen „${gruppe[0].hostname}“`,
|
||||
beschreibung: `${kundenHinweis(gruppe[0].kundeName)} · ${beschreibungen.join(" | ")} · Entweder getrennte Maschinen – dann helfen Schlagworte – oder ein Doppeleintrag nach Neuinstallation; den alten Host dann stilllegen`,
|
||||
hostId: gruppe[0].id,
|
||||
kundeId: gruppe[0].kundeId,
|
||||
laufzeitId: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
|
||||
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
|
||||
@@ -450,6 +496,7 @@ export const REGELN: Regel[] = [
|
||||
kundenrechnungFehlt,
|
||||
kundenrechnungUnbezahlt,
|
||||
kundeBezahltEinkaufOffen,
|
||||
hostnameDoppelt,
|
||||
];
|
||||
|
||||
/** Wendet alle aktiven Regeln an. */
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
orderBy: { subNextduedate: "asc" },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface HostFilter {
|
||||
kunde?: string;
|
||||
/** Schlagwort, nach dem gefiltert wird (#040). */
|
||||
tag?: string;
|
||||
/** Stillgelegte Hosts mitzeigen (#041). */
|
||||
archiviert?: "ja";
|
||||
status?: string;
|
||||
produkt?: string;
|
||||
ablauf?: "30" | "14" | "abgelaufen";
|
||||
@@ -22,6 +24,9 @@ export const SEITENGROESSE = 25;
|
||||
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
||||
const und: Prisma.HostWhereInput[] = [];
|
||||
|
||||
// Stillgelegte Hosts erscheinen nur, wenn ausdrücklich danach gefragt wird.
|
||||
if (filter.archiviert !== "ja") und.push({ archiviertAm: null });
|
||||
|
||||
if (filter.tag) {
|
||||
und.push({ tags: { some: { name: filter.tag } } });
|
||||
}
|
||||
|
||||
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
||||
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
|
||||
}
|
||||
|
||||
/** Stillgelegte Hosts zählen nirgends mit (#041). */
|
||||
const NICHT_ARCHIVIERT = { archiviertAm: null } as const;
|
||||
|
||||
export async function hostsOhneKunde() {
|
||||
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
|
||||
// noch niemandem, dürfen also auch niemandem gezeigt werden.
|
||||
if (await sichtbarerKunde()) return [];
|
||||
|
||||
return prisma.host.findMany({
|
||||
where: { kundeId: null },
|
||||
where: { kundeId: null, ...NICHT_ARCHIVIERT },
|
||||
orderBy: { erstmalsGesehenAm: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function hostAnzahl() {
|
||||
const kundeId = await sichtbarerKunde();
|
||||
return prisma.host.count({ where: kundeId ? { kundeId } : {} });
|
||||
return prisma.host.count({ where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } });
|
||||
}
|
||||
|
||||
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
const kundeId = await sichtbarerKunde();
|
||||
const hostSicht = kundeId ? { kundeId } : {};
|
||||
const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
|
||||
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
|
||||
|
||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||
prisma.host.count({ where: hostSicht }),
|
||||
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
|
||||
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }),
|
||||
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null, ...NICHT_ARCHIVIERT } }),
|
||||
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||
prisma.host.count({
|
||||
where: {
|
||||
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
|
||||
},
|
||||
}),
|
||||
prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }),
|
||||
prisma.alert.count({
|
||||
where: {
|
||||
erledigtAm: null,
|
||||
OR: [{ hostId: null }, { host: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
||||
@@ -94,7 +102,7 @@ export async function suchIndex() {
|
||||
|
||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: kundeId ? { kundeId } : {},
|
||||
where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||
orderBy: [{ subNextduedate: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
Reference in New Issue
Block a user