diff --git a/.env.example b/.env.example index aa9aac6..b0abbd7 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ ADMIN_PASSWORD="bitte-aendern" # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) APP_URL="http://localhost:3000" +# Zeitplan des Workers (Standard: stündlich zur vollen Stunde) +WORKER_CRON="0 * * * *" + # Ablage für hochgeladene Rechnungs-PDFs UPLOAD_DIR="./data/uploads" diff --git a/CHANGELOG.md b/CHANGELOG.md index a323a37..e08b21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### 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 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 5ebf4ee..9dbb244 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 3 h 58 min +**Gesamt:** 4 h 28 min | Issue | Zeit | |---|---| @@ -17,6 +17,7 @@ | #008 Laufzeiten und Rechnungen | 0 h 35 min | | #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min | | #010 Export CSV und PDF | 0 h 20 min | +| #011 Warnregeln | 0 h 30 min | | Meilenstein | Zeit | |---|---| @@ -28,12 +29,13 @@ | v0.4.2 | 0 h 10 min | **abgeschlossen** | v0.5.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 **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.6.0 +**Version:** 0.7.0 ### Erledigt - 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 - #010 abgeschlossen: CSV- und PDF-Export für Hosts, Kunden, einzelne Kundenunterlage und Rechnungsliste; PDFs serverseitig ohne Browser - 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 +- **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. - **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). @@ -90,4 +95,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #011 Warnregeln mit Worker und konfigurierbaren Schwellwerten +- #012 Benachrichtigungen: SMTP über Microsoft 365, Webhook, Zusammenfassung, keine Doppelten diff --git a/README.md b/README.md index 93e78e4..9a2d921 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ npm run typecheck 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). ## Agent auf einem Proxmox-Host installieren diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png new file mode 100644 index 0000000..87c7ad5 Binary files /dev/null and b/docs/anleitung/bilder/warnregeln.png differ diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png new file mode 100644 index 0000000..d0341df Binary files /dev/null and b/docs/anleitung/bilder/warnungen.png differ diff --git a/docs/issues/011-warnregeln.md b/docs/issues/011-warnregeln.md index b8caaaf..f0bf3cc 100644 --- a/docs/issues/011-warnregeln.md +++ b/docs/issues/011-warnregeln.md @@ -1,26 +1,36 @@ # #011 Warnregeln -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.7.0 **Labels:** backend, alerts -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min ## Ziel Konfigurierbare Warnregeln, die Alerts öffnen und automatisch schließen. ## Aufgaben -- [ ] 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 -- [ ] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report -- [ ] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern -- [ ] Warnglocke und Alert-Liste (offen/erledigt, quittieren) -- [ ] Tests für jede Regel inkl. Grenzfälle +- [x] Regel-Engine: reine Funktionen, Fingerprint, Abgleich offen/neu/erledigt +- [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 +- [x] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report +- [x] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern +- [x] Warnglocke und Alert-Liste (offen/erledigt, quittieren) +- [x] Tests für jede Regel inkl. Grenzfälle ## Akzeptanzkriterien -- [ ] Kein doppelter offener Alert für denselben Sachverhalt -- [ ] Alerts schließen sich automatisch, wenn die Ursache behoben ist -- [ ] Alle Regel-Tests grün +- [x] Kein doppelter offener Alert für denselben Sachverhalt +- [x] Alerts schließen sich automatisch, wenn die Ursache behoben ist +- [x] Alle Regel-Tests grün ## Abhängigkeiten #008 ## 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::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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 2281f81..2521361 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | | [#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 | -| [#011](011-warnregeln.md) | Warnregeln | in Arbeit | v0.7.0 | 8 h | – | -| [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – | +| [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min | +| [#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 | – | | [#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 | – | @@ -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 | – | | [#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 | -| | **Summe** | | | **114 h** | **3 h 58 min** | +| | **Summe** | | | **114 h** | **4 h 28 min** | diff --git a/package-lock.json b/package-lock.json index e60afbf..4123a0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "admin-lte": "^4.9.1", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", + "croner": "^10.0.1", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8", @@ -4788,6 +4789,25 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index f721a80..d0e96ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.6.0", + "version": "0.7.0", "private": true, "scripts": { "dev": "next dev", @@ -18,7 +18,9 @@ "db:studio": "prisma studio", "db:generate": "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": { "@node-rs/argon2": "^2.2.1", @@ -28,6 +30,7 @@ "admin-lte": "^4.9.1", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", + "croner": "^10.0.1", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/scripts/alerts-pruefen.ts b/scripts/alerts-pruefen.ts new file mode 100644 index 0000000..f1523ea --- /dev/null +++ b/scripts/alerts-pruefen.ts @@ -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); + }); diff --git a/src/app/(dashboard)/einstellungen/warnregeln/page.tsx b/src/app/(dashboard)/einstellungen/warnregeln/page.tsx new file mode 100644 index 0000000..fbff1fa --- /dev/null +++ b/src/app/(dashboard)/einstellungen/warnregeln/page.tsx @@ -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 = { + 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; + + 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 ( + <> + + +
+
+ +

+ 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 Warnungen. +

+ +
+
+
+ + ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 9fc861f..b0b4a50 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -30,6 +30,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
{children}
diff --git a/src/app/(dashboard)/warnungen/page.tsx b/src/app/(dashboard)/warnungen/page.tsx new file mode 100644 index 0000000..207f199 --- /dev/null +++ b/src/app/(dashboard)/warnungen/page.tsx @@ -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>; + +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 ( + <> + } /> + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + Offen + + + Erledigt + +
+ } + > +
+ + + + + + + + + + + + {alerts.map((alert) => ( + + + + + + + + ))} + {alerts.length === 0 ? ( + + + + ) : null} + +
SchwereWarnungBetrifftSeit + Aktion +
+ + {alert.schwere === "GEFAHR" + ? "Gefahr" + : alert.schwere === "WARNUNG" + ? "Warnung" + : "Hinweis"} + + + {alert.titel} +
{alert.beschreibung}
+
{alert.regel}
+
+ {alert.host ? ( +
+ {alert.host.hostname} +
+ ) : null} + {alert.kunde ? ( +
+ {alert.kunde.name} +
+ ) : null} + {alert.laufzeit ? ( +
+ Laufzeit öffnen +
+ ) : null} +
+ {formatDatumZeit(alert.geoeffnetAm)} + {alert.quittiertAm ?
quittiert
: null} + {alert.erledigtAm ? ( +
+ erledigt {formatDatumZeit(alert.erledigtAm)} +
+ ) : null} +
+ {alert.erledigtAm ? null : ( +
+ {alert.quittiertAm ? null : ( +
+ + +
+ )} +
+ + +
+
+ )} +
+ {status === "erledigt" ? "Noch nichts erledigt." : "Keine offenen Warnungen."} +
+
+ + +

+ Die Regeln laufen stündlich im Worker und nach jedem eingehenden Report.{" "} + Schwellwerte ändern +

+
+ + + ); +} diff --git a/src/app/api/agent/v1/report/route.ts b/src/app/api/agent/v1/report/route.ts index fb03d24..24b6df3 100644 --- a/src/app/api/agent/v1/report/route.ts +++ b/src/app/api/agent/v1/report/route.ts @@ -10,6 +10,7 @@ import { } from "@/server/agent/http"; import { reportSchema } from "@/server/agent/schema"; import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service"; +import { alertsAbgleichen } from "@/server/alerts/abgleich"; export const dynamic = "force-dynamic"; @@ -30,6 +31,14 @@ export async function POST(request: Request) { const host = await authentifiziereHost(token); 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({ ok: true, hostId: host.id, diff --git a/src/components/alerts/PruefenKnopf.tsx b/src/components/alerts/PruefenKnopf.tsx new file mode 100644 index 0000000..7a7a6ca --- /dev/null +++ b/src/components/alerts/PruefenKnopf.tsx @@ -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(null); + const [laeuft, starte] = useTransition(); + + return ( + +