feat(alerts): zehn Warnregeln mit Worker, Liste und Einstellungen (#011)

Regeln als reine Funktionen, Abgleich oeffnet und schliesst Warnungen anhand
eines Fingerabdrucks. Worker wertet stuendlich aus, zusaetzlich nach jedem
Report. 123 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 22:25:33 +02:00
co-authored by Claude Opus 5
parent 5018589f08
commit 8a299f6c5e
25 changed files with 1638 additions and 21 deletions
+3
View File
@@ -12,6 +12,9 @@ ADMIN_PASSWORD="bitte-aendern"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000" APP_URL="http://localhost:3000"
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
WORKER_CRON="0 * * * *"
# Ablage für hochgeladene Rechnungs-PDFs # Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads" UPLOAD_DIR="./data/uploads"
+11
View File
@@ -17,6 +17,17 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.7.0] – 2026-09-17
### Hinzugefügt
- Zehn Warnregeln: Ablauf in 30/14/7 Tagen, Status nicht aktiv, Socket-Abweichung, keine Meldung seit 48 Stunden, geänderte Server-ID, mehrfach gemeldeter Key, Host ohne Kunde, fällige Proxmox-Rechnung, fehlende und überfällige Kundenrechnung (#011)
- Worker-Prozess (`npm run worker`), der die Regeln stündlich auswertet; zusätzlich Auswertung nach jedem eingehenden Report (#011)
- Seite „Warnungen“ mit Filter offen/erledigt, Quittieren, Schließen und sofortiger Prüfung (#011)
- Einstellungsseite für Regeln und Schwellwerte (#011)
### Geändert
- Warnungen schließen sich automatisch, sobald die Ursache behoben ist; Titel und Beschreibung werden laufend nachgeführt (#011)
## [0.6.0] – 2026-09-17 ## [0.6.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+8 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 3 h 58 min **Gesamt:** 4 h 28 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -17,6 +17,7 @@
| #008 Laufzeiten und Rechnungen | 0 h 35 min | | #008 Laufzeiten und Rechnungen | 0 h 35 min |
| #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min | | #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min |
| #010 Export CSV und PDF | 0 h 20 min | | #010 Export CSV und PDF | 0 h 20 min |
| #011 Warnregeln | 0 h 30 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -28,12 +29,13 @@
| v0.4.2 | 0 h 10 min | **abgeschlossen** | v0.4.2 | 0 h 10 min | **abgeschlossen**
| v0.5.0 | 0 h 35 min | **abgeschlossen** | v0.5.0 | 0 h 35 min | **abgeschlossen**
| v0.6.0 | 0 h 35 min | **abgeschlossen** | v0.6.0 | 0 h 35 min | **abgeschlossen**
| v0.7.0 | 0 h 30 min | **abgeschlossen**
--- ---
## 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.6.0 **Version:** 0.7.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -62,8 +64,11 @@
- #009 abgeschlossen: Jahresbahn und Ablauf-Kalender (90 Tage) aus der Datenbank, dazu eine Karte für bereits abgelaufene Subscriptions; die Oberfläche nutzt keine Beispieldaten mehr - #009 abgeschlossen: Jahresbahn und Ablauf-Kalender (90 Tage) aus der Datenbank, dazu eine Karte für bereits abgelaufene Subscriptions; die Oberfläche nutzt keine Beispieldaten mehr
- #010 abgeschlossen: CSV- und PDF-Export für Hosts, Kunden, einzelne Kundenunterlage und Rechnungsliste; PDFs serverseitig ohne Browser - #010 abgeschlossen: CSV- und PDF-Export für Hosts, Kunden, einzelne Kundenunterlage und Rechnungsliste; PDFs serverseitig ohne Browser
- Meilenstein v0.6.0 abgeschlossen - Meilenstein v0.6.0 abgeschlossen
- #011 abgeschlossen: zehn Warnregeln als reine Funktionen, Abgleich mit offenen Warnungen, Worker mit stündlichem Zeitplan, Auswertung nach jedem Report, Warnungsliste und Einstellungsseite; 123 Tests
- Meilenstein v0.7.0 abgeschlossen
### Entscheidungen ### Entscheidungen
- **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation.
- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. - **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig.
- **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal. - **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal.
- **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig).
@@ -90,4 +95,4 @@
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt ### Nächster Schritt
- #011 Warnregeln mit Worker und konfigurierbaren Schwellwerten - #012 Benachrichtigungen: SMTP über Microsoft 365, Webhook, Zusammenfassung, keine Doppelten
+7
View File
@@ -22,6 +22,13 @@ npm run typecheck
npm test # legt bei Bedarf die Testdatenbank an npm test # legt bei Bedarf die Testdatenbank an
``` ```
Der Worker wertet die Warnregeln stündlich aus und läuft als eigener Prozess:
```bash
npm run worker # Dauerbetrieb
npm run alerts:check # einmalige Auswertung
```
Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen). Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen).
## Agent auf einem Proxmox-Host installieren ## Agent auf einem Proxmox-Host installieren
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+21 -11
View File
@@ -1,26 +1,36 @@
# #011 Warnregeln # #011 Warnregeln
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.7.0 **Meilenstein:** v0.7.0
**Labels:** backend, alerts **Labels:** backend, alerts
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
Konfigurierbare Warnregeln, die Alerts öffnen und automatisch schließen. Konfigurierbare Warnregeln, die Alerts öffnen und automatisch schließen.
## Aufgaben ## Aufgaben
- [ ] Regel-Engine: reine Funktionen, Fingerprint, Abgleich offen/neu/erledigt - [x] Regel-Engine: reine Funktionen, Fingerprint, Abgleich offen/neu/erledigt
- [ ] Regeln: Ablauf 30/14/7, Status ≠ active, Sockets-Abweichung, keine Meldung 48 h, Server-ID geändert, Key mehrfach, Host ohne Kunde, Proxmox-Rechnung fällig, Kundenrechnung fehlt, Kundenrechnung unbezahlt nach X Tagen - [x] Regeln: Ablauf 30/14/7, Status ≠ active, Sockets-Abweichung, keine Meldung 48 h, Server-ID geändert, Key mehrfach, Host ohne Kunde, Proxmox-Rechnung fällig, Kundenrechnung fehlt, Kundenrechnung unbezahlt nach X Tagen
- [ ] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report - [x] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report
- [ ] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern - [x] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern
- [ ] Warnglocke und Alert-Liste (offen/erledigt, quittieren) - [x] Warnglocke und Alert-Liste (offen/erledigt, quittieren)
- [ ] Tests für jede Regel inkl. Grenzfälle - [x] Tests für jede Regel inkl. Grenzfälle
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Kein doppelter offener Alert für denselben Sachverhalt - [x] Kein doppelter offener Alert für denselben Sachverhalt
- [ ] Alerts schließen sich automatisch, wenn die Ursache behoben ist - [x] Alerts schließen sich automatisch, wenn die Ursache behoben ist
- [ ] Alle Regel-Tests grün - [x] Alle Regel-Tests grün
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Die Regeln sind reine Funktionen ohne Datenbankzugriff (`src/server/alerts/regeln.ts`); der Abgleich
(`abgleich.ts`) öffnet, aktualisiert und schließt die Warnungen. Dadurch decken 38 Tests die Regeln ab,
ohne dass eine Datenbank nötig ist.
- **Stufen als Teil des Fingerabdrucks:** `subscription-laeuft-ab:<host>:7`. Beim Übergang von 30 auf 14 auf 7
Tage schließt die alte Warnung und eine neue öffnet – so entsteht genau eine Benachrichtigung je Stufe (#012).
- Ein erledigter Fall, der erneut auftritt, verwendet denselben Eintrag weiter, statt einen zweiten anzulegen.
- Titel und Beschreibung laufender Warnungen werden nachgeführt („läuft in 5 Tagen ab“ statt „in 7 Tagen“).
- Ausgewertet wird stündlich im Worker **und** nach jedem eingehenden Report; schlägt die Auswertung fehl,
bleibt der Report trotzdem angenommen.
- Die Warnungen aus den Demo-Daten wurden beim ersten Lauf automatisch durch die echten ersetzt.
+3 -3
View File
@@ -14,8 +14,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min | | [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min |
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min | | [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min |
| [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min | | [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min |
| [#011](011-warnregeln.md) | Warnregeln | in Arbeit | v0.7.0 | 8 h | – | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | in Arbeit | v0.8.0 | 6 h | – |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – |
| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
@@ -24,4 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| | **Summe** | | | **114 h** | **3 h 58 min** | | | **Summe** | | | **114 h** | **4 h 28 min** |
+20
View File
@@ -16,6 +16,7 @@
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
@@ -4788,6 +4789,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/croner": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
"integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==",
"funding": [
{
"type": "other",
"url": "https://paypal.me/hexagonpp"
},
{
"type": "github",
"url": "https://github.com/sponsors/hexagon"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -18,7 +18,9 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"issues:sync": "tsx scripts/gitea-issues.ts" "issues:sync": "tsx scripts/gitea-issues.ts",
"worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -28,6 +30,7 @@
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
+25
View File
@@ -0,0 +1,25 @@
import "dotenv/config";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
/** Einmalige Auswertung der Warnregeln – praktisch zum Prüfen ohne laufenden Worker. */
async function main() {
await warnregelnSicherstellen();
const ergebnis = await alertsAbgleichen();
console.log(
`${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert`,
);
for (const alert of ergebnis.geoeffnet) {
console.log(` + ${alert.schwere.padEnd(8)} ${alert.titel} – ${alert.beschreibung}`);
}
for (const fingerprint of ergebnis.erledigt) {
console.log(` - erledigt: ${fingerprint}`);
}
}
main()
.then(() => process.exit(0))
.catch((fehler) => {
console.error(fehler);
process.exit(1);
});
@@ -0,0 +1,57 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { WarnregelFormular, type RegelAnzeige } from "@/components/alerts/WarnregelFormular";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
export const metadata = { title: "Warnregeln" };
const BESCHRIFTUNGEN: Record<string, string> = {
tage: "Tage (mehrere Stufen mit Komma)",
stunden: "Stunden",
};
export default async function WarnregelnSeite() {
await warnregelnSicherstellen();
const gespeichert = await prisma.warnregelEinstellung.findMany();
const nachName = new Map(gespeichert.map((eintrag) => [eintrag.regel, eintrag]));
const regeln: RegelAnzeige[] = REGELN.map((regel) => {
const eintrag = nachName.get(regel.name);
const werte = (eintrag?.schwellwerte ?? regel.standardSchwellwerte) as Record<string, unknown>;
return {
name: regel.name,
beschreibung: regel.beschreibung,
aktiv: eintrag?.aktiv ?? true,
schwellwerte: Object.keys(regel.standardSchwellwerte).map((schluessel) => ({
name: schluessel,
beschriftung: BESCHRIFTUNGEN[schluessel] ?? schluessel,
wert: Array.isArray(werte[schluessel])
? (werte[schluessel] as number[]).join(", ")
: String(werte[schluessel] ?? ""),
})),
};
});
return (
<>
<ContentHeader titel="Warnregeln" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Warnregeln" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Regeln und Schwellwerte" rahmen="primary">
<p className="text-body-secondary">
Die Regeln laufen stündlich im Worker und zusätzlich nach jedem eingehenden Report. Eine Warnung
wird nur einmal geöffnet und schließt sich selbst, sobald die Ursache behoben ist. Offene
Warnungen stehen unter <Link href="/warnungen">Warnungen</Link>.
</p>
<WarnregelFormular regeln={regeln} />
</Card>
</div>
</div>
</>
);
}
+1
View File
@@ -30,6 +30,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<Sidebar <Sidebar
hostsAnzahl={zahlen.hostsGesamt} hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte} offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts}
benutzer={BENUTZER} benutzer={BENUTZER}
/> />
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { PruefenKnopf } from "@/components/alerts/PruefenKnopf";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import { alertQuittieren, alertSchliessen } from "@/server/actions/alerts";
export const metadata = { title: "Warnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
const SCHWERE_FARBE = { GEFAHR: "danger", WARNUNG: "warning", INFO: "info" } as const;
export default async function WarnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const status = (Array.isArray(roh.status) ? roh.status[0] : roh.status) ?? "offen";
const [alerts, zahlen] = await Promise.all([
prisma.alert.findMany({
where: status === "erledigt" ? { erledigtAm: { not: null } } : { erledigtAm: null },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: 200,
include: {
host: { select: { id: true, hostname: true } },
kunde: { select: { id: true, name: true } },
laufzeit: { select: { id: true } },
},
}),
prisma.alert.groupBy({
by: ["schwere"],
where: { erledigtAm: null },
_count: { _all: true },
}),
]);
const anzahl = (schwere: string) => zahlen.find((z) => z.schwere === schwere)?._count._all ?? 0;
return (
<>
<ContentHeader titel="Warnungen" brotkrumen={[{ titel: "Warnungen" }]} aktion={<PruefenKnopf />} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Gefahr"
wert={String(anzahl("GEFAHR"))}
icon="bi-exclamation-octagon"
farbe="danger"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Warnung"
wert={String(anzahl("WARNUNG"))}
icon="bi-exclamation-triangle"
farbe="warning"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Hinweis" wert={String(anzahl("INFO"))} icon="bi-info-circle" farbe="info" />
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Offen gesamt"
wert={String(anzahl("GEFAHR") + anzahl("WARNUNG") + anzahl("INFO"))}
icon="bi-bell"
farbe="neutral"
/>
</div>
</div>
<Card
titel={status === "erledigt" ? "Erledigte Warnungen" : "Offene Warnungen"}
rahmen={status === "erledigt" ? "info" : "danger"}
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
<Link
href="/warnungen"
className={`btn ${status !== "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Offen
</Link>
<Link
href="/warnungen?status=erledigt"
className={`btn ${status === "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Erledigt
</Link>
</div>
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Schwere</th>
<th scope="col">Warnung</th>
<th scope="col">Betrifft</th>
<th scope="col">Seit</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{alerts.map((alert) => (
<tr key={alert.id}>
<td>
<Badge farbe={SCHWERE_FARBE[alert.schwere]}>
{alert.schwere === "GEFAHR"
? "Gefahr"
: alert.schwere === "WARNUNG"
? "Warnung"
: "Hinweis"}
</Badge>
</td>
<td>
<span className="fw-semibold">{alert.titel}</span>
<div className="small text-body-secondary">{alert.beschreibung}</div>
<div className="small text-body-secondary pl-mono">{alert.regel}</div>
</td>
<td className="small">
{alert.host ? (
<div>
<Link href={`/hosts/${alert.host.id}`}>{alert.host.hostname}</Link>
</div>
) : null}
{alert.kunde ? (
<div>
<Link href={`/kunden/${alert.kunde.id}`}>{alert.kunde.name}</Link>
</div>
) : null}
{alert.laufzeit ? (
<div>
<Link href={`/rechnungen/${alert.laufzeit.id}`}>Laufzeit öffnen</Link>
</div>
) : null}
</td>
<td className="small">
{formatDatumZeit(alert.geoeffnetAm)}
{alert.quittiertAm ? <div className="text-body-secondary">quittiert</div> : null}
{alert.erledigtAm ? (
<div className="text-body-secondary">
erledigt {formatDatumZeit(alert.erledigtAm)}
</div>
) : null}
</td>
<td className="text-end">
{alert.erledigtAm ? null : (
<div className="d-flex gap-2 justify-content-end">
{alert.quittiertAm ? null : (
<form action={alertQuittieren}>
<input type="hidden" name="id" value={alert.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
Quittieren
</button>
</form>
)}
<form action={alertSchliessen}>
<input type="hidden" name="id" value={alert.id} />
<button
type="submit"
className="btn btn-sm btn-outline-danger"
title="Schließt die Warnung; besteht die Ursache weiter, kommt sie beim nächsten Durchlauf zurück"
>
Schließen
</button>
</form>
</div>
)}
</td>
</tr>
))}
{alerts.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
{status === "erledigt" ? "Noch nichts erledigt." : "Keine offenen Warnungen."}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Regeln laufen stündlich im Worker und nach jedem eingehenden Report.{" "}
<Link href="/einstellungen/warnregeln">Schwellwerte ändern</Link>
</p>
</div>
</div>
</>
);
}
+9
View File
@@ -10,6 +10,7 @@ import {
} from "@/server/agent/http"; } from "@/server/agent/http";
import { reportSchema } from "@/server/agent/schema"; import { reportSchema } from "@/server/agent/schema";
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service"; import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,6 +31,14 @@ export async function POST(request: Request) {
const host = await authentifiziereHost(token); const host = await authentifiziereHost(token);
const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers)); const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers));
// Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen.
// Schlägt das fehl, ist der Report trotzdem angenommen.
try {
await alertsAbgleichen();
} catch (fehler) {
console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler);
}
return Response.json({ return Response.json({
ok: true, ok: true,
hostId: host.id, hostId: host.id,
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useState, useTransition } from "react";
import { alertsJetztPruefen } from "@/server/actions/alerts";
/** Stößt die Auswertung sofort an, statt auf den nächsten Durchlauf des Workers zu warten. */
export function PruefenKnopf() {
const [meldung, setMeldung] = useState<string | null>(null);
const [laeuft, starte] = useTransition();
return (
<span className="d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
disabled={laeuft}
onClick={() =>
starte(async () => {
const ergebnis = await alertsJetztPruefen();
setMeldung(ergebnis.erfolg ?? ergebnis.fehler ?? null);
})
}
>
<i className="bi bi-arrow-clockwise me-1" aria-hidden="true" />
{laeuft ? "Prüft …" : "Jetzt prüfen"}
</button>
{meldung ? <span className="small text-body-secondary">{meldung}</span> : null}
</span>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useActionState } from "react";
import { warnregelnSpeichern } from "@/server/actions/alerts";
import type { AktionsErgebnis } from "@/server/actions/kunden";
export interface RegelAnzeige {
name: string;
beschreibung: string;
aktiv: boolean;
/** Schwellwerte als Text, mehrere Stufen mit Komma getrennt. */
schwellwerte: { name: string; beschriftung: string; wert: string }[];
}
const LEER: AktionsErgebnis = {};
export function WarnregelFormular({ regeln }: { regeln: RegelAnzeige[] }) {
const [ergebnis, aktion, laeuft] = useActionState(warnregelnSpeichern, LEER);
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th scope="col" style={{ width: "6rem" }}>
Aktiv
</th>
<th scope="col">Regel</th>
<th scope="col" style={{ width: "22rem" }}>
Schwellwerte
</th>
</tr>
</thead>
<tbody>
{regeln.map((regel) => (
<tr key={regel.name}>
<td>
<input
type="checkbox"
className="form-check-input pl-touch-target"
name={`aktiv:${regel.name}`}
defaultChecked={regel.aktiv}
aria-label={`${regel.beschreibung} aktiv`}
/>
</td>
<td>
<span className="fw-semibold">{regel.beschreibung}</span>
<div className="small text-body-secondary pl-mono">{regel.name}</div>
</td>
<td>
{regel.schwellwerte.length === 0 ? (
<span className="small text-body-secondary">keine</span>
) : (
regel.schwellwerte.map((schwelle) => (
<div className="mb-2" key={schwelle.name}>
<label className="form-label small mb-1" htmlFor={`${regel.name}-${schwelle.name}`}>
{schwelle.beschriftung}
</label>
<input
id={`${regel.name}-${schwelle.name}`}
name={`schwelle:${regel.name}:${schwelle.name}`}
className="form-control form-control-sm"
defaultValue={schwelle.wert}
/>
</div>
))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button type="submit" className="btn btn-primary pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Speichern und neu auswerten"}
</button>
</form>
);
}
+6 -2
View File
@@ -150,7 +150,7 @@ export function Header({
{alerts.slice(0, 6).map((alert) => ( {alerts.slice(0, 6).map((alert) => (
<Link <Link
key={alert.id} key={alert.id}
href={alert.hostId ? `/hosts/${alert.hostId}` : "/hosts"} href={alert.hostId ? `/hosts/${alert.hostId}` : "/warnungen"}
className="dropdown-item d-flex gap-2 py-2" className="dropdown-item d-flex gap-2 py-2"
onClick={() => setGlockeOffen(false)} onClick={() => setGlockeOffen(false)}
> >
@@ -162,7 +162,11 @@ export function Header({
</Link> </Link>
))} ))}
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<Link href="/hosts" className="dropdown-item text-center" onClick={() => setGlockeOffen(false)}> <Link
href="/warnungen"
className="dropdown-item text-center"
onClick={() => setGlockeOffen(false)}
>
Alle Warnungen ansehen Alle Warnungen ansehen
</Link> </Link>
</div> </div>
+9
View File
@@ -15,10 +15,12 @@ interface MenuEintrag {
export function Sidebar({ export function Sidebar({
hostsAnzahl, hostsAnzahl,
offeneRechnungspunkte, offeneRechnungspunkte,
offeneWarnungen,
benutzer, benutzer,
}: { }: {
hostsAnzahl: number; hostsAnzahl: number;
offeneRechnungspunkte: number; offeneRechnungspunkte: number;
offeneWarnungen: number;
benutzer: string; benutzer: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -44,10 +46,17 @@ export function Sidebar({
: undefined, : undefined,
}, },
{ href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" }, { href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" },
{
href: "/warnungen",
titel: "Warnungen",
icon: "bi-bell",
badge: offeneWarnungen > 0 ? { text: String(offeneWarnungen), klasse: "pl-bg-warning" } : undefined,
},
]; ];
const einstellungen: MenuEintrag[] = [ const einstellungen: MenuEintrag[] = [
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" }, { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" }, { href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" }, { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
]; ];
+93
View File
@@ -0,0 +1,93 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
import type { AktionsErgebnis } from "./kunden";
function neuLaden() {
revalidatePath("/warnungen");
revalidatePath("/einstellungen/warnregeln");
revalidatePath("/");
}
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
export async function alertQuittieren(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.alert.update({ where: { id }, data: { quittiertAm: new Date() } });
await protokolliere("alert.quittiert", "Alert", id, {});
neuLaden();
}
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
export async function alertSchliessen(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.alert.update({ where: { id }, data: { erledigtAm: new Date() } });
await protokolliere("alert.geschlossen", "Alert", id, {});
neuLaden();
}
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
erfolg: `${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`,
};
}
/** Schwellwerte und Ein-/Ausschalter der Regeln speichern. */
export async function warnregelnSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record<string, number | number[]> = {};
for (const [name, standard] of Object.entries(regel.standardSchwellwerte)) {
const eingabe = formular.get(`schwelle:${regel.name}:${name}`)?.toString().trim();
if (!eingabe) continue;
if (Array.isArray(standard)) {
const zahlen = eingabe
.split(",")
.map((teil) => Number(teil.trim()))
.filter((zahl) => Number.isFinite(zahl) && zahl > 0)
.sort((a, b) => b - a);
if (zahlen.length === 0) {
return { fehler: `Bei „${regel.beschreibung}“ bitte mindestens einen Wert angeben.` };
}
schwellwerte[name] = zahlen;
} else {
const zahl = Number(eingabe);
if (!Number.isFinite(zahl) || zahl <= 0) {
return { fehler: `Bei „${regel.beschreibung}“ bitte eine Zahl größer null angeben.` };
}
schwellwerte[name] = zahl;
}
}
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.name },
update: { aktiv, schwellwerte },
create: { regel: regel.name, aktiv, schwellwerte },
});
}
await protokolliere("warnregeln.geaendert", "WarnregelEinstellung", null, {});
// Änderungen sollen sofort sichtbar sein.
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
erfolg: `Gespeichert. Auswertung: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt.`,
};
}
+153
View File
@@ -0,0 +1,153 @@
import { prisma } from "@/lib/db";
import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from "./regeln";
/**
* Lädt den Zustand, wendet die Regeln an und gleicht das Ergebnis mit den offenen Warnungen ab:
* neue öffnen, verschwundene schließen, bestehende unverändert lassen.
*/
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({
include: {
kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
},
}),
prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" },
include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
}),
prisma.warnregelEinstellung.findMany(),
]);
return {
jetzt,
hosts: hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
subStatus: host.subStatus,
subNextduedate: host.subNextduedate,
subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets,
letzterReportAm: host.letzterReportAm,
serverIds: host.serverIdHistorie,
})),
laufzeiten: laufzeiten.map((laufzeit) => ({
id: laufzeit.id,
hostId: laufzeit.hostId,
hostname: laufzeit.host.hostname,
kundeId: laufzeit.host.kundeId,
kundeName: laufzeit.host.kunde?.name ?? null,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
einkaufBetrag: laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
verkaufDatum: laufzeit.kundenPosition?.rechnung.datum ?? null,
})),
einstellungen: new Map(
einstellungen.map((eintrag) => [
eintrag.regel,
{
aktiv: eintrag.aktiv,
schwellwerte: (eintrag.schwellwerte ?? {}) as Record<string, unknown>,
},
]),
),
};
}
export interface AbgleichErgebnis {
geoeffnet: AlertKandidat[];
erledigt: string[];
unveraendert: number;
}
/** Vergleicht die Kandidaten mit den offenen Warnungen und schreibt die Unterschiede fort. */
export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<AbgleichErgebnis> {
const kontext = await kontextLaden(jetzt);
const kandidaten = alleRegelnPruefen(kontext);
const offene = await prisma.alert.findMany({ where: { erledigtAm: null } });
const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert]));
const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
for (const kandidat of neu) {
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen.
await prisma.alert.upsert({
where: { fingerprint: kandidat.fingerprint },
update: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
create: {
fingerprint: kandidat.fingerprint,
regel: kandidat.regel,
schwere: kandidat.schwere,
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
hostId: kandidat.hostId,
kundeId: kandidat.kundeId,
laufzeitId: kandidat.laufzeitId,
geoeffnetAm: jetzt,
},
});
}
// Beschreibungen bestehender Warnungen nachführen (z. B. „läuft in 9 Tagen ab“).
for (const alert of offene) {
const kandidat = kandidatenNachFingerprint.get(alert.fingerprint);
if (!kandidat) continue;
if (kandidat.titel !== alert.titel || kandidat.beschreibung !== alert.beschreibung) {
await prisma.alert.update({
where: { id: alert.id },
data: { titel: kandidat.titel, beschreibung: kandidat.beschreibung, schwere: kandidat.schwere },
});
}
}
if (verschwunden.length > 0) {
await prisma.alert.updateMany({
where: { id: { in: verschwunden.map((alert) => alert.id) } },
data: { erledigtAm: jetzt },
});
}
return {
geoeffnet: neu,
erledigt: verschwunden.map((alert) => alert.fingerprint),
unveraendert: kandidaten.length - neu.length,
};
}
/** Legt fehlende Regel-Einstellungen mit ihren Standardwerten an (Seed und Aktualisierungen). */
export async function warnregelnSicherstellen() {
for (const regel of REGELN) {
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.name },
update: {},
create: { regel: regel.name, aktiv: true, schwellwerte: regel.standardSchwellwerte },
});
}
}
+391
View File
@@ -0,0 +1,391 @@
import type { AlertSchwere, SubscriptionStatus } from "@/generated/prisma/enums";
import { formatDatum, formatEuro } from "@/lib/format";
/**
* Warnregeln als reine Funktionen: Sie bekommen einen Ausschnitt der Daten und geben
* Kandidaten zurück. Kein Datenbankzugriff, keine Nebenwirkungen – dadurch sind sie einzeln testbar.
* Das Öffnen und Schließen der Alerts übernimmt der Abgleich (`abgleich.ts`).
*/
export interface AlertKandidat {
/** Regel + betroffenes Objekt (+ Stufe). Zwei Kandidaten mit gleichem Fingerabdruck sind derselbe Fall. */
fingerprint: string;
regel: string;
schwere: AlertSchwere;
titel: string;
beschreibung: string;
hostId: string | null;
kundeId: string | null;
laufzeitId: string | null;
}
export interface HostDaten {
id: string;
hostname: string;
kundeId: string | null;
kundeName: string | null;
subKey: string | null;
subStatus: SubscriptionStatus;
subNextduedate: Date | null;
subLizenzierteSockets: number | null;
sockets: number | null;
letzterReportAm: Date | null;
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
}
export interface LaufzeitDaten {
id: string;
hostId: string;
hostname: string;
kundeId: string | null;
kundeName: string | null;
key: string;
start: Date;
ende: Date;
einkaufNummer: string | null;
einkaufBetrag: number | null;
einkaufBezahlt: boolean | null;
einkaufFaelligAm: Date | null;
verkaufNummer: string | null;
verkaufBetrag: number | null;
verkaufErstellt: boolean;
verkaufBezahlt: boolean;
verkaufDatum: Date | null;
}
export interface RegelKontext {
jetzt: Date;
hosts: HostDaten[];
laufzeiten: LaufzeitDaten[];
einstellungen: Map<string, { aktiv: boolean; schwellwerte: Record<string, unknown> }>;
}
export interface Regel {
name: string;
beschreibung: string;
/** Wird beim Anlegen der Einstellung als Vorgabe gespeichert (JSON-taugliche Werte). */
standardSchwellwerte: Record<string, number | number[] | string | boolean>;
pruefe: (kontext: RegelKontext) => AlertKandidat[];
}
const TAG = 86_400_000;
function tageBis(datum: Date, jetzt: Date): number {
const ziel = Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate());
const start = Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate());
return Math.round((ziel - start) / TAG);
}
function schwellwert<T>(kontext: RegelKontext, regel: string, name: string, standard: T): T {
const wert = kontext.einstellungen.get(regel)?.schwellwerte?.[name];
return (wert as T) ?? standard;
}
function kundenHinweis(kundeName: string | null): string {
return kundeName ?? "Nicht zugeordnet";
}
// ---------------------------------------------------------------- Regeln
const subscriptionLaeuftAb: Regel = {
name: "subscription-laeuft-ab",
beschreibung: "Subscription läuft in wenigen Tagen ab",
standardSchwellwerte: { tage: [30, 14, 7] },
pruefe(kontext) {
const stufen = [...schwellwert<number[]>(kontext, "subscription-laeuft-ab", "tage", [30, 14, 7])].sort(
(a, b) => b - a,
);
return kontext.hosts.flatMap((host) => {
if (!host.subNextduedate || host.subStatus !== "ACTIVE") return [];
const tage = tageBis(host.subNextduedate, kontext.jetzt);
if (tage < 0) return [];
// Immer nur die strengste erreichte Stufe melden.
const erreicht = stufen.filter((stufe) => tage <= stufe).sort((a, b) => a - b)[0];
if (erreicht === undefined) return [];
return [
{
fingerprint: `subscription-laeuft-ab:${host.id}:${erreicht}`,
regel: "subscription-laeuft-ab",
schwere: (erreicht <= 7 ? "GEFAHR" : erreicht <= 14 ? "WARNUNG" : "INFO") as AlertSchwere,
titel: `Subscription läuft in ${tage} Tag${tage === 1 ? "" : "en"} ab`,
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)} · ${formatDatum(host.subNextduedate)}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
},
];
});
},
};
/** Eigene Titel, damit die Meldung ein ganzer Satz bleibt. */
const STATUS_TITEL: Record<SubscriptionStatus, string> = {
ACTIVE: "Subscription aktiv",
NOTFOUND: "Keine Subscription gefunden",
INVALID: "Subscription ungültig",
EXPIRED: "Subscription abgelaufen",
SUSPENDED: "Subscription ausgesetzt",
UNKNOWN: "Subscription-Status unbekannt",
};
const statusNichtAktiv: Regel = {
name: "status-nicht-aktiv",
beschreibung: "Subscription ist nicht aktiv",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter((host) => host.subStatus !== "ACTIVE")
.map((host) => ({
fingerprint: `status-nicht-aktiv:${host.id}`,
regel: "status-nicht-aktiv",
schwere: (host.subStatus === "NOTFOUND" || host.subStatus === "UNKNOWN"
? "WARNUNG"
: "GEFAHR") as AlertSchwere,
titel: STATUS_TITEL[host.subStatus],
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)}${
host.subKey ? ` · ${host.subKey}` : ""
}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const socketsAbweichung: Regel = {
name: "sockets-abweichung",
beschreibung: "Lizenzierte und physische Sockets weichen ab",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter(
(host) =>
host.subLizenzierteSockets !== null &&
host.sockets !== null &&
host.subLizenzierteSockets !== host.sockets,
)
.map((host) => ({
fingerprint: `sockets-abweichung:${host.id}`,
regel: "sockets-abweichung",
schwere: "WARNUNG" as AlertSchwere,
titel: "Lizenzierte Sockets weichen ab",
beschreibung: `${host.hostname} · ${host.subLizenzierteSockets} lizenziert, ${host.sockets} physisch vorhanden`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const keineMeldung: Regel = {
name: "keine-meldung",
beschreibung: "Host hat sich lange nicht gemeldet",
standardSchwellwerte: { stunden: 48 },
pruefe(kontext) {
const stunden = schwellwert(kontext, "keine-meldung", "stunden", 48);
const grenze = new Date(kontext.jetzt.getTime() - stunden * 3_600_000);
return kontext.hosts
.filter((host) => host.letzterReportAm !== null && host.letzterReportAm < grenze)
.map((host) => ({
fingerprint: `keine-meldung:${host.id}`,
regel: "keine-meldung",
schwere: "WARNUNG" as AlertSchwere,
titel: `Host meldet sich seit über ${stunden} Stunden nicht`,
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)} · letzte Meldung ${formatDatum(
host.letzterReportAm,
)}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const serverIdGeaendert: Regel = {
name: "serverid-geaendert",
beschreibung: "Server-ID hat sich geändert (Hardwaretausch oder Neuinstallation)",
standardSchwellwerte: { tage: 30 },
pruefe(kontext) {
const tage = schwellwert(kontext, "serverid-geaendert", "tage", 30);
const grenze = new Date(kontext.jetzt.getTime() - tage * TAG);
return kontext.hosts.flatMap((host) => {
if (host.serverIds.length < 2) return [];
const nachSortiert = [...host.serverIds].sort(
(a, b) => b.letzteMeldung.getTime() - a.letzteMeldung.getTime(),
);
const aktuell = nachSortiert[0];
const vorher = nachSortiert[1];
// Nur melden, solange der Wechsel frisch ist – ein alter Hardwaretausch ist erledigt.
if (aktuell.ersteMeldung < grenze) return [];
return [
{
fingerprint: `serverid-geaendert:${host.id}:${aktuell.serverId}`,
regel: "serverid-geaendert",
schwere: "GEFAHR" as AlertSchwere,
titel: "Server-ID hat sich geändert",
beschreibung: `${host.hostname} · neu ${aktuell.serverId}, vorher ${vorher.serverId} – der Key muss bei Proxmox neu zugewiesen werden`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
},
];
});
},
};
const keyMehrfach: Regel = {
name: "key-mehrfach-gemeldet",
beschreibung: "Derselbe Subscription-Key wird von mehreren Hosts gemeldet",
standardSchwellwerte: {},
pruefe(kontext) {
const proKey = new Map<string, HostDaten[]>();
for (const host of kontext.hosts) {
if (!host.subKey) continue;
proKey.set(host.subKey, [...(proKey.get(host.subKey) ?? []), host]);
}
return [...proKey.entries()]
.filter(([, hosts]) => hosts.length > 1)
.map(([key, hosts]) => ({
fingerprint: `key-mehrfach-gemeldet:${key}`,
regel: "key-mehrfach-gemeldet",
schwere: "GEFAHR" as AlertSchwere,
titel: "Key wird von mehreren Hosts gemeldet",
beschreibung: `${key} · ${hosts.map((h) => h.hostname).join(", ")}`,
hostId: hosts[0].id,
kundeId: hosts[0].kundeId,
laufzeitId: null,
}));
},
};
const hostOhneKunde: Regel = {
name: "host-ohne-kunde",
beschreibung: "Host ist keinem Kunden zugewiesen",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter((host) => host.kundeId === null)
.map((host) => ({
fingerprint: `host-ohne-kunde:${host.id}`,
regel: "host-ohne-kunde",
schwere: "INFO" as AlertSchwere,
titel: "Host ist keinem Kunden zugewiesen",
beschreibung: `${host.hostname}${host.subKey ? ` · ${host.subKey}` : ""}`,
hostId: host.id,
kundeId: null,
laufzeitId: null,
}));
},
};
const proxmoxRechnungFaellig: Regel = {
name: "proxmox-rechnung-faellig",
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
standardSchwellwerte: { tage: 7 },
pruefe(kontext) {
const tage = schwellwert(kontext, "proxmox-rechnung-faellig", "tage", 7);
return kontext.laufzeiten.flatMap((laufzeit) => {
if (laufzeit.einkaufBezahlt !== false || !laufzeit.einkaufFaelligAm) return [];
const rest = tageBis(laufzeit.einkaufFaelligAm, kontext.jetzt);
if (rest > tage) return [];
return [
{
fingerprint: `proxmox-rechnung-faellig:${laufzeit.id}`,
regel: "proxmox-rechnung-faellig",
schwere: (rest < 0 ? "GEFAHR" : "WARNUNG") as AlertSchwere,
titel: rest < 0 ? "Proxmox-Rechnung überfällig" : "Proxmox-Rechnung wird fällig",
beschreibung: `${laufzeit.einkaufNummer ?? "ohne Nummer"} · ${formatEuro(
laufzeit.einkaufBetrag,
)} netto · fällig am ${formatDatum(laufzeit.einkaufFaelligAm)} · ${laufzeit.hostname}`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
const kundenrechnungFehlt: Regel = {
name: "kundenrechnung-fehlt",
beschreibung: "Einkauf ist erfasst, die Kundenrechnung fehlt noch",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
.filter((laufzeit) => laufzeit.kundeId !== null)
.map((laufzeit) => ({
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
regel: "kundenrechnung-fehlt",
schwere: "WARNUNG" as AlertSchwere,
titel: "Kundenrechnung noch nicht erstellt",
beschreibung: `${laufzeit.hostname} · ${kundenHinweis(laufzeit.kundeName)} · Laufzeit ab ${formatDatum(
laufzeit.start,
)} · Einkauf ${laufzeit.einkaufNummer}`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
}));
},
};
const kundenrechnungUnbezahlt: Regel = {
name: "kundenrechnung-unbezahlt",
beschreibung: "Kundenrechnung ist erstellt, aber noch nicht bezahlt",
standardSchwellwerte: { tage: 30 },
pruefe(kontext) {
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
return kontext.laufzeiten.flatMap((laufzeit) => {
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
if (offenSeit < tage) return [];
return [
{
fingerprint: `kundenrechnung-unbezahlt:${laufzeit.id}`,
regel: "kundenrechnung-unbezahlt",
schwere: "WARNUNG" as AlertSchwere,
titel: `Kundenrechnung seit ${offenSeit} Tagen offen`,
beschreibung: `${laufzeit.verkaufNummer ?? "ohne Nummer"} · ${kundenHinweis(
laufzeit.kundeName,
)} · ${formatEuro(laufzeit.verkaufBetrag)} netto`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
export const REGELN: Regel[] = [
subscriptionLaeuftAb,
statusNichtAktiv,
socketsAbweichung,
keineMeldung,
serverIdGeaendert,
keyMehrfach,
hostOhneKunde,
proxmoxRechnungFaellig,
kundenrechnungFehlt,
kundenrechnungUnbezahlt,
];
/** Wendet alle aktiven Regeln an. */
export function alleRegelnPruefen(kontext: RegelKontext): AlertKandidat[] {
return REGELN.filter((regel) => kontext.einstellungen.get(regel.name)?.aktiv !== false).flatMap((regel) =>
regel.pruefe(kontext),
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Cron } from "croner";
import "dotenv/config";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
/**
* Hintergrundprozess: wertet die Warnregeln regelmäßig aus, unabhängig davon, ob jemand
* das Dashboard geöffnet hat. Läuft als eigener Dienst neben der Web-Anwendung (siehe #014).
*
* npm run worker
*/
const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`);
}
async function durchlauf() {
try {
const ergebnis = await alertsAbgleichen();
protokoll(
`Warnregeln geprüft: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`,
);
for (const alert of ergebnis.geoeffnet) {
protokoll(` neu: ${alert.titel} – ${alert.beschreibung}`);
}
} catch (fehler) {
console.error("Auswertung fehlgeschlagen:", fehler);
}
}
async function start() {
await warnregelnSicherstellen();
protokoll(`Worker gestartet, Zeitplan "${ZEITPLAN}" (${ZEITZONE}).`);
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
await durchlauf();
const aufgabe = new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf);
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
protokoll("Worker wird beendet.");
aufgabe.stop();
process.exit(0);
});
}
}
void start();
+134
View File
@@ -0,0 +1,134 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const JETZT = new Date("2026-09-17T12:00:00Z");
async function hostMitAblauf(tage: number) {
return testPrisma.host.create({
data: {
agentId: "agent-1",
hostname: "pve-01",
produkt: "PVE",
subStatus: "ACTIVE",
subKey: "pve1c-aaaa",
subNextduedate: new Date(JETZT.getTime() + tage * 86_400_000),
letzterReportAm: JETZT,
},
});
}
describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
await warnregelnSicherstellen();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(10);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
});
it("öffnet eine Warnung und meldet sie nicht doppelt", async () => {
await hostMitAblauf(5);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.map((a) => a.regel)).toContain("subscription-laeuft-ab");
const zweiter = await alertsAbgleichen(JETZT);
expect(zweiter.geoeffnet).toHaveLength(0);
const offene = await testPrisma.alert.findMany({ where: { erledigtAm: null } });
expect(offene.filter((a) => a.regel === "subscription-laeuft-ab")).toHaveLength(1);
});
it("schließt eine Warnung, sobald die Ursache behoben ist", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
// Verlängert: neues Ablaufdatum weit in der Zukunft.
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.erledigt).toContain(`subscription-laeuft-ab:${host.id}:7`);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.erledigtAm).not.toBeNull();
});
it("öffnet einen erledigten Fall erneut, wenn er wieder auftritt", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 5 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
`subscription-laeuft-ab:${host.id}:7`,
);
const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
// Derselbe Eintrag wird wiederverwendet, es entsteht kein zweiter.
expect(alerts).toHaveLength(1);
expect(alerts[0].erledigtAm).toBeNull();
});
it("führt die Beschreibung nach, wenn sich der Fall ändert", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
const spaeter = new Date(JETZT.getTime() + 2 * 86_400_000);
await alertsAbgleichen(spaeter);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.titel).toBe("Subscription läuft in 3 Tagen ab");
});
it("steigt von der 30- auf die 7-Tage-Stufe", async () => {
const host = await hostMitAblauf(25);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.some((a) => a.fingerprint.endsWith(":30"))).toBe(true);
const spaeter = new Date(JETZT.getTime() + 20 * 86_400_000);
const zweiter = await alertsAbgleichen(spaeter);
expect(zweiter.geoeffnet.some((a) => a.fingerprint.endsWith(":7"))).toBe(true);
expect(zweiter.erledigt).toContain(`subscription-laeuft-ab:${host.id}:30`);
});
it("beachtet abgeschaltete Regeln", async () => {
await testPrisma.host.create({
data: { agentId: "agent-2", hostname: "pve-ohne-kunde", produkt: "PVE", letzterReportAm: JETZT },
});
await testPrisma.warnregelEinstellung.update({
where: { regel: "host-ohne-kunde" },
data: { aktiv: false },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.some((a) => a.regel === "host-ohne-kunde")).toBe(false);
});
});
+317
View File
@@ -0,0 +1,317 @@
import { describe, expect, it } from "vitest";
import {
alleRegelnPruefen,
REGELN,
type HostDaten,
type LaufzeitDaten,
type RegelKontext,
} from "@/server/alerts/regeln";
const JETZT = new Date("2026-09-17T12:00:00Z");
const TAG = 86_400_000;
function inTagen(tage: number): Date {
return new Date(JETZT.getTime() + tage * TAG);
}
function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
return {
id: "h1",
hostname: "pve-01",
kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH",
subKey: "pve1c-aaaa",
subStatus: "ACTIVE",
subNextduedate: inTagen(200),
subLizenzierteSockets: 1,
sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen,
};
}
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
return {
id: "l1",
hostId: "h1",
hostname: "pve-01",
kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH",
key: "pve1c-aaaa",
start: inTagen(-100),
ende: inTagen(265),
einkaufNummer: "PX-2026-1",
einkaufBetrag: 115,
einkaufBezahlt: true,
einkaufFaelligAm: inTagen(-80),
verkaufNummer: "RE-2026-1",
verkaufBetrag: 165,
verkaufErstellt: true,
verkaufBezahlt: true,
verkaufDatum: inTagen(-90),
...überschreibungen,
};
}
function kontext(teil: Partial<RegelKontext> = {}): RegelKontext {
return {
jetzt: JETZT,
hosts: [],
laufzeiten: [],
einstellungen: new Map(),
...teil,
};
}
/** Hilfsfunktion: Kandidaten einer bestimmten Regel. */
function ausRegel(k: RegelKontext, name: string) {
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
}
describe("Warnregeln", () => {
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(10);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10);
});
describe("Subscription läuft ab", () => {
it.each([
[31, 0, null],
[30, 1, "INFO"],
[20, 1, "INFO"],
[14, 1, "WARNUNG"],
[8, 1, "WARNUNG"],
[7, 1, "GEFAHR"],
[1, 1, "GEFAHR"],
[0, 1, "GEFAHR"],
[-1, 0, null],
])("bei %i Tagen Restlaufzeit: %i Warnung(en)", (tage, anzahl, schwere) => {
const kandidaten = ausRegel(
kontext({ hosts: [host({ subNextduedate: inTagen(tage) })] }),
"subscription-laeuft-ab",
);
expect(kandidaten).toHaveLength(anzahl);
if (schwere) expect(kandidaten[0].schwere).toBe(schwere);
});
it("meldet nur die strengste erreichte Stufe", () => {
const kandidaten = ausRegel(
kontext({ hosts: [host({ subNextduedate: inTagen(5) })] }),
"subscription-laeuft-ab",
);
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].fingerprint).toBe("subscription-laeuft-ab:h1:7");
});
it("beachtet eigene Schwellwerte", () => {
const k = kontext({
hosts: [host({ subNextduedate: inTagen(50) })],
einstellungen: new Map([
["subscription-laeuft-ab", { aktiv: true, schwellwerte: { tage: [60, 30] } }],
]),
});
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(1);
});
it("meldet nichts für Hosts ohne aktive Subscription", () => {
const k = kontext({ hosts: [host({ subStatus: "EXPIRED", subNextduedate: inTagen(3) })] });
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(0);
});
});
describe("Status nicht aktiv", () => {
it.each([
["EXPIRED", "GEFAHR"],
["INVALID", "GEFAHR"],
["SUSPENDED", "GEFAHR"],
["NOTFOUND", "WARNUNG"],
["UNKNOWN", "WARNUNG"],
] as const)("meldet %s als %s", (status, schwere) => {
const kandidaten = ausRegel(kontext({ hosts: [host({ subStatus: status })] }), "status-nicht-aktiv");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe(schwere);
});
it("meldet aktive Subscriptions nicht", () => {
expect(ausRegel(kontext({ hosts: [host()] }), "status-nicht-aktiv")).toHaveLength(0);
});
});
describe("Sockets", () => {
it("meldet eine Abweichung", () => {
const k = kontext({ hosts: [host({ subLizenzierteSockets: 1, sockets: 2 })] });
const kandidaten = ausRegel(k, "sockets-abweichung");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("1 lizenziert, 2 physisch");
});
it("meldet nichts, wenn ein Wert fehlt", () => {
expect(ausRegel(kontext({ hosts: [host({ sockets: null })] }), "sockets-abweichung")).toHaveLength(0);
expect(
ausRegel(kontext({ hosts: [host({ subLizenzierteSockets: null })] }), "sockets-abweichung"),
).toHaveLength(0);
});
});
describe("Keine Meldung", () => {
it("meldet nach Ablauf der Frist", () => {
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 49 * 3_600_000) })] });
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
});
it("meldet innerhalb der Frist nicht", () => {
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 47 * 3_600_000) })] });
expect(ausRegel(k, "keine-meldung")).toHaveLength(0);
});
it("beachtet einen eigenen Schwellwert", () => {
const k = kontext({
hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 13 * 3_600_000) })],
einstellungen: new Map([["keine-meldung", { aktiv: true, schwellwerte: { stunden: 12 } }]]),
});
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
});
});
describe("Server-ID", () => {
it("meldet einen frischen Wechsel", () => {
const k = kontext({
hosts: [
host({
serverIds: [
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-10) },
{ serverId: "NEU", ersteMeldung: inTagen(-5), letzteMeldung: JETZT },
],
}),
],
});
const kandidaten = ausRegel(k, "serverid-geaendert");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("neu NEU, vorher ALT");
});
it("meldet einen alten Wechsel nicht mehr", () => {
const k = kontext({
hosts: [
host({
serverIds: [
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-200) },
{ serverId: "NEU", ersteMeldung: inTagen(-190), letzteMeldung: JETZT },
],
}),
],
});
expect(ausRegel(k, "serverid-geaendert")).toHaveLength(0);
});
});
describe("Key mehrfach gemeldet", () => {
it("meldet denselben Key auf zwei Hosts", () => {
const k = kontext({
hosts: [host(), host({ id: "h2", hostname: "pve-02" })],
});
const kandidaten = ausRegel(k, "key-mehrfach-gemeldet");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("pve-01, pve-02");
});
it("meldet unterschiedliche Keys nicht", () => {
const k = kontext({ hosts: [host(), host({ id: "h2", subKey: "pve1c-bbbb" })] });
expect(ausRegel(k, "key-mehrfach-gemeldet")).toHaveLength(0);
});
});
describe("Host ohne Kunde", () => {
it("meldet nicht zugeordnete Hosts", () => {
const k = kontext({ hosts: [host({ kundeId: null, kundeName: null })] });
const kandidaten = ausRegel(k, "host-ohne-kunde");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe("INFO");
});
});
describe("Proxmox-Rechnung fällig", () => {
it("meldet eine bald fällige unbezahlte Rechnung als Warnung", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(5) })],
});
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe("WARNUNG");
});
it("meldet eine überfällige Rechnung als Gefahr", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(-2) })],
});
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
expect(kandidaten[0].schwere).toBe("GEFAHR");
expect(kandidaten[0].titel).toBe("Proxmox-Rechnung überfällig");
});
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
).toHaveLength(0);
expect(
ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(20) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0);
});
});
describe("Kundenrechnung", () => {
it("meldet eine fehlende Kundenrechnung, wenn der Einkauf erfasst ist", () => {
const k = kontext({ laufzeiten: [laufzeit({ verkaufErstellt: false, verkaufNummer: null })] });
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(1);
});
it("meldet nichts für Hosts ohne Kunde", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufErstellt: false, kundeId: null, kundeName: null })],
});
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(0);
});
it("meldet eine seit über 30 Tagen unbezahlte Kundenrechnung", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-31) })],
});
const kandidaten = ausRegel(k, "kundenrechnung-unbezahlt");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].titel).toContain("31 Tagen");
});
it("meldet innerhalb der Frist nicht", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-29) })],
});
expect(ausRegel(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
});
});
it("überspringt abgeschaltete Regeln", () => {
const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })],
einstellungen: new Map([["host-ohne-kunde", { aktiv: false, schwellwerte: {} }]]),
});
expect(ausRegel(k, "host-ohne-kunde")).toHaveLength(0);
});
it("erzeugt für jeden Fall einen eindeutigen Fingerabdruck", () => {
const k = kontext({
hosts: [
host({ subStatus: "EXPIRED", sockets: 2, kundeId: null, kundeName: null }),
host({ id: "h2", hostname: "pve-02", subStatus: "NOTFOUND" }),
],
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(1), verkaufErstellt: false })],
});
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
expect(new Set(fingerprints).size).toBe(fingerprints.length);
});
});