Merge branch 'phase/041-doppelte-hosts'

This commit is contained in:
2026-10-03 17:57:18 +02:00
22 changed files with 423 additions and 20 deletions
+8
View File
@@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### 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 ## [0.23.0] – 2026-10-03
### Hinzugefügt ### Hinzugefügt
+6 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 21 h 43 min **Gesamt:** 22 h 33 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -46,6 +46,7 @@
| #038 Überbreite auf dem Handy | 0 h 40 min | | #038 Überbreite auf dem Handy | 0 h 40 min |
| #039 Zugang zur Laufzeit | 0 h 30 min | | #039 Zugang zur Laufzeit | 0 h 30 min |
| #040 Schlagworte sichtbar | 0 h 40 min | | #040 Schlagworte sichtbar | 0 h 40 min |
| #041 Doppelte Hosts | 0 h 50 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -68,7 +69,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.23.0 **Version:** 0.24.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - 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 - 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 - 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 ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #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) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+15
View File
@@ -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`. 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 ## 5. Kunden und Standorte pflegen
+35
View File
@@ -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.
+2 -1
View File
@@ -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 | | [#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 | | [#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 | | [#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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.23.0", "version": "0.24.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -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");
+4
View File
@@ -134,6 +134,10 @@ model Host {
subNextduedate DateTime? @db.Date subNextduedate DateTime? @db.Date
subChecktime DateTime? 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()) erstmalsGesehenAm DateTime @default(now())
letzterReportAm DateTime? letzterReportAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
+32
View File
@@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular"; import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
import { hostTokenWiderrufen } from "@/server/actions/tokens"; import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste"; import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts"; import { meldungsGrenze } from "@/server/queries/hosts";
@@ -108,6 +109,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2> <h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div> <div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null} {meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<div className="mt-2">
<span className="badge pl-bg-neutral">Stillgelegt</span>
</div>
) : null}
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen {/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
Hosts ist das die Stelle, an der man sie braucht (#040). */} 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} kundeId={host.kundeId}
standortId={host.standortId} standortId={host.standortId}
/> />
{/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */}
<div className="mt-3 pt-3 border-top d-flex flex-wrap gap-2">
<form action={hostArchivieren}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
</button>
</form>
{host.laufzeiten.length === 0 ? (
<form action={hostLoeschen}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Endgültig löschen
</button>
</form>
) : null}
</div>
<div className="form-text">
{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."}
</div>
</Card> </Card>
</div> </div>
+5
View File
@@ -69,6 +69,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
q: einzeln(roh.q), q: einzeln(roh.q),
kunde: einzeln(roh.kunde), kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag), tag: einzeln(roh.tag),
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
status: einzeln(roh.status), status: einzeln(roh.status),
produkt: einzeln(roh.produkt), produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
@@ -87,6 +88,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
q: filter.q, q: filter.q,
kunde: filter.kunde, kunde: filter.kunde,
tag: filter.tag, tag: filter.tag,
archiviert: filter.archiviert,
status: filter.status, status: filter.status,
produkt: filter.produkt, produkt: filter.produkt,
ablauf: filter.ablauf, ablauf: filter.ablauf,
@@ -154,6 +156,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {host.meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<span className="badge pl-bg-neutral ms-2">Stillgelegt</span>
) : null}
{host.notizen ? ( {host.notizen ? (
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040). // Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
<i <i
+15 -1
View File
@@ -28,7 +28,7 @@ export function HostFilterLeiste({
router.push(`${pathname}?${neu.toString()}`); router.push(`${pathname}?${neu.toString()}`);
} }
const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "q"].filter( const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "archiviert", "q"].filter(
(schluessel) => werte[schluessel], (schluessel) => werte[schluessel],
).length; ).length;
@@ -158,6 +158,20 @@ export function HostFilterLeiste({
nur mit offenen Rechnungspunkten nur mit offenen Rechnungspunkten
</label> </label>
</div> </div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="archiviert"
checked={werte.archiviert === "ja"}
onChange={(e) => setzen("archiviert", e.target.checked ? "ja" : "")}
/>
<label className="form-check-label small" htmlFor="archiviert">
stillgelegte Hosts zeigen
</label>
</div>
{aktiveFilter > 0 ? ( {aktiveFilter > 0 ? (
<Link href="/hosts" className="small"> <Link href="/hosts" className="small">
Filter zurücksetzen ({aktiveFilter}) Filter zurücksetzen ({aktiveFilter})
+7 -1
View File
@@ -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. * Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
*/ */
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> { export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
let kopfzeilen: Headers;
try {
const { headers } = await import("next/headers"); const { headers } = await import("next/headers");
const kopfzeilen = await 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. // 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); const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
+60
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
revalidatePath("/kunden"); revalidatePath("/kunden");
revalidatePath("/"); 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"));
}
+4 -1
View File
@@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> { export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([ const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
where: { archiviertAm: null },
include: { include: {
kunde: { select: { name: true } }, kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } }, serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
}, },
}), }),
prisma.laufzeit.findMany({ prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" }, where: { status: "BESTAETIGT", host: { archiviertAm: null } },
include: { include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } }, host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
proxmoxPosition: { include: { rechnung: true } }, proxmoxPosition: { include: { rechnung: true } },
@@ -40,6 +42,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
subLizenzierteSockets: host.subLizenzierteSockets, subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets, sockets: host.sockets,
letzterReportAm: host.letzterReportAm, letzterReportAm: host.letzterReportAm,
ips: host.ips,
serverIds: host.serverIdHistorie, serverIds: host.serverIdHistorie,
})), })),
laufzeiten: laufzeiten.map((laufzeit) => ({ laufzeiten: laufzeiten.map((laufzeit) => ({
+47
View File
@@ -32,6 +32,7 @@ export interface HostDaten {
subLizenzierteSockets: number | null; subLizenzierteSockets: number | null;
sockets: number | null; sockets: number | null;
letzterReportAm: Date | null; letzterReportAm: Date | null;
ips: string[];
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[]; 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 * 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 * 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, kundenrechnungFehlt,
kundenrechnungUnbezahlt, kundenrechnungUnbezahlt,
kundeBezahltEinkaufOffen, kundeBezahltEinkaufOffen,
hostnameDoppelt,
]; ];
/** Wendet alle aktiven Regeln an. */ /** Wendet alle aktiven Regeln an. */
+1 -1
View File
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
const einkaufSichtbar = await darfEinkaufSehen(); const einkaufSichtbar = await darfEinkaufSehen();
const hosts = await prisma.host.findMany({ 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" }, orderBy: { subNextduedate: "asc" },
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
+5
View File
@@ -8,6 +8,8 @@ export interface HostFilter {
kunde?: string; kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */ /** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string; tag?: string;
/** Stillgelegte Hosts mitzeigen (#041). */
archiviert?: "ja";
status?: string; status?: string;
produkt?: string; produkt?: string;
ablauf?: "30" | "14" | "abgelaufen"; ablauf?: "30" | "14" | "abgelaufen";
@@ -22,6 +24,9 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput { function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: 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) { if (filter.tag) {
und.push({ tags: { some: { name: filter.tag } } }); und.push({ tags: { some: { name: filter.tag } } });
} }
+14 -6
View File
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000); 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() { export async function hostsOhneKunde() {
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition // Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
// noch niemandem, dürfen also auch niemandem gezeigt werden. // noch niemandem, dürfen also auch niemandem gezeigt werden.
if (await sichtbarerKunde()) return []; if (await sichtbarerKunde()) return [];
return prisma.host.findMany({ return prisma.host.findMany({
where: { kundeId: null }, where: { kundeId: null, ...NICHT_ARCHIVIERT },
orderBy: { erstmalsGesehenAm: "desc" }, orderBy: { erstmalsGesehenAm: "desc" },
}); });
} }
export async function hostAnzahl() { export async function hostAnzahl() {
const kundeId = await sichtbarerKunde(); 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. */ /** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
export async function kennzahlen(jetzt: Date = new Date()) { export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000); const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const kundeId = await sichtbarerKunde(); const kundeId = await sichtbarerKunde();
const hostSicht = kundeId ? { kundeId } : {}; const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
const laufzeitSicht = kundeId ? { host: { kundeId } } : {}; const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([ const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
prisma.host.count({ where: hostSicht }), prisma.host.count({ where: hostSicht }),
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }), 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: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
prisma.host.count({ prisma.host.count({
where: { where: {
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }], 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([ const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
@@ -94,7 +102,7 @@ export async function suchIndex() {
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([ const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
where: kundeId ? { kundeId } : {}, where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
select: { select: {
id: true, id: true,
hostname: true, hostname: true,
+1 -1
View File
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde(); const nurKunde = await sichtbarerKunde();
const hosts = await prisma.host.findMany({ 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" }], orderBy: [{ subNextduedate: "asc" }],
select: { select: {
id: true, id: true,
+1 -1
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany(); const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(12); expect(einstellungen).toHaveLength(13);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
}); });
+97
View File
@@ -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<string, string>) {
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);
});
});
+57 -3
View File
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
subLizenzierteSockets: 1, subLizenzierteSockets: 1,
sockets: 1, sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000), letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
ips: ["10.0.0.10"],
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }], serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen, ...überschreibungen,
}; };
@@ -72,9 +73,9 @@ function ausRegel(k: RegelKontext, name: string) {
} }
describe("Warnregeln", () => { describe("Warnregeln", () => {
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => { it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(12); expect(REGELN).toHaveLength(13);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12); expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
}); });
describe("Subscription läuft ab", () => { describe("Subscription läuft ab", () => {
@@ -414,4 +415,57 @@ describe("Warnregeln", () => {
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0); 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");
});
});
}); });