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"); + }); + }); });