From 3657b60d16a82c6dba511f8270132a169dd901da Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 3 Oct 2026 17:57:18 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 8 ++ DEVLOG.md | 8 +- docs/anleitung/README.md | 15 +++ docs/issues/041-doppelte-hosts.md | 35 +++++++ docs/issues/README.md | 3 +- package.json | 2 +- .../migration.sql | 5 + prisma/schema.prisma | 4 + src/app/(dashboard)/hosts/[id]/page.tsx | 32 ++++++ src/app/(dashboard)/hosts/page.tsx | 5 + src/components/hosts/HostFilterLeiste.tsx | 16 ++- src/lib/adresse.ts | 10 +- src/server/actions/hosts.ts | 60 ++++++++++++ src/server/alerts/abgleich.ts | 5 +- src/server/alerts/regeln.ts | 47 +++++++++ src/server/queries/dashboard.ts | 2 +- src/server/queries/hostliste.ts | 5 + src/server/queries/hosts.ts | 20 ++-- src/server/queries/jahresbahn.ts | 2 +- tests/alerts-abgleich.test.ts | 2 +- tests/host-stilllegen.test.ts | 97 +++++++++++++++++++ tests/warnregeln.test.ts | 60 +++++++++++- 22 files changed, 423 insertions(+), 20 deletions(-) create mode 100644 docs/issues/041-doppelte-hosts.md create mode 100644 prisma/migrations/20261003180000_host_archivieren/migration.sql create mode 100644 tests/host-stilllegen.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96aaa8e..87c71f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.24.0] – 2026-10-03 + +### Hinzugefügt +- Dreizehnte Warnregel: Mehrere Hosts desselben Kunden tragen denselben Namen – mit IP-Adressen zum Unterscheiden (#041) +- Hosts lassen sich **stilllegen**: Sie verschwinden aus Listen, Zahlen, Suche und Warnungen, ihre Laufzeiten und Rechnungen bleiben erhalten (#041) +- Endgültiges Löschen für Hosts, an denen keine Laufzeit hängt (#041) +- Schalter „stillgelegte Hosts zeigen“ in der Host-Liste (#041) + ## [0.23.0] – 2026-10-03 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 8a49fae..74592b6 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 21 h 43 min +**Gesamt:** 22 h 33 min | Issue | Zeit | |---|---| @@ -46,6 +46,7 @@ | #038 Überbreite auf dem Handy | 0 h 40 min | | #039 Zugang zur Laufzeit | 0 h 30 min | | #040 Schlagworte sichtbar | 0 h 40 min | +| #041 Doppelte Hosts | 0 h 50 min | | Meilenstein | Zeit | |---|---| @@ -68,7 +69,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.23.0 +**Version:** 0.24.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -208,6 +209,9 @@ - Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts - Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten +- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden +- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt + ### Nächster Schritt - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index d5e3221..8815b0c 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -205,6 +205,21 @@ gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tag Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`. +### Wenn ein Host doppelt erscheint + +Steht derselbe Name zweimal beim selben Kunden, meldet ProxLedger das als Hinweis und nennt die +IP-Adressen beider Einträge. Zwei Fälle sind möglich: + +- **Zwei verschiedene Maschinen** heißen gleich. Dann helfen Schlagworte beim Auseinanderhalten. +- **Dieselbe Maschine hat sich neu registriert** – etwa nach einer Neuinstallation oder weil ein + Container geklont wurde. Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am + Namen; ändert sie sich, entsteht ein zweiter Eintrag. + +Den alten Eintrag legst du im Host-Detail still. Er verschwindet dann aus Listen, Zahlen und +Warnungen, seine Laufzeiten und Rechnungen bleiben aber erhalten – mit dem Schalter *stillgelegte +Hosts zeigen* findest du ihn wieder. **Endgültig löschen** geht nur, solange keine Laufzeit daran +hängt. + --- ## 5. Kunden und Standorte pflegen diff --git a/docs/issues/041-doppelte-hosts.md b/docs/issues/041-doppelte-hosts.md new file mode 100644 index 0000000..8105a99 --- /dev/null +++ b/docs/issues/041-doppelte-hosts.md @@ -0,0 +1,35 @@ +# #041 Gleichnamige Hosts erkennen und stilllegen +**Status:** erledigt +**Meilenstein:** v0.24.0 +**Labels:** backend, ui, alerts +**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min + +## Auslöser +Florian markierte in der Host-Liste zwei Paare: `hv-pve-01` und `hv-pve-02` standen jeweils **zweimal +beim selben Kunden** – einmal „Aktiv“ mit einer IP, einmal „Keine Subscription“ mit drei anderen. + +## Was dahintersteckt +Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am Namen. Ändert sich diese Kennung +– Neuinstallation, geklonter Container, zurückgespielte Sicherung –, meldet sich dieselbe Maschine als +**neuer** Host, und der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind +aber genauso möglich. Welcher Fall vorliegt, weiß nur der Betreiber. + +## Was fehlte +1. Kein Hinweis auf den Doppeleintrag. +2. **Keine Möglichkeit, einen Host loszuwerden.** Ein Eintrag, der nicht mehr existiert, blieb für + immer in Listen, Zahlen und Warnungen stehen. + +## Behebung +- **Dreizehnte Warnregel `hostname-doppelt`:** meldet mehrere Hosts desselben Kunden mit gleichem + Namen und nennt die IP-Adressen zum Unterscheiden. Gleiche Namen bei **verschiedenen** Kunden sind + der Normalfall und bleiben still. +- **Stilllegen** (`archiviertAm`): Der Host verschwindet aus Listen, Zahlen, Suche und Warnungen; + seine offenen Warnungen werden geschlossen. Laufzeiten und Rechnungen bleiben erhalten. +- **Wieder aufnehmen** mit demselben Knopf. +- **Endgültig löschen** nur, solange keine Laufzeit am Host hängt – Buchhaltung wirft man nicht weg. +- In der Liste ein Schalter „stillgelegte Hosts zeigen“ und ein Abzeichen an der Zeile. + +## Entscheidung +Stilllegen statt automatisch zusammenführen. Ein Zusammenführen müsste raten, welcher Eintrag welche +Rechnung erbt – im Zweifel zulasten der Buchhaltung. Die Warnung nennt beide Hosts mit IPs; die +Entscheidung trifft der Mensch. diff --git a/docs/issues/README.md b/docs/issues/README.md index 7a30946..18de0b4 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -44,4 +44,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min | | [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min | | [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min | -| | **Summe** | | | **165 h** | **21 h 43 min** | +| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min | +| | **Summe** | | | **168 h** | **22 h 33 min** | diff --git a/package.json b/package.json index af650d7..74806be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.23.0", + "version": "0.24.0", "private": true, "scripts": { "dev": "next dev", diff --git a/prisma/migrations/20261003180000_host_archivieren/migration.sql b/prisma/migrations/20261003180000_host_archivieren/migration.sql new file mode 100644 index 0000000..4a0c9d6 --- /dev/null +++ b/prisma/migrations/20261003180000_host_archivieren/migration.sql @@ -0,0 +1,5 @@ +-- Hosts stilllegen, statt sie zu löschen (#041). +-- Ein stillgelegter Host verschwindet aus Listen, Zahlen und Warnungen; seine Laufzeiten und +-- Rechnungen bleiben erhalten, weil sie zur Buchhaltung gehören. +ALTER TABLE "Host" ADD COLUMN "archiviertAm" TIMESTAMP(3); +CREATE INDEX "Host_archiviertAm_idx" ON "Host"("archiviertAm"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d4c7149..5dd09ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,10 @@ model Host { subNextduedate DateTime? @db.Date subChecktime DateTime? + /// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine + /// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041). + archiviertAm DateTime? + erstmalsGesehenAm DateTime @default(now()) letzterReportAm DateTime? erstelltAm DateTime @default(now()) diff --git a/src/app/(dashboard)/hosts/[id]/page.tsx b/src/app/(dashboard)/hosts/[id]/page.tsx index fceef16..f0892ef 100644 --- a/src/app/(dashboard)/hosts/[id]/page.tsx +++ b/src/app/(dashboard)/hosts/[id]/page.tsx @@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige"; import { prisma } from "@/lib/db"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular"; +import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts"; import { hostTokenWiderrufen } from "@/server/actions/tokens"; import { hostMitDetails } from "@/server/queries/hostliste"; import { meldungsGrenze } from "@/server/queries/hosts"; @@ -108,6 +109,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id

{host.hostname}

{host.fqdn ?? "Kein FQDN gemeldet"}
{meldetNicht ? : null} + {host.archiviertAm ? ( +
+ Stillgelegt +
+ ) : null} {/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen Hosts ist das die Stelle, an der man sie braucht (#040). */} @@ -198,6 +204,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id kundeId={host.kundeId} standortId={host.standortId} /> + + {/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */} +
+
+ + +
+ + {host.laufzeiten.length === 0 ? ( +
+ + +
+ ) : null} +
+
+ {host.archiviertAm + ? `Stillgelegt am ${formatDatum(host.archiviertAm)} – erscheint nicht in Listen, Zahlen und Warnungen.` + : host.laufzeiten.length === 0 + ? "Stilllegen blendet den Host aus; löschen geht nur, solange keine Laufzeit daran hängt." + : "An diesem Host hängen Laufzeiten – er lässt sich nur stilllegen, nicht löschen."} +
diff --git a/src/app/(dashboard)/hosts/page.tsx b/src/app/(dashboard)/hosts/page.tsx index 02b82c6..415a76c 100644 --- a/src/app/(dashboard)/hosts/page.tsx +++ b/src/app/(dashboard)/hosts/page.tsx @@ -69,6 +69,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP q: einzeln(roh.q), kunde: einzeln(roh.kunde), tag: einzeln(roh.tag), + archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"], status: einzeln(roh.status), produkt: einzeln(roh.produkt), ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], @@ -87,6 +88,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP q: filter.q, kunde: filter.kunde, tag: filter.tag, + archiviert: filter.archiviert, status: filter.status, produkt: filter.produkt, ablauf: filter.ablauf, @@ -154,6 +156,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP {host.hostname} {host.meldetNicht ? : null} + {host.archiviertAm ? ( + Stillgelegt + ) : null} {host.notizen ? ( // Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040). werte[schluessel], ).length; @@ -158,6 +158,20 @@ export function HostFilterLeiste({ nur mit offenen Rechnungspunkten + +
+ setzen("archiviert", e.target.checked ? "ja" : "")} + /> + +
+ {aktiveFilter > 0 ? ( Filter zurücksetzen ({aktiveFilter}) diff --git a/src/lib/adresse.ts b/src/lib/adresse.ts index 56bb3eb..c526197 100644 --- a/src/lib/adresse.ts +++ b/src/lib/adresse.ts @@ -55,8 +55,14 @@ export function eigeneAdresse(request: Request, pfad: string, eingetragen?: stri * Containername); im Browser landet man damit auf einer Seite, die es nicht gibt. */ export async function eigenerPfad(pfad: string, eingetragen?: string): Promise { - const { headers } = await import("next/headers"); - const kopfzeilen = await headers(); + let kopfzeilen: Headers; + try { + const { headers } = await import("next/headers"); + kopfzeilen = await headers(); + } catch { + // Außerhalb einer Anfrage (Tests, Skripte) gibt es keine Kopfzeilen – dann bleibt der Pfad relativ. + return pfad; + } // Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt. const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen); diff --git a/src/server/actions/hosts.ts b/src/server/actions/hosts.ts index d9b5b02..4bbe5b6 100644 --- a/src/server/actions/hosts.ts +++ b/src/server/actions/hosts.ts @@ -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 { + 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")); +} diff --git a/src/server/alerts/abgleich.ts b/src/server/alerts/abgleich.ts index 58c587e..2378d98 100644 --- a/src/server/alerts/abgleich.ts +++ b/src/server/alerts/abgleich.ts @@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from export async function kontextLaden(jetzt: Date = new Date()): Promise { 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 ({ diff --git a/src/server/alerts/regeln.ts b/src/server/alerts/regeln.ts index a66704f..a48c193 100644 --- a/src/server/alerts/regeln.ts +++ b/src/server/alerts/regeln.ts @@ -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(); + 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. */ diff --git a/src/server/queries/dashboard.ts b/src/server/queries/dashboard.ts index 3a591d4..e1bac05 100644 --- a/src/server/queries/dashboard.ts +++ b/src/server/queries/dashboard.ts @@ -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 } }, diff --git a/src/server/queries/hostliste.ts b/src/server/queries/hostliste.ts index 76a2ae2..90f0098 100644 --- a/src/server/queries/hostliste.ts +++ b/src/server/queries/hostliste.ts @@ -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 } } }); } diff --git a/src/server/queries/hosts.ts b/src/server/queries/hosts.ts index 37be262..3d69633 100644 --- a/src/server/queries/hosts.ts +++ b/src/server/queries/hosts.ts @@ -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, diff --git a/src/server/queries/jahresbahn.ts b/src/server/queries/jahresbahn.ts index 007e5c7..62abc04 100644 --- a/src/server/queries/jahresbahn.ts +++ b/src/server/queries/jahresbahn.ts @@ -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, diff --git a/tests/alerts-abgleich.test.ts b/tests/alerts-abgleich.test.ts index 7fdfbaa..ba3f81c 100644 --- a/tests/alerts-abgleich.test.ts +++ b/tests/alerts-abgleich.test.ts @@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => { const einstellungen = await testPrisma.warnregelEinstellung.findMany(); - expect(einstellungen).toHaveLength(12); + expect(einstellungen).toHaveLength(13); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); }); diff --git a/tests/host-stilllegen.test.ts b/tests/host-stilllegen.test.ts new file mode 100644 index 0000000..cef1025 --- /dev/null +++ b/tests/host-stilllegen.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** Hosts stilllegen statt löschen – und löschen nur, wenn nichts daran hängt (#041). */ +vi.mock("@/server/auth/sitzungspflicht", () => ({ + sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }), + sichtbarerKunde: async () => null, + darfEinkaufSehen: async () => true, + angemeldet: async () => null, +})); +vi.mock("next/cache", () => ({ revalidatePath: () => {} })); +vi.mock("next/navigation", () => ({ redirect: () => {} })); + +const { hostArchivieren, hostLoeschen } = await import("@/server/actions/hosts"); +const { hostListe } = await import("@/server/queries/hostliste"); + +function formular(werte: Record) { + const daten = new FormData(); + for (const [name, wert] of Object.entries(werte)) daten.set(name, wert); + return daten; +} + +async function host(hostname = "hv-pve-01") { + return testPrisma.host.create({ data: { agentId: `a-${hostname}`, hostname, produkt: "PVE" } }); +} + +describe.runIf(datenbankVerfuegbar)("Hosts stilllegen", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + afterAll(async () => { + await testPrisma.$disconnect(); + }); + + it("nimmt einen stillgelegten Host aus der Liste", async () => { + const h = await host(); + expect((await hostListe({})).gesamt).toBe(1); + + await hostArchivieren(formular({ hostId: h.id })); + + expect((await hostListe({})).gesamt).toBe(0); + expect((await hostListe({ archiviert: "ja" })).gesamt).toBe(1); + }); + + it("schließt beim Stilllegen die offenen Warnungen", async () => { + const h = await host(); + await testPrisma.alert.create({ + data: { + regel: "status-nicht-aktiv", + fingerprint: `status-nicht-aktiv:${h.id}`, + schwere: "WARNUNG", + titel: "Keine Subscription gefunden", + beschreibung: "…", + hostId: h.id, + }, + }); + + await hostArchivieren(formular({ hostId: h.id })); + + const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: h.id } }); + expect(alert.erledigtAm).not.toBeNull(); + }); + + it("nimmt einen Host auf Wunsch wieder auf", async () => { + const h = await host(); + await hostArchivieren(formular({ hostId: h.id })); + await hostArchivieren(formular({ hostId: h.id })); + + expect((await hostListe({})).gesamt).toBe(1); + }); + + it("löscht einen Host ohne Laufzeiten", async () => { + const h = await host(); + await hostLoeschen(formular({ hostId: h.id })); + + expect(await testPrisma.host.count()).toBe(0); + }); + + it("löscht keinen Host, an dem eine Laufzeit hängt", async () => { + const h = await host(); + await testPrisma.laufzeit.create({ + data: { + hostId: h.id, + key: "pve1c-aaaa", + start: new Date("2026-01-01"), + ende: new Date("2026-12-31"), + status: "BESTAETIGT", + }, + }); + + await hostLoeschen(formular({ hostId: h.id })); + + // Buchhaltung bleibt: Der Host ist noch da und lässt sich nur stilllegen. + expect(await testPrisma.host.count()).toBe(1); + }); +}); diff --git a/tests/warnregeln.test.ts b/tests/warnregeln.test.ts index 98bc859..39b145a 100644 --- a/tests/warnregeln.test.ts +++ b/tests/warnregeln.test.ts @@ -28,6 +28,7 @@ function host(überschreibungen: Partial = {}): HostDaten { subLizenzierteSockets: 1, sockets: 1, letzterReportAm: new Date(JETZT.getTime() - 3_600_000), + ips: ["10.0.0.10"], serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }], ...überschreibungen, }; @@ -72,9 +73,9 @@ function ausRegel(k: RegelKontext, name: string) { } describe("Warnregeln", () => { - it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => { - expect(REGELN).toHaveLength(12); - expect(new Set(REGELN.map((r) => r.name)).size).toBe(12); + it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => { + expect(REGELN).toHaveLength(13); + expect(new Set(REGELN.map((r) => r.name)).size).toBe(13); }); describe("Subscription läuft ab", () => { @@ -414,4 +415,57 @@ describe("Warnregeln", () => { expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0); }); }); + + describe("Gleicher Hostname beim selben Kunden", () => { + it("meldet zwei gleichnamige Hosts desselben Kunden – mit IPs zum Unterscheiden", () => { + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "hv-pve-01", ips: ["10.10.5.32"] }), + host({ id: "h2", hostname: "hv-pve-01", ips: ["10.10.12.33", "10.10.5.11"] }), + ], + }); + + const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt"); + expect(treffer).toHaveLength(1); + expect(treffer[0].titel).toBe("2 Hosts heißen „hv-pve-01“"); + expect(treffer[0].beschreibung).toContain("10.10.5.32"); + expect(treffer[0].beschreibung).toContain("10.10.12.33"); + }); + + it("schweigt bei gleichem Namen in verschiedenen Kundennetzen", () => { + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "hv-pve-01", kundeId: "k1" }), + host({ id: "h2", hostname: "hv-pve-01", kundeId: "k2" }), + ], + }); + + expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0); + }); + + it("schweigt bei Hosts ohne Kunde", () => { + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "hv-pve-01", kundeId: null, kundeName: null }), + host({ id: "h2", hostname: "hv-pve-01", kundeId: null, kundeName: null }), + ], + }); + + expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0); + }); + + it("erzeugt für drei gleichnamige Hosts nur eine Warnung", () => { + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "hv-pve-01" }), + host({ id: "h2", hostname: "hv-pve-01" }), + host({ id: "h3", hostname: "HV-PVE-01" }), + ], + }); + + const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt"); + expect(treffer).toHaveLength(1); + expect(treffer[0].titel).toContain("3 Hosts"); + }); + }); });