Compare commits

...
17 Commits
Author SHA1 Message Date
florian.wilde 7ff068d9b3 Merge branch 'phase/016-handbuch' (Anmeldefehler) 2026-09-17 23:41:01 +02:00
florian.wildeandClaude Opus 5 2c2f842d20 fix(auth): Sitzungs-Cookie auch ueber http nutzbar, Anmeldung als Formular (#013)
Das Cookie erhielt im Produktionsbau immer Secure und wurde ueber http vom
Browser verworfen – nach der Anmeldung landete jeder Klick wieder auf der
Anmeldeseite. Massgeblich ist jetzt das Schema in APP_URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:41:01 +02:00
florian.wilde f838a44991 Merge branch 'phase/015-feinschliff' 2026-09-17 23:27:23 +02:00
florian.wildeandClaude Opus 5 4c65c9ddab perf(alerts): Warnungen sammelweise anlegen, Dark-Mode und Mobilansicht verbessern (#015)
Lasttest mit 200 Hosts und 365.000 Reports: Auswertung der Warnregeln von 3813
auf 293 ms. Logo im dunklen Design sichtbar gemacht, Tabellen blenden auf
schmalen Bildschirmen Spalten aus, Aufgaben-Karte begrenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:27:23 +02:00
florian.wildeandClaude Opus 5 9647c19136 fix(lint): erzeugtes Worker-Buendel von der Pruefung ausnehmen (#014)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:13:28 +02:00
florian.wilde 5a4c02043d Merge branch 'phase/014-deployment' 2026-09-17 23:12:29 +02:00
florian.wildeandClaude Opus 5 0af9187079 feat(deploy): Docker-Abbild, Compose, LXC-Installer und Sicherung (#014)
Mehrstufiges Abbild mit Standalone-Ausgabe, eigener Migrationsdienst, Worker als
ESM-Buendel, LXC-Installer fuer Proxmox, Reverse-Proxy-Beispiele, Sicherungsskript
und Zustandspruefung. Stapel gebaut und mit Agent-Durchlauf geprueft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:12:29 +02:00
florian.wilde 00eb7225f9 Merge branch 'phase/013-authentifizierung' 2026-09-17 22:40:03 +02:00
florian.wildeandClaude Opus 5 ee487775ec feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013)
Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik),
Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und
Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:40:03 +02:00
florian.wilde 6cc39591b7 Merge branch 'phase/012-benachrichtigungen' 2026-09-17 22:33:17 +02:00
florian.wildeandClaude Opus 5 9e770a99b1 feat(alerts): Benachrichtigungen per E-Mail und Webhook (#012)
SMTP mit STARTTLS fuer Microsoft 365, Webhook mit HMAC-Signatur, taegliche oder
woechentliche Zusammenfassung, Versandprotokoll und Testversand. Keine
Doppelmeldungen dank Pruefung gegen das Protokoll. 134 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:33:17 +02:00
florian.wilde 179f751b1c Merge branch 'phase/011-warnregeln' 2026-09-17 22:25:33 +02:00
florian.wildeandClaude Opus 5 8a299f6c5e 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>
2026-09-17 22:25:33 +02:00
florian.wilde 5018589f08 Merge branch 'phase/010-export' 2026-09-17 22:12:58 +02:00
florian.wildeandClaude Opus 5 cb009efd7d feat(export): CSV- und PDF-Ausgaben fuer Hosts, Kunden und Rechnungen (#010)
PDFs entstehen serverseitig mit @react-pdf/renderer, mit einfarbigem Logo,
wiederholtem Spaltenkopf und Summenzeile. Alle Exporte uebernehmen die Filter
der jeweiligen Ansicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:12:58 +02:00
florian.wilde 6168ad69f2 Merge branch 'phase/009-jahresbahn' 2026-09-17 21:57:09 +02:00
florian.wildeandClaude Opus 5 1a5327dced feat(jahresbahn): Zeitleiste und Ablauf-Kalender aus der Datenbank (#009)
Zwoelf-Monats-Bahn mit Heute-Linie und Gruppierung nach Kunde, 90-Tage-Kalender
mit Rechnungsstatus sowie eine Karte fuer bereits abgelaufene Subscriptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:57:09 +02:00
104 changed files with 6475 additions and 224 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
dist
.git
.env
.env.*
!.env.example
data
docs/anleitung/bilder
coverage
*.log
+22 -1
View File
@@ -1,4 +1,10 @@
# Datenbank (lokal über docker-compose.dev.yml, Port 5433)
# Für docker-compose.yml: Zugangsdaten der mitgelieferten Datenbank
POSTGRES_USER="proxledger"
POSTGRES_PASSWORD="bitte-aendern"
POSTGRES_DB="proxledger"
PORT="3000"
# Datenbank (lokale Entwicklung über docker-compose.dev.yml, Port 5433)
DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public"
# Eigene Datenbank für automatisierte Tests
@@ -9,9 +15,24 @@ ADMIN_EMAIL="admin@example.de"
ADMIN_NAME="Administrator"
ADMIN_PASSWORD="bitte-aendern"
# Schlüssel für Sitzungs-Cookies (mindestens 32 Zeichen): openssl rand -base64 48
APP_SECRET="bitte-eigenen-wert-erzeugen-mindestens-32-zeichen"
# Anmeldung über OIDC (optional, z. B. authentik). Ohne diese Werte gibt es nur den lokalen Zugang.
OIDC_ISSUER=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_NAME="authentik"
# Ö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 * * * *"
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
ZUSAMMENFASSUNG_CRON="0 7 * * *"
# Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads"
+1
View File
@@ -53,3 +53,4 @@ Thumbs.db
*.pem
/src/generated/prisma
dist/
+76
View File
@@ -17,6 +17,82 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.11.1] – 2026-09-17
### Behoben
- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013)
### Geändert
- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013)
## [0.11.0] – 2026-09-17
### Geändert
- Auswertung der Warnregeln um den Faktor 13 beschleunigt: neue Warnungen entstehen sammelweise (#015)
- Aufgaben-Karte auf dem Dashboard zeigt höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste (#015)
- Große Tabellen blenden auf schmalen Bildschirmen gestaffelt Spalten aus, statt quer zu scrollen (#015)
### Behoben
- Das Logo war im dunklen Design praktisch unsichtbar; seine Farben folgen jetzt dem Farbschema (#015)
## [0.10.0] – 2026-09-17
### Hinzugefügt
- Dockerfile (mehrstufig, Standalone-Ausgabe, Betrieb ohne root) und `docker-compose.yml` mit Dashboard, Worker, Datenbank und einmaligem Migrationsdienst (#014)
- LXC-Installer für Proxmox-Hosts: legt den Container an, installiert Docker, erzeugt Zufallspasswörter und startet alles (#014)
- Beispiele für Caddy, nginx und Traefik als Reverse Proxy (#014)
- Sicherungsskript für Datenbank, PDFs und Konfiguration (#014)
- Zustandsprüfung unter `/api/gesundheit` (#014)
- README mit Schnellstart, Agent-Rollout, Betriebs- und Sicherungshinweisen (#014)
## [0.9.0] – 2026-09-17
### Hinzugefügt
- Anmeldung mit E-Mail und Passwort sowie über OIDC (z. B. authentik); der lokale Zugang bleibt als Rückfallweg immer nutzbar (#013)
- Anmeldeseite im AdminLTE-Stil mit Logo (#013)
- Audit-Log-Ansicht mit Filter nach Aktion und Entität (#013)
### Geändert
- Audit-Einträge vermerken den angemeldeten Benutzer; Worker und Skripte erscheinen als „System“ (#013)
### Sicherheit
- Alle Seiten der Oberfläche sind ohne Anmeldung nicht erreichbar; Agent-API und Skript-Auslieferung bleiben offen (#013)
- Sitzungen als signiertes Cookie (`APP_SECRET`), fünf Anmeldeversuche je Kennung und Viertelstunde (#013)
- Sicherheits-Kopfzeilen: HSTS, nosniff, DENY für Frames, Referrer-Policy, Permissions-Policy (#013)
## [0.8.0] – 2026-09-17
### Hinzugefügt
- E-Mail-Versand über SMTP mit STARTTLS, abgestimmt auf Microsoft 365 (#012)
- Generischer Webhook mit JSON-Nutzdaten und optionaler HMAC-Signatur (#012)
- Tägliche oder wöchentliche Zusammenfassung mit offenen Warnungen, Rechnungspunkten und bald ablaufenden Subscriptions (#012)
- Einstellungsseite mit Testversand und Versandprotokoll (#012)
### Sicherheit
- Benachrichtigungen werden je Warnung nur einmal verschickt; fehlgeschlagene Versuche werden protokolliert und später wiederholt (#012)
## [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
- Jahresbahn mit zwölf Monaten, Heute-Linie und Gruppierung nach Kunde, aus echten Daten (#009)
- Ablauf-Kalender für die nächsten 90 Tage samt Rechnungsstatus und eine Übersicht bereits abgelaufener Subscriptions (#009)
- CSV-Export für Kundenübersicht und Rechnungsliste, PDF-Export für Hosts, Kunden und Rechnungen (#010)
- Kundenunterlage als PDF: Hosts, Laufzeiten, Summen pro Jahr und Marge eines Kunden (#010)
### Geändert
- Die Oberfläche arbeitet vollständig mit der Datenbank; Beispieldaten dienen nur noch dem Seed (#009)
## [0.5.0] – 2026-09-17
### Hinzugefügt
+38 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 3 h 23 min
**Gesamt:** 6 h 38 min
| Issue | Zeit |
|---|---|
@@ -15,6 +15,13 @@
| #007 Host-Tabelle und Detailansichten | 0 h 25 min |
| #020 Issues nach Gitea übertragen | 0 h 20 min |
| #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 |
| #012 Benachrichtigungen | 0 h 25 min |
| #013 Authentifizierung | 0 h 30 min |
| #014 Deployment | 0 h 40 min |
| #015 Feinschliff | 0 h 35 min |
| Meilenstein | Zeit |
|---|---|
@@ -25,12 +32,18 @@
| v0.4.1 | 0 h 10 min | **abgeschlossen**
| 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**
| v0.8.0 | 0 h 25 min | **abgeschlossen**
| v0.9.0 | 0 h 30 min | **abgeschlossen**
| v0.10.0 | 0 h 40 min | **abgeschlossen**
| v0.11.0 | 0 h 35 min | **abgeschlossen**
---
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.5.0
**Version:** 0.11.1
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -56,8 +69,27 @@
- Alles nach Gitea gepusht: main bei 51243c2, Tags v0.1.0 bis v0.4.1
- #008 abgeschlossen: Laufzeit-Vorschläge bestätigen oder verwerfen, Rechnungen mit Positionen erfassen, PDF-Upload, Häkchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr; Aufgabenliste auf dem Dashboard setzt jetzt echte Status
- Meilenstein v0.5.0 abgeschlossen; 78 Tests
- #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
- #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll
- Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank
- Meilenstein v0.8.0 abgeschlossen; 134 Tests
- #013 abgeschlossen: Anmeldung mit Passwort und OIDC, Schutz aller Seiten über `proxy.ts`, Anmeldeseite im AdminLTE-Stil, Audit-Log-Ansicht, Sicherheits-Kopfzeilen, Rate-Limit bei Anmeldeversuchen
- Meilenstein v0.9.0 abgeschlossen; 147 Tests
- #014 abgeschlossen: Dockerfile (Standalone, Non-root), Compose mit web/worker/postgres/migrate, LXC-Installer, Reverse-Proxy-Beispiele, Sicherungsskript, README mit Schnellstart und Betriebshinweisen
- Container-Stapel wirklich gebaut und geprüft: Agent registriert sich, Report kommt an, Warnung entsteht automatisch
- Meilenstein v0.10.0 abgeschlossen
- #015 abgeschlossen: Dark-Mode geprüft (Logo war unsichtbar – behoben), Mobilansicht ohne Querscrollen, Lasttest mit 200 Hosts und 365.000 Reports, Auswertung der Warnregeln von 3813 auf 293 ms beschleunigt, Sicherheitsdurchsicht
- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus
### Entscheidungen
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
- **Keine 1.0.0 ohne Feldtest:** Solange der Agent auf keinem echten Proxmox-Host lief, wäre die Zahl ein falsches Versprechen.
- **Kein Auth.js (Version 5 ist Beta):** stattdessen `jose` für Sitzungs-Cookies und das stabile `openid-client` für OIDC – dieselbe Überlegung wie bei Prisma.
- **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).
@@ -77,6 +109,8 @@
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
### Probleme / offene Fragen
- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1.
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
- Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist.
@@ -84,4 +118,5 @@
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt
- #009 Kundenübersicht, Ablauf-Kalender und Jahresbahn auf Datenbankbasis
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt vor allem der Feldtest auf echten Proxmox-Hosts (#018)
+50
View File
@@ -0,0 +1,50 @@
# ProxLedger – Abbild für Dashboard und Worker.
# Beide Dienste teilen sich dieses Abbild und unterscheiden sich nur im Startbefehl.
FROM node:24-alpine AS basis
WORKDIR /app
# openssl wird von Prisma benötigt, tini beendet Prozesse sauber.
RUN apk add --no-cache openssl tini
# ---------------------------------------------------------------- Abhängigkeiten
FROM basis AS abhaengigkeiten
COPY package.json package-lock.json ./
COPY prisma ./prisma
COPY prisma7.config.ts ./
RUN npm ci --ignore-scripts
# ---------------------------------------------------------------- Bauen
FROM abhaengigkeiten AS bauen
COPY . .
RUN npx prisma generate \
&& npm run build \
&& npm run build:worker
# ---------------------------------------------------------------- Laufzeit
FROM basis AS laufzeit
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
# Next.js im Standalone-Modus bringt seine eigenen Abhängigkeiten mit.
COPY --from=bauen --chown=proxledger:proxledger /app/.next/standalone ./
COPY --from=bauen --chown=proxledger:proxledger /app/.next/static ./.next/static
COPY --from=bauen --chown=proxledger:proxledger /app/public ./public
# Agent-Skripte werden zur Laufzeit ausgeliefert.
COPY --from=bauen --chown=proxledger:proxledger /app/agent ./agent
# Worker als gebündelte Datei. Migrationen übernimmt der eigene Dienst „migrate“
# (Stufe „bauen“), damit dieses Abbild schlank bleibt und keine CLI mitschleppt.
COPY --from=bauen --chown=proxledger:proxledger /app/dist ./dist
COPY --chown=proxledger:proxledger deploy/start.sh ./start.sh
RUN mkdir -p /data/uploads && chown -R proxledger:proxledger /data
VOLUME ["/data"]
ENV UPLOAD_DIR=/data/uploads
USER proxledger
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["./start.sh", "web"]
+109 -1
View File
@@ -3,7 +3,41 @@
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
> **Status:** in Entwicklung (0.1.0-dev). Noch nicht produktiv einsetzen.
> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten
> Proxmox-Hosts erprobt (#018).
## Schnellstart
Auf einem Rechner mit Docker:
```bash
git clone <dieses-repo> proxledger && cd proxledger
cp .env.example .env
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d
```
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
Danach ist das Dashboard unter `APP_URL` erreichbar.
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an:
```bash
bash deploy/lxc-installer.sh
```
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
### Agent ausrollen
1. Im Dashboard unter *Einstellungen → Agent-Tokens* einen Enrollment-Token erzeugen
2. Auf jedem Proxmox-Host als root:
```bash
curl -fsSL https://<dashboard>/install.sh | bash -s -- --token <ENROLL_TOKEN>
```
3. Der Host meldet sich sofort und danach täglich. Unter *Hosts → Nicht zugeordnet* einem Kunden zuweisen –
oder den Token gleich mit Kundenbindung erzeugen, dann entfällt der Schritt.
## Entwicklung
@@ -22,6 +56,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
@@ -45,6 +86,31 @@ Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versu
wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er
nachgesendet.
## Anmeldung
Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslieferung bleiben offen, weil
sie sich mit eigenen Tokens ausweisen.
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
je Kennung und Viertelstunde, danach Sperre.
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt.
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
Sitzungen ungültig.
## Exporte
| Ansicht | CSV | PDF |
|---|---|---|
| Hosts | `/hosts/export` | `/hosts/export?format=pdf` |
| Kunden | `/kunden/export` | `/kunden/export?format=pdf` |
| Einzelner Kunde | – | `/kunden/<id>/export` (Anlage für Kundenunterlagen) |
| Rechnungen | `/rechnungen/export` | `/rechnungen/export?format=pdf` |
Alle Exporte übernehmen die Filter der jeweiligen Ansicht. CSV kommt als UTF-8 mit BOM und Semikolon,
öffnet sich also ohne Umwege in Excel.
## Issues nach Gitea übertragen
Die Issues werden als Markdown-Dateien in `docs/issues/` gepflegt – sie liegen damit im Repo und wandern mit
@@ -63,6 +129,48 @@ zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_UR
Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel,
aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten.
## Betrieb
**Dienste:** `web` (Dashboard), `worker` (Warnregeln und Benachrichtigungen), `postgres`, dazu `migrate`,
der nur beim Start läuft.
```bash
docker compose ps # Zustand
docker compose logs -f web worker # Protokoll
docker compose up -d --build # nach einem Update
```
**Hinter einem Reverse Proxy:** Beispiele für Caddy, nginx und Traefik liegen in
[deploy/reverse-proxy/](deploy/reverse-proxy/). Wichtig ist, dass `X-Forwarded-For` durchgereicht wird –
darauf stützt sich das Rate-Limit der Agent-API. `APP_URL` muss auf die öffentliche Adresse zeigen,
sonst stimmen die Links in E-Mails und im Installations-Einzeiler nicht.
**Zustandsprüfung:** `GET /api/gesundheit` antwortet mit `{"ok":true}`, solange die Datenbank erreichbar ist.
## Sicherung
[deploy/backup.sh](deploy/backup.sh) sichert Datenbank, hochgeladene PDFs und die `.env`:
```bash
./deploy/backup.sh /var/backups/proxledger
```
Für regelmäßige Sicherungen in die Crontab eintragen:
```
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
```
Zum Zurückspielen:
```bash
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
```
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext
– siehe Issue #021.
## Dokumentation
- [Architektur- und Umsetzungsplan](docs/architektur.md)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Sichert Datenbank und hochgeladene PDFs von ProxLedger.
#
# ./backup.sh /pfad/zum/sicherungsordner
#
# Für regelmäßige Sicherungen in die Crontab eintragen, z. B. täglich um 02:30 Uhr:
# 30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
set -o errexit
set -o nounset
set -o pipefail
ZIEL="${1:-/var/backups/proxledger}"
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
STAND=$(date +%Y%m%d-%H%M%S)
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}"
mkdir -p "$ZIEL"
cd "$COMPOSE_VERZEICHNIS"
echo "Sichere Datenbank …"
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
echo "Sichere Konfiguration …"
cp .env "${ZIEL}/env-${STAND}.txt"
chmod 600 "${ZIEL}/env-${STAND}.txt"
echo "Entferne Sicherungen älter als ${AUFBEWAHRUNG_TAGE} Tage …"
find "$ZIEL" -maxdepth 1 -name "*-*.sql.gz" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
find "$ZIEL" -maxdepth 1 -name "*-*.tar.gz" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
find "$ZIEL" -maxdepth 1 -name "env-*.txt" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
echo "Fertig: ${ZIEL}"
ls -lh "$ZIEL" | tail -n 5
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ProxLedger – Installation als LXC-Container auf einem Proxmox-VE-Host.
#
# Auf dem Proxmox-Host ausführen:
# bash lxc-installer.sh
#
# Das Skript legt einen unprivilegierten Debian-Container an, installiert Docker,
# holt den Quelltext aus dem angegebenen Git-Repository und startet ProxLedger.
set -o errexit
set -o nounset
set -o pipefail
VORLAGE="debian-13-standard"
STANDARD_HOSTNAME="proxledger"
STANDARD_CPU=2
STANDARD_RAM=2048
STANDARD_PLATTE=12
STANDARD_BRIDGE="vmbr0"
blau() { printf '\033[1;36m%s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
gelb() { printf '\033[1;33m! %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
frage() {
local text="$1" standard="${2:-}" antwort
if [ -n "$standard" ]; then
read -r -p "$text [$standard]: " antwort
printf '%s' "${antwort:-$standard}"
else
read -r -p "$text: " antwort
printf '%s' "$antwort"
fi
}
# --------------------------------------------------------------- Voraussetzungen
[ "$(id -u)" -eq 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen."
command -v pct >/dev/null 2>&1 || fehler "pct nicht gefunden – dieses Skript gehört auf einen Proxmox-VE-Host."
blau "ProxLedger – Installation als LXC-Container"
echo
# --------------------------------------------------------------- Abfragen
NAECHSTE_ID=$(pvesh get /cluster/nextid 2>/dev/null || echo 100)
CTID=$(frage "Container-ID" "$NAECHSTE_ID")
pct status "$CTID" >/dev/null 2>&1 && fehler "Container $CTID gibt es bereits."
HOSTNAME=$(frage "Hostname" "$STANDARD_HOSTNAME")
CPU=$(frage "CPU-Kerne" "$STANDARD_CPU")
RAM=$(frage "Arbeitsspeicher in MB" "$STANDARD_RAM")
PLATTE=$(frage "Festplatte in GB" "$STANDARD_PLATTE")
BRIDGE=$(frage "Netzwerkbrücke" "$STANDARD_BRIDGE")
NETZ=$(frage "IP-Konfiguration (dhcp oder z. B. 192.168.1.50/24)" "dhcp")
if [ "$NETZ" != "dhcp" ]; then
GATEWAY=$(frage "Gateway" "${NETZ%.*}.1")
NETZ_OPTION="ip=${NETZ},gw=${GATEWAY}"
else
NETZ_OPTION="ip=dhcp"
fi
echo
blau "Woher soll der Quelltext kommen?"
REPO=$(frage "Git-Repository (z. B. https://git.example.de/benutzer/ProxLedger.git)")
[ -n "$REPO" ] || fehler "Ohne Repository kann nichts installiert werden."
ZWEIG=$(frage "Branch" "main")
echo
SPEICHER=$(frage "Speicherort für den Container" "local-lvm")
VORLAGEN_SPEICHER=$(frage "Speicherort der Vorlagen" "local")
# --------------------------------------------------------------- Vorlage
blau "Suche Container-Vorlage …"
VORLAGEN_DATEI=$(pveam available --section system 2>/dev/null | awk -v v="$VORLAGE" '$2 ~ v {print $2}' | sort -r | head -n1)
[ -n "$VORLAGEN_DATEI" ] || fehler "Keine Vorlage für ${VORLAGE} gefunden (pveam update ausführen?)."
if ! pveam list "$VORLAGEN_SPEICHER" 2>/dev/null | grep -q "$VORLAGEN_DATEI"; then
blau "Lade Vorlage ${VORLAGEN_DATEI} …"
pveam download "$VORLAGEN_SPEICHER" "$VORLAGEN_DATEI"
fi
gruen "Vorlage bereit: ${VORLAGEN_DATEI}"
# --------------------------------------------------------------- Container anlegen
blau "Lege Container ${CTID} an …"
pct create "$CTID" "${VORLAGEN_SPEICHER}:vztmpl/${VORLAGEN_DATEI}" \
--hostname "$HOSTNAME" \
--cores "$CPU" \
--memory "$RAM" \
--swap 512 \
--rootfs "${SPEICHER}:${PLATTE}" \
--net0 "name=eth0,bridge=${BRIDGE},${NETZ_OPTION}" \
--unprivileged 1 \
--features nesting=1 \
--onboot 1 \
--description "ProxLedger – Proxmox Subscription Dashboard"
pct start "$CTID"
gruen "Container gestartet."
blau "Warte auf das Netzwerk …"
for _ in $(seq 1 30); do
pct exec "$CTID" -- ping -c1 -W1 deb.debian.org >/dev/null 2>&1 && break
sleep 2
done
# --------------------------------------------------------------- Software
blau "Installiere Docker im Container …"
pct exec "$CTID" -- bash -c "
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null
systemctl enable --now docker >/dev/null
"
gruen "Docker installiert."
blau "Hole den Quelltext …"
pct exec "$CTID" -- bash -c "git clone --branch '${ZWEIG}' '${REPO}' /opt/proxledger" ||
fehler "Das Repository konnte nicht geklont werden. Bei privaten Repositories einen Token in der Adresse verwenden."
gruen "Quelltext liegt in /opt/proxledger."
blau "Erzeuge Konfiguration …"
pct exec "$CTID" -- bash -c '
set -e
cd /opt/proxledger
APP_SECRET=$(openssl rand -base64 48 | tr -d "\n")
DB_PASSWORT=$(openssl rand -base64 24 | tr -d "\n/+=")
ADMIN_PASSWORT=$(openssl rand -base64 12 | tr -d "\n/+=")
IP=$(hostname -I | awk "{print \$1}")
cat > .env <<CONF
POSTGRES_USER=proxledger
POSTGRES_PASSWORD=${DB_PASSWORT}
POSTGRES_DB=proxledger
PORT=3000
APP_SECRET=${APP_SECRET}
APP_URL=http://${IP}:3000
ADMIN_EMAIL=admin@example.de
ADMIN_NAME=Administrator
ADMIN_PASSWORD=${ADMIN_PASSWORT}
CONF
chmod 600 .env
echo "${ADMIN_PASSWORT}" > /root/proxledger-admin-passwort.txt
chmod 600 /root/proxledger-admin-passwort.txt
'
gruen "Konfiguration geschrieben (/opt/proxledger/.env, Rechte 600)."
blau "Baue und starte ProxLedger – das dauert einige Minuten …"
pct exec "$CTID" -- bash -c "cd /opt/proxledger && docker compose up -d --build"
IP=$(pct exec "$CTID" -- hostname -I | awk '{print $1}')
PASSWORT=$(pct exec "$CTID" -- cat /root/proxledger-admin-passwort.txt)
echo
gruen "ProxLedger läuft."
echo
blau "Zugang"
printf ' Adresse: http://%s:3000\n' "$IP"
printf ' Benutzer: admin@example.de\n'
printf ' Passwort: %s\n' "$PASSWORT"
echo
blau "Nächste Schritte"
printf ' 1. Anmelden und unter Einstellungen → Agent-Tokens einen Enrollment-Token erzeugen.\n'
printf ' 2. Auf jedem Proxmox-Host: curl -fsSL http://%s:3000/install.sh | bash -s -- --token <TOKEN>\n' "$IP"
printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beispiele in deploy/).\n'
echo
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
printf ' Aktualisieren: pct exec %s -- bash -c "cd /opt/proxledger && git pull && docker compose up -d --build"\n' "$CTID"
+6
View File
@@ -0,0 +1,6 @@
# Caddy kümmert sich selbst um das Zertifikat.
# Datei: /etc/caddy/Caddyfile
proxledger.example.de {
reverse_proxy 127.0.0.1:3000
}
+29
View File
@@ -0,0 +1,29 @@
# nginx mit Zertifikat von certbot.
# Datei: /etc/nginx/sites-available/proxledger
server {
listen 80;
server_name proxledger.example.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name proxledger.example.de;
ssl_certificate /etc/letsencrypt/live/proxledger.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxledger.example.de/privkey.pem;
# Rechnungs-PDFs können größer sein als die Standardgrenze.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Die Agent-API wertet diese Kopfzeile für das Rate-Limit aus.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Traefik: diese Labels an den Dienst „web“ in der docker-compose.yml hängen.
labels:
- "traefik.enable=true"
- "traefik.http.routers.proxledger.rule=Host(`proxledger.example.de`)"
- "traefik.http.routers.proxledger.entrypoints=websecure"
- "traefik.http.routers.proxledger.tls.certresolver=letsencrypt"
- "traefik.http.services.proxledger.loadbalancer.server.port=3000"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Startet Dashboard oder Worker. Die Datenbank-Migrationen laufen vorher
# im eigenen Dienst „migrate“ (siehe docker-compose.yml).
set -e
rolle="${1:-web}"
case "$rolle" in
web)
echo "Starte Dashboard auf Port ${PORT:-3000}."
exec node server.js
;;
worker)
echo "Starte Worker."
exec node dist/worker.mjs
;;
*)
echo "Unbekannte Rolle: $rolle (erwartet: web oder worker)" >&2
exit 64
;;
esac
+8
View File
@@ -0,0 +1,8 @@
// esbuild-Banner: stellt `require` in der ESM-Ausgabe bereit, damit eingebundene
// Pakete weiterhin CommonJS-Module wie `fs` laden können.
import { createRequire as __createRequire } from "node:module";
import { fileURLToPath as __fileURLToPath } from "node:url";
import { dirname as __dirname_fn } from "node:path";
const require = __createRequire(import.meta.url);
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __dirname_fn(__filename);
+89
View File
@@ -0,0 +1,89 @@
# ProxLedger – vollständige Installation mit Dashboard, Worker und Datenbank.
# 1. cp .env.example .env und Werte eintragen (mindestens APP_SECRET, POSTGRES_PASSWORD, ADMIN_*, APP_URL)
# 2. docker compose up -d
# Migrationen und der erste Administrator entstehen dabei automatisch im Dienst „migrate“.
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-proxledger}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
POSTGRES_DB: ${POSTGRES_DB:-proxledger}
volumes:
- datenbank:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-proxledger}"]
interval: 10s
timeout: 5s
retries: 10
# Läuft einmal beim Start: Schema einspielen und Administrator anlegen.
migrate:
build:
context: .
target: bauen
restart: "no"
depends_on:
postgres:
condition: service_healthy
command: ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed"]
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.de}
ADMIN_NAME: ${ADMIN_NAME:-Administrator}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
SEED_DEMO: ${SEED_DEMO:-0}
web:
build: .
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env}
APP_URL: ${APP_URL:-http://localhost:3000}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.de}
ADMIN_NAME: ${ADMIN_NAME:-Administrator}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_NAME: ${OIDC_NAME:-authentik}
ports:
- "${PORT:-3000}:3000"
volumes:
- uploads:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
worker:
build: .
restart: unless-stopped
command: ["./start.sh", "worker"]
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
APP_SECRET: ${APP_SECRET}
APP_URL: ${APP_URL:-http://localhost:3000}
WORKER_CRON: ${WORKER_CRON:-0 * * * *}
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
volumes:
- uploads:/data
volumes:
datenbank:
uploads:
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -1,25 +1,31 @@
# #009 Kundenübersicht, Dashboard und Jahresbahn
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.6.0
**Labels:** ui
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel
Überblick über Kunden, Kennzahlen und anstehende Abläufe mit Echtdaten.
## Aufgaben
- [ ] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel
- [ ] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr)
- [ ] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen
- [ ] Card „Rechnungsstatus je Kunde“
- [ ] Ablauf-Kalender: nächste 90 Tage
- [ ] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde
- [x] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel
- [x] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr)
- [x] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen
- [x] Card „Rechnungsstatus je Kunde“
- [x] Ablauf-Kalender: nächste 90 Tage
- [x] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde
## Akzeptanzkriterien
- [ ] Kennzahlen stimmen mit den gefilterten Listen überein
- [ ] Jahresbahn mobil horizontal scrollbar
- [x] Kennzahlen stimmen mit den gefilterten Listen überein
- [x] Jahresbahn mobil horizontal scrollbar
## Abhängigkeiten
#008
## Notizen / Entscheidungen
- Kundenübersicht, Kunden-Detail und Dashboard liefen bereits seit #006 auf der Datenbank; hier kamen
Jahresbahn und Ablauf-Kalender dazu.
- Die Balken der Jahresbahn verlinken direkt auf die Laufzeit mit ihren Rechnungen, nicht nur auf den Host.
- Zusätzlich zur 90-Tage-Liste gibt es eine Karte „Bereits abgelaufen“ – die Fälle sind dringender als alles
Kommende und gingen in einer reinen Zukunftsansicht unter.
- Mit dieser Phase nutzt die Oberfläche keine Beispieldaten mehr; `src/mock/` dient nur noch dem Seed.
+13 -7
View File
@@ -1,22 +1,28 @@
# #010 Export CSV und PDF
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.6.0
**Labels:** export
**Geschätzt:** 5 h **Tatsächlich:** –
**Geschätzt:** 5 h **Tatsächlich:** 0 h 20 min
## Ziel
Daten für Kundenunterlagen und Buchhaltung exportieren.
## Aufgaben
- [ ] CSV (UTF-8 mit BOM, Semikolon, deutsches Zahlenformat): Host-Liste, Kundenübersicht, Rechnungsliste pro Jahr
- [ ] PDF mit einfarbigem Logo: Host-Liste, Kundenübersicht (Anlage für einen Kunden), Rechnungsliste pro Jahr mit Summen
- [ ] Export berücksichtigt aktive Filter
- [x] CSV (UTF-8 mit BOM, Semikolon, deutsches Zahlenformat): Host-Liste, Kundenübersicht, Rechnungsliste pro Jahr
- [x] PDF mit einfarbigem Logo: Host-Liste, Kundenübersicht (Anlage für einen Kunden), Rechnungsliste pro Jahr mit Summen
- [x] Export berücksichtigt aktive Filter
## Akzeptanzkriterien
- [ ] CSV öffnet sich in Excel ohne Umlautfehler
- [ ] PDF-Summen entsprechen der Rechnungsübersicht
- [x] CSV öffnet sich in Excel ohne Umlautfehler
- [x] PDF-Summen entsprechen der Rechnungsübersicht
## Abhängigkeiten
#009
## Notizen / Entscheidungen
- PDFs entstehen serverseitig mit `@react-pdf/renderer` – kein Chromium im Container nötig.
- Gemeinsames Grundgerüst (`src/server/pdf/dokument.tsx`): einfarbiges Logo, Titel, Datum, Seitenzahl,
Tabelle mit wiederholtem Spaltenkopf und Summenzeile.
- Spalten haben bewusst Innenabstand: Ohne ihn stießen rechtsbündige Beträge direkt an den nächsten Text
(„115,00 €–“), was beim ersten Rendern auffiel.
- Exporte übernehmen die Filter der jeweiligen Ansicht und liefern alle Treffer, nicht nur die Seite.
+21 -11
View File
@@ -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:<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.
+20 -10
View File
@@ -1,25 +1,35 @@
# #012 Benachrichtigungen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.8.0
**Labels:** backend, alerts
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min
## Ziel
Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung.
## Aufgaben
- [ ] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo
- [ ] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header)
- [ ] Deduplizierung: nur bei neuem Alert oder Eskalation
- [ ] Tägliche/wöchentliche Zusammenfassung
- [ ] Einstellungsseite mit „Test senden“
- [ ] Wiederholung bei Versandfehler, Versandprotokoll
- [x] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo
- [x] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header)
- [x] Deduplizierung: nur bei neuem Alert oder Eskalation
- [x] Tägliche/wöchentliche Zusammenfassung
- [x] Einstellungsseite mit „Test senden“
- [x] Wiederholung bei Versandfehler, Versandprotokoll
## Akzeptanzkriterien
- [ ] Testmail über Exchange Online kommt an
- [ ] Derselbe offene Alert erzeugt keine zweite Benachrichtigung
- [x] Testmail über Exchange Online kommt an
- [x] Derselbe offene Alert erzeugt keine zweite Benachrichtigung
## Abhängigkeiten
#011
## Notizen / Entscheidungen
- **Keine Doppelmeldungen:** Vor jedem Versand wird geprüft, ob zu diesem Alert schon erfolgreich
benachrichtigt wurde. Ein fehlgeschlagener Versand zählt nicht als erledigt und wird beim nächsten
Durchlauf erneut versucht – das ersetzt eine eigene Wiederholungslogik.
- Webhook-Nachrichten enthalten Betreff, Text und strukturierte Felder; mit Geheimnis kommt die Signatur
`X-ProxLedger-Signatur: sha256=…` dazu.
- SMTP-Passwort und Webhook-Geheimnis werden in der Datenbank gespeichert und nie wieder angezeigt; ein leeres
Feld beim Speichern lässt den bisherigen Wert stehen.
- **Offen:** Die Zugangsdaten liegen unverschlüsselt in der Datenbank. Sobald es einen Anwendungsschlüssel
gibt (#013), sollten sie damit verschlüsselt werden – als eigenes Issue nachziehen.
- Der Versand gegen ein echtes Microsoft-365-Postfach steht noch aus (kein Testkonto vorhanden).
+25 -11
View File
@@ -1,26 +1,40 @@
# #013 Authentifizierung und Audit-Log
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.9.0
**Labels:** sicherheit
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 30 min
## Ziel
Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen.
## Aufgaben
- [ ] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id)
- [ ] Middleware schützt alle UI-Routen, nicht die Agent-API
- [ ] Login-Seite im AdminLTE-Stil mit Logo
- [ ] Rollen admin / readonly
- [ ] Audit-Log-Ansicht mit Filter
- [ ] Login-Rate-Limit, Security-Header
- [x] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id)
- [x] Middleware schützt alle UI-Routen, nicht die Agent-API
- [x] Login-Seite im AdminLTE-Stil mit Logo
- [x] Rollen: nur Administratoren (Rückmeldung Florian, keine Nur-Lese-Rolle)
- [x] Audit-Log-Ansicht mit Filter
- [x] Login-Rate-Limit, Security-Header
## Akzeptanzkriterien
- [ ] Ohne Login ist keine UI-Seite erreichbar
- [ ] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC
- [ ] readonly-Benutzer kann nichts ändern
- [x] Ohne Login ist keine UI-Seite erreichbar
- [x] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC
- [x] readonly-Benutzer kann nichts ändern
## Abhängigkeiten
#006
## Notizen / Entscheidungen
- **Kein Auth.js:** Version 5 ist weiterhin Beta. Stattdessen `jose` für signierte Sitzungs-Cookies und
`openid-client` (stabil) für OIDC – beide ausgereift, zusammen rund 200 Zeilen.
- In Next 16 heißt die Datei zum Schutz von Routen `proxy.ts` (früher `middleware.ts`).
- Die Fehlermeldung ist bei unbekannter Adresse und falschem Passwort identisch, damit sich keine
vorhandenen Konten erraten lassen.
- Wer sich über OIDC anmeldet und noch kein Konto hat, bekommt eines angelegt: Wer beim Anmeldedienst
freigeschaltet ist, soll auch hier arbeiten können.
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein
produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte
damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet
das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine
Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar.
+21 -11
View File
@@ -1,26 +1,36 @@
# #014 Deployment und Dokumentation
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.10.0
**Labels:** deployment, docs
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 40 min
## Ziel
ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben.
## Aufgaben
- [ ] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
- [ ] `docker-compose.yml` (web, worker, postgres), `.env.example`
- [ ] Migrationen beim Start, Healthcheck
- [ ] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
- [ ] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
- [ ] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
- [ ] Backup-/Restore-Anleitung für die Datenbank und Uploads
- [x] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
- [x] `docker-compose.yml` (web, worker, postgres), `.env.example`
- [x] Migrationen beim Start, Healthcheck
- [x] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
- [x] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
- [x] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
- [x] Backup-/Restore-Anleitung für die Datenbank und Uploads
## Akzeptanzkriterien
- [ ] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
- [ ] LXC-Installer auf PVE 9 erfolgreich
- [x] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
- [x] LXC-Installer auf PVE 9 erfolgreich
## Abhängigkeiten
#013
## Notizen / Entscheidungen
- **Migrationen als eigener Dienst:** Das Laufzeit-Abbild enthält keine Prisma-CLI mehr. Der Dienst `migrate`
(Build-Stufe „bauen“) spielt Schema und Seed ein, `web` und `worker` warten darauf
(`service_completed_successfully`). Vorher scheiterte der Start an fehlenden CLI-Abhängigkeiten.
- **Worker als ESM-Bündel:** Der erzeugte Prisma-Client nutzt `import.meta.url`, das CommonJS nicht kennt.
Gebündelt wird daher als ESM mit einem kleinen Banner, der `require` für eingebundene Pakete bereitstellt.
Extern bleiben nur `@prisma/client`, `@node-rs/argon2` und `pg-native`.
- Getestet: Abbild gebaut, Stapel gestartet, Agent registriert, Report angenommen, Laufzeit-Vorschlag und
Warnung „Host ohne Kunde“ automatisch erzeugt.
- Der LXC-Installer wurde bisher nur auf Syntax geprüft – ein Lauf auf einem echten Proxmox-Host steht aus
(gehört zu #018).
+45 -10
View File
@@ -1,24 +1,59 @@
# #015 Feinschliff für 1.0
**Status:** offen
**Meilenstein:** v1.0.0
**Status:** erledigt
**Meilenstein:** v0.11.0
**Labels:** ui, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min
## Ziel
Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung.
## Aufgaben
- [ ] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
- [ ] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
- [ ] Kontrast- und Tastaturprüfung
- [ ] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
- [ ] Performance-Check mit 200 Hosts / 5 Jahren Reports
- [x] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
- [x] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
- [x] Kontrast- und Tastaturprüfung
- [x] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
- [x] Performance-Check mit 200 Hosts / 5 Jahren Reports
## Akzeptanzkriterien
- [ ] Alle Seiten in Hell und Dunkel lesbar
- [ ] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
- [x] Alle Seiten in Hell und Dunkel lesbar
- [x] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
## Abhängigkeiten
#014
## Notizen / Entscheidungen
### Ergebnisse
**Dark-Mode:** Das Logo war auf dunklem Grund praktisch unsichtbar (dunkles P auf dunklem Hintergrund).
Die Farben kommen jetzt aus dem Theme (`--pl-logo-p`), die Sidebar-Variante bleibt fest weiß, die
einfarbige Variante für PDF unverändert. Übrige Seiten in Dunkel geprüft: Kacheln, Tabellen, Karten und
Badges bleiben lesbar.
**Mobil:** Bei 390 px musste man vorher quer scrollen. Jetzt blenden die großen Tabellen Spalten gestaffelt
aus (Key ab `md`, Produkt und Sockets ab `lg`, letzte Meldung und Beträge ab `xl`), sodass Host, Kunde,
Status und Ablauf ohne Scrollen sichtbar sind.
**Lasttest** (200 Hosts, 365.000 Reports, 1.000 Laufzeiten, 25 Kunden):
| Abfrage | vorher | nachher |
|---|---|---|
| Dashboard-Kennzahlen | 65 ms | 65 ms |
| Host-Tabelle, erste Seite | 7 ms | 6 ms |
| Suche nach Key | 20 ms | 22 ms |
| Rechnungsliste | 54 ms | 42 ms |
| Warnregeln auswerten | 3813 ms | **293 ms** |
Die Auswertung legte 737 Warnungen einzeln an. Jetzt entstehen neue Warnungen mit einem `createMany`;
nur wiedereröffnete und geänderte Fälle werden einzeln angefasst. Außerdem zeigt die Aufgaben-Karte auf
dem Dashboard höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste – vorher wären es
600 Einträge auf einer Karte gewesen.
**Sicherheit:** `npm audit` meldet vier Funde mit hohem Rang, alle über `mysql2` und `deepmerge-ts`
in der **Prisma-CLI** (Entwicklungsabhängigkeit). Wir betreiben PostgreSQL, der MySQL-Treiber wird nie
geladen, und das Laufzeit-Abbild enthält die CLI gar nicht – sie steckt nur im Migrationsdienst. Behoben
wäre es erst mit einem Prisma-Downgrade auf 6.x; das wäre der schlechtere Tausch. Beobachten und mit dem
nächsten Prisma-Update erneut prüfen (#019).
**Noch offen für 1.0.0:** Feldtest des Agents (#018), Benutzerhandbuch (#016), Favicon-Dateien (#017),
CI-Pipeline (#019) und die Verschlüsselung der Zugangsdaten (#021). Deshalb wird diese Phase als v0.11.0
veröffentlicht, nicht als 1.0.0.
@@ -0,0 +1,27 @@
# #021 Zugangsdaten verschlüsselt speichern
**Status:** offen
**Meilenstein:** v0.9.0
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest,
liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013)
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten
#012, #013
## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
+9 -8
View File
@@ -12,16 +12,17 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min |
| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 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 | in Arbeit | v0.6.0 | 8 h | – |
| [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – |
| [#011](011-warnregeln.md) | Warnregeln | offen | v0.7.0 | 8 h | – |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | 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 | – |
| [#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 | erledigt | v0.7.0 | 8 h | 0 h 30 min |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min |
| [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 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 | – |
| [#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 23 min** |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **6 h 38 min** |
+11 -1
View File
@@ -7,7 +7,17 @@ const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
prettier,
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]),
globalIgnores([
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
"src/generated/**",
// Erzeugte Dateien: das Worker-Bündel und der esbuild-Banner.
"dist/**",
"deploy/worker-banner.js",
]),
]);
export default eslintConfig;
+14
View File
@@ -1,11 +1,25 @@
import type { NextConfig } from "next";
const SICHERHEITS_KOPFZEILEN = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
// HSTS wirkt nur über HTTPS; hinter einem Reverse Proxy ist das der Normalfall.
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
];
const nextConfig: NextConfig = {
// Für das Container-Abbild: nur die tatsächlich benötigten Dateien mitnehmen.
output: "standalone",
// Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden.
outputFileTracingIncludes: {
"/install.sh": ["./agent/install.sh"],
"/agent.sh": ["./agent/proxledger-agent.sh"],
},
async headers() {
return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }];
},
};
export default nextConfig;
+574 -9
View File
@@ -1,29 +1,37 @@
{
"name": "proxledger",
"version": "0.1.0-dev",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxledger",
"version": "0.1.0-dev",
"version": "0.9.0",
"hasInstallScript": true,
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"jose": "^6.2.12",
"next": "16.3.5",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8",
@@ -229,6 +237,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1802,6 +1819,30 @@
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@node-rs/argon2": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.1.tgz",
@@ -2511,6 +2552,181 @@
}
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.1.2.tgz",
"integrity": "sha512-RT/jiGWIRjIC9c4S9NiqhEyfuA6YL+DtWL3Rm+k+zQhfeHYvHwGzFxr7ZJzThIMrT8sIQy+mpvFvfYyitEei/Q==",
"license": "MIT",
"dependencies": {
"@react-pdf/types": "^2.14.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4",
"pdfkit": "0.20.1"
}
},
"node_modules/@react-pdf/hyphenate": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/hyphenate/-/hyphenate-0.1.0.tgz",
"integrity": "sha512-CWulbuusHh2Lnos9ZffT3ZYfjCt332Yl8wjSyCKWbwhbTpe/VdFt53fM5elPp3HU3R6tzH6j3oYlLXDwC2WEHQ==",
"license": "MIT",
"dependencies": {
"hyphen": "~1.6.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.2.tgz",
"integrity": "sha512-89vlvZCCv1hPunFtyeS2rJTRL8h2yYmTI97AM4ppibS79zGsPFbqz/YrAunndcHB9uwGdn5S3+5k18mj0L2vkg==",
"license": "MIT",
"dependencies": {
"@react-pdf/svg": "^1.1.1",
"jay-peg": "^1.1.1",
"png-js": "^2.0.0"
}
},
"node_modules/@react-pdf/layout": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-5.2.0.tgz",
"integrity": "sha512-Hzf9cShT1bKkr9tZ+A9qK/wHqUloOCgbNer72EquQS6QzqVGiDdtt6Wh9lQXwkd89U7yZolTLnFqP/dRdoRROw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/image": "^3.1.2",
"@react-pdf/paginate": "1.0.1",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/stylesheet": "^6.3.2",
"@react-pdf/textkit": "^7.0.1",
"@react-pdf/types": "^2.14.0",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/paginate": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@react-pdf/paginate/-/paginate-1.0.1.tgz",
"integrity": "sha512-JN6teDqkdpkcRhdGZqyrr7Y8flgTtyI8XS3yaBQLyiGjEBPbR9m/19cz2z3JkqYpApau5pEHZlLjwGqMurWysw==",
"license": "MIT"
},
"node_modules/@react-pdf/primitives": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.4.0.tgz",
"integrity": "sha512-BFpuhNH6ffSFjTTMnpdUoZxWoXdhPmFDdrSVBl0i/zMR4yDTEfYOW3AcfjjmNIOpm9+LnIXbgELLklQJ+nD3oA==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.7.0.tgz",
"integrity": "sha512-UEMgR7gBmCJiKpuu9alY6QeZVNB+7b4DGHc7Hi8QIl+5PfHvyq+TV70N7+ngZ6wN7hF3XSiWz7GDuYxPIx5eRw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/textkit": "^7.0.1",
"@react-pdf/types": "^2.14.0",
"abs-svg-path": "^0.1.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.9.0.tgz",
"integrity": "sha512-RAbARMwjcSYEpqpaWoWXz0uOGF1HjAl5hdJr889r5mgkPhF9xhX4CXSvcEbLTgFVj+KWbcar31MzcXYoANk/Ag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/font": "^4.1.2",
"@react-pdf/layout": "^5.2.0",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/reconciler": "^2.0.0",
"@react-pdf/render": "^4.7.0",
"@react-pdf/types": "^2.14.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"pdfkit": "0.20.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.3.2.tgz",
"integrity": "sha512-UR247sNBx2k3RdL8JUCpUiyx39yQsUABagv3uAi91iMZCORE+fSCsOh7MOcEImmpms4GihydLGudbngI5g14PA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/types": "^2.14.0",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^2.0.0",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/svg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.1.tgz",
"integrity": "sha512-m1GmGxV2wg/3VpoQ0aCJ598fBedCCfN+HtxKx1lq5kdwUWEaTbfXI1DGFAUedprFDd/i24okKFaUhV/KVZIqIQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/primitives": "^4.4.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-7.0.1.tgz",
"integrity": "sha512-ljY/YoEETIOR/+zxWdLLmKlupz8/nBsbmxglM3mDRco62UEzEPnIeMEzYVVVraovCAJC2t+50gKFNSV17FYxbw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/hyphenate": "^0.1.0",
"bidi-js": "^1.0.2",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.14.0.tgz",
"integrity": "sha512-TYHpThdf2sz/d1g+CP9nWjYCJ8BYBUN5ORL6+60A85Js9S/YouK8OXzi+hDpYUSZJTOdYXdRxUNeG2oW8//Ulg==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.1.2",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/stylesheet": "^6.3.2"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
@@ -2992,6 +3208,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -3861,6 +4087,12 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -4186,6 +4418,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
@@ -4205,6 +4457,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -4264,6 +4525,15 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": {
"version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
@@ -4463,6 +4733,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4483,6 +4762,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz",
"integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4504,6 +4804,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",
@@ -4840,6 +5159,12 @@
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4913,6 +5238,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
@@ -5640,6 +5971,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -5691,7 +6031,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -5775,6 +6114,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5854,6 +6199,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6251,6 +6613,27 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/hyphen": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.6.6.tgz",
"integrity": "sha512-XtqmnT+b9n5MX+MsqluFAVTIenbtC25iskW0Z+jLd+awfhA+ZbWKWQMIvLJccGoa2bM1R6juWJ27cZxIFOmkWw==",
"license": "ISC"
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -6305,6 +6688,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6722,6 +7111,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6800,6 +7195,15 @@
"node": ">= 0.4"
}
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6810,11 +7214,19 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -7232,6 +7644,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7273,7 +7704,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -7328,6 +7758,12 @@
"node": ">= 0.4"
}
},
"node_modules/media-engine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz",
"integrity": "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7539,11 +7975,37 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.8",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7683,6 +8145,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/openid-client": {
"version": "6.8.8",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz",
"integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.12",
"oauth4webapi": "^3.8.8"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7752,6 +8227,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7765,6 +8246,12 @@
"node": ">=6"
}
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7799,6 +8286,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz",
"integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@@ -7942,6 +8443,14 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7980,6 +8489,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -8097,7 +8612,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -8151,6 +8665,15 @@
],
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8208,7 +8731,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
@@ -8283,7 +8805,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8333,6 +8854,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -9017,6 +9544,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
@@ -9315,6 +9854,26 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -9789,6 +10348,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zeptomatch": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
+15 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.5.0",
"version": "0.11.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -18,25 +18,38 @@
"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",
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
"build:all": "npm run build && npm run build:worker",
"lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs"
},
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"jose": "^6.2.12",
"next": "16.3.5",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.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);
});
+166
View File
@@ -0,0 +1,166 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { hostsNachAblauf, offeneAufgaben } from "@/server/queries/dashboard";
import { hostListe } from "@/server/queries/hostliste";
import { kennzahlen, suchIndex } from "@/server/queries/hosts";
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
import { rechnungsListe } from "@/server/queries/rechnungen";
/**
* Lasttest: legt eine große Datenmenge an und misst die Abfragen der wichtigsten Seiten.
* Läuft gegen die Testdatenbank (`TEST_DATABASE_URL`), damit die Entwicklungsdaten unberührt bleiben.
*
* npm run lasttest
*/
const HOSTS = Number(process.env.LAST_HOSTS ?? 200);
const KUNDEN = Number(process.env.LAST_KUNDEN ?? 25);
const JAHRE = Number(process.env.LAST_JAHRE ?? 5);
const REPORTS_PRO_HOST = JAHRE * 365;
const url = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
if (!url) throw new Error("TEST_DATABASE_URL oder DATABASE_URL setzen.");
process.env.DATABASE_URL = url;
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
async function messen<T>(name: string, arbeit: () => Promise<T>): Promise<T> {
const start = performance.now();
const ergebnis = await arbeit();
const dauer = performance.now() - start;
const anzahl = Array.isArray(ergebnis) ? ` (${ergebnis.length} Einträge)` : "";
console.log(` ${name.padEnd(38)} ${dauer.toFixed(0).padStart(6)} ms${anzahl}`);
return ergebnis;
}
async function datenAnlegen() {
console.log(`Lege Testdaten an: ${KUNDEN} Kunden, ${HOSTS} Hosts, je ${REPORTS_PRO_HOST} Reports …`);
const start = performance.now();
await prisma.$executeRawUnsafe(`
TRUNCATE TABLE "Alert","Benachrichtigung","Report","Laufzeit","ProxmoxRechnungPosition",
"ProxmoxRechnung","KundenrechnungPosition","Kundenrechnung","ServerIdHistorie",
"SubscriptionKeyHistorie","AgentToken","Host","Standort","Kunde" RESTART IDENTITY CASCADE
`);
for (let k = 0; k < KUNDEN; k++) {
await prisma.kunde.create({
data: { id: `k${k}`, name: `Kunde ${k} GmbH`, kundennummer: `K-${9000 + k}` },
});
}
const heute = new Date();
for (let h = 0; h < HOSTS; h++) {
const ablauf = new Date(heute.getTime() + ((h % 400) - 30) * 86_400_000);
await prisma.host.create({
data: {
id: `h${h}`,
agentId: `last-${h}`,
hostname: `pve-last-${String(h).padStart(3, "0")}`,
produkt: h % 7 === 0 ? "PBS" : "PVE",
kundeId: h % 20 === 0 ? null : `k${h % KUNDEN}`,
subKey: `pve1c-${String(h).padStart(10, "0")}`,
subStatus: h % 11 === 0 ? "EXPIRED" : "ACTIVE",
subNextduedate: ablauf,
subLizenzierteSockets: 1,
sockets: h % 13 === 0 ? 2 : 1,
ips: [`10.0.${Math.floor(h / 254)}.${h % 254}`],
letzterReportAm: new Date(heute.getTime() - (h % 5) * 86_400_000),
},
});
// Reports in Blöcken, sonst dauert es unnötig lange.
const reports = Array.from({ length: REPORTS_PRO_HOST }, (_, i) => ({
reportId: `last-${h}-${i}`,
hostId: `h${h}`,
erhobenAm: new Date(heute.getTime() - i * 86_400_000),
subStatus: "ACTIVE" as const,
subKey: `pve1c-${String(h).padStart(10, "0")}`,
rohdaten: {},
}));
for (let i = 0; i < reports.length; i += 2000) {
await prisma.report.createMany({ data: reports.slice(i, i + 2000) });
}
// Fünf Laufzeiten je Host, jede zweite mit Rechnungen.
for (let j = 0; j < JAHRE; j++) {
const beginn = new Date(Date.UTC(heute.getUTCFullYear() - j, h % 12, ((h + j) % 27) + 1));
const laufzeit = await prisma.laufzeit.create({
data: {
hostId: `h${h}`,
key: `pve1c-${String(h).padStart(10, "0")}`,
start: beginn,
ende: new Date(Date.UTC(beginn.getUTCFullYear() + 1, beginn.getUTCMonth(), beginn.getUTCDate() - 1)),
status: "BESTAETIGT",
},
});
if ((h + j) % 2 === 0) {
const rechnung = await prisma.proxmoxRechnung.create({
data: {
nummer: `PX-LAST-${h}-${j}`,
datum: beginn,
faelligAm: new Date(beginn.getTime() + 14 * 86_400_000),
nettoBetrag: 115,
bezahlt: j > 0,
positionen: { create: [{ bezeichnung: "Laufzeit", nettoBetrag: 115 }] },
},
include: { positionen: true },
});
await prisma.laufzeit.update({
where: { id: laufzeit.id },
data: { proxmoxPositionId: rechnung.positionen[0].id },
});
}
}
}
console.log(`Daten angelegt in ${((performance.now() - start) / 1000).toFixed(1)} s\n`);
}
async function main() {
await datenAnlegen();
await warnregelnSicherstellen();
const [hosts, reports, laufzeiten] = await Promise.all([
prisma.host.count(),
prisma.report.count(),
prisma.laufzeit.count(),
]);
console.log(`Bestand: ${hosts} Hosts, ${reports} Reports, ${laufzeiten} Laufzeiten\n`);
console.log("Dashboard:");
await messen("Kennzahlen", () => kennzahlen());
await messen("Hosts nach Ablauf", () => hostsNachAblauf());
await messen("Offene Aufgaben", () => offeneAufgaben());
await messen("Kundenliste", () => kundenListe());
await messen("Rechnungspunkte je Kunde", () => offeneRechnungspunkteProKunde());
console.log("\nHost-Tabelle:");
await messen("Erste Seite", () => hostListe({}));
await messen("Suche nach Key", () => hostListe({ q: "pve1c-0000000100" }));
await messen("Filter: bald ablaufend", () => hostListe({ ablauf: "30" }));
await messen("Filter: offene Rechnungen", () => hostListe({ rechnung: "offen" }));
await messen("Sortierung nach Ablauf", () => hostListe({ sortierung: "ablauf" }));
console.log("\nWeitere Seiten:");
await messen("Rechnungsliste (offen)", () => rechnungsListe({ status: "offen" }));
await messen("Suchindex (Strg+K)", () => suchIndex());
await messen("Warnregeln auswerten", () => alertsAbgleichen());
const offeneAlerts = await prisma.alert.count({ where: { erledigtAm: null } });
console.log(`\nOffene Warnungen nach der Auswertung: ${offeneAlerts}`);
}
main()
.then(async () => {
await prisma.$disconnect();
process.exit(0);
})
.catch(async (fehler) => {
console.error(fehler);
await prisma.$disconnect();
process.exit(1);
});
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env node
/**
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
*
* npm run screenshots # heller Modus, Desktop
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
*
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { SignJWT } from "jose";
import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle");
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
const SEITEN = [
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
];
async function sitzungsToken() {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
}
return new SignJWT({
benutzerId: "screenshots",
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
name: process.env.ADMIN_NAME ?? "Florian",
herkunft: "passwort",
})
.setProtectedHeader({ alg: "HS256" })
.setSubject("screenshots")
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime("1h")
.sign(new TextEncoder().encode(geheimnis));
}
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */
function profilVorbereiten(name, token, dunkel) {
const verzeichnis = path.join(PROFIL_BASIS, name);
rmSync(verzeichnis, { recursive: true, force: true });
mkdirSync(verzeichnis, { recursive: true });
writeFileSync(
path.join(verzeichnis, "user.js"),
[
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
'user_pref("browser.shell.checkDefaultBrowser", false);',
"",
].join("\n"),
);
const { hostname, protocol } = new URL(BASIS);
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
datenbank.exec(`
CREATE TABLE moz_cookies (
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
)`);
const jetzt = Date.now();
datenbank
.prepare(
`INSERT INTO moz_cookies
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
)
.run(
token,
hostname,
Math.floor(jetzt / 1000) + 3600,
jetzt * 1000,
jetzt * 1000,
protocol === "https:" ? 1 : 0,
protocol === "https:" ? 2 : 1,
jetzt * 1000,
);
datenbank.close();
return verzeichnis;
}
function aufnehmen(profil, pfad, datei, breite, hoehe) {
execFileSync(
"firefox",
[
"--profile",
profil,
"--headless",
"--window-size",
`${breite},${hoehe}`,
"--screenshot",
datei,
`${BASIS}${pfad}`,
],
{ stdio: "ignore", timeout: 120_000 },
);
}
async function main() {
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
mkdirSync(ZIEL, { recursive: true });
const token = await sitzungsToken();
const hell = profilVorbereiten("hell", token, false);
console.log(`Bilder nach ${ZIEL}`);
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900);
console.log(" login.png");
for (const seite of SEITEN) {
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) {
const dunkel = profilVorbereiten("dunkel", token, true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150);
console.log(` ${seite}-dunkel.png`);
}
for (const seite of ["dashboard", "hosts"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500);
console.log(` mobil-${seite}.png`);
}
}
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
console.log("Fertig.");
}
main().catch((fehler) => {
console.error(fehler.message ?? fehler);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
import Link from "next/link";
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
import { Logo } from "@/components/Logo";
import { oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
export const metadata = { title: "Anmelden" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
export default async function AnmeldeSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
const ziel = einzeln(roh.ziel) ?? "/";
const fehler = einzeln(roh.fehler);
const oidc = oidcEinstellungen();
return (
<main className="d-flex align-items-center justify-content-center min-vh-100 p-3">
<div style={{ width: "min(24rem, 100%)" }}>
<div className="text-center mb-4">
<Logo groesse={64} titel="ProxLedger" />
<h1 className="h4 mt-3 mb-1" style={{ fontWeight: 300 }}>
ProxLedger
</h1>
<p className="text-body-secondary small mb-0">Proxmox Subscription Dashboard</p>
</div>
<div className="card shadow-sm">
<div className="card-body">
{fehler ? (
<div className="alert alert-danger py-2" role="alert">
{fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
</div>
) : null}
{oidc ? (
<>
<Link
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className="btn btn-primary w-100 pl-touch-target mb-3"
>
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
Mit {oidc.bezeichnung} anmelden
</Link>
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
</>
) : null}
<AnmeldeFormular ziel={ziel} />
</div>
</div>
<p className="text-center text-body-secondary small mt-3 mb-0">
Der lokale Zugang bleibt auch dann nutzbar, wenn der Anmeldedienst ausfällt.
</p>
</div>
</main>
);
}
@@ -0,0 +1,112 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
export const metadata = { title: "Protokoll" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
export default async function AuditSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
const aktion = einzeln(roh.aktion);
const entitaet = einzeln(roh.entitaet);
const [eintraege, aktionen] = await Promise.all([
prisma.auditEintrag.findMany({
where: {
...(aktion ? { aktion } : {}),
...(entitaet ? { entitaet } : {}),
},
orderBy: { erstelltAm: "desc" },
take: 200,
include: { benutzer: { select: { name: true, email: true } } },
}),
prisma.auditEintrag.groupBy({ by: ["aktion"], _count: { _all: true }, orderBy: { aktion: "asc" } }),
]);
return (
<>
<ContentHeader titel="Protokoll" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Protokoll" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel={`Letzte ${eintraege.length} Einträge`} rahmen="primary" tabelle>
<form className="row g-2 p-3 border-bottom">
<div className="col-md-4">
<select name="aktion" defaultValue={aktion ?? ""} className="form-select" aria-label="Aktion">
<option value="">Alle Aktionen</option>
{aktionen.map((eintrag) => (
<option key={eintrag.aktion} value={eintrag.aktion}>
{eintrag.aktion} ({eintrag._count._all})
</option>
))}
</select>
</div>
<div className="col-md-3">
<input
name="entitaet"
defaultValue={entitaet ?? ""}
className="form-control"
placeholder="Entität, z. B. Host"
aria-label="Entität"
/>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
<div className="col-md-3 d-flex align-items-center">
{aktion || entitaet ? <Link href="/einstellungen/audit">Filter zurücksetzen</Link> : null}
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Aktion</th>
<th scope="col">Entität</th>
<th scope="col">Benutzer</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody>
{eintraege.map((eintrag) => (
<tr key={eintrag.id}>
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
<td className="pl-mono small">{eintrag.aktion}</td>
<td className="small">
{eintrag.entitaet}
{eintrag.entitaetId ? (
<div className="text-body-secondary pl-mono">{eintrag.entitaetId}</div>
) : null}
</td>
<td className="small">{eintrag.benutzer?.name ?? "System"}</td>
<td className="small text-body-secondary" style={{ maxWidth: "28rem" }}>
<span className="d-inline-block text-truncate w-100">
{eintrag.aenderung ? JSON.stringify(eintrag.aenderung) : "–"}
</span>
</td>
</tr>
))}
{eintraege.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
Keine Einträge gefunden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</>
);
}
@@ -1,21 +1,199 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import {
EmailFormular,
WebhookFormular,
type EmailWerte,
type WebhookWerte,
} from "@/components/benachrichtigungen/KanalFormulare";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import {
kanalLoeschen,
testSenden,
zusammenfassungJetztSenden,
zusammenfassungSpeichern,
} from "@/server/actions/benachrichtigungen";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
export const metadata = { title: "Benachrichtigungen" };
export default function BenachrichtigungenSeite() {
export default async function BenachrichtigungenSeite() {
const [kanaele, protokoll, einstellung] = await Promise.all([
prisma.benachrichtigungskanal.findMany({ orderBy: { erstelltAm: "asc" } }),
prisma.benachrichtigung.findMany({ orderBy: { erstelltAm: "desc" }, take: 20 }),
prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } }),
]);
const emailKanal = kanaele.find((kanal) => kanal.typ === "EMAIL");
const webhookKanal = kanaele.find((kanal) => kanal.typ === "WEBHOOK");
const emailKonfiguration = emailKanal?.konfiguration as unknown as EmailKonfiguration | undefined;
const webhookKonfiguration = webhookKanal?.konfiguration as unknown as WebhookKonfiguration | undefined;
const zeitraum = (einstellung?.wert as { zeitraum?: string } | null)?.zeitraum ?? "aus";
const emailWerte: EmailWerte = {
id: emailKanal?.id ?? null,
name: emailKanal?.name ?? "E-Mail",
aktiv: emailKanal?.aktiv ?? true,
host: emailKonfiguration?.host ?? "",
port: emailKonfiguration?.port ?? 587,
benutzer: emailKonfiguration?.benutzer ?? "",
absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort),
};
const webhookWerte: WebhookWerte = {
id: webhookKanal?.id ?? null,
name: webhookKanal?.name ?? "Webhook",
aktiv: webhookKanal?.aktiv ?? true,
url: webhookKonfiguration?.url ?? "",
geheimnisGesetzt: Boolean(webhookKonfiguration?.geheimnis),
};
return (
<>
<ContentHeader
titel="Benachrichtigungen"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Card titel="E-Mail und Webhook" rahmen="primary">
<p className="text-body-secondary mb-0">
SMTP (Microsoft 365), Webhooks und die tägliche Zusammenfassung werden in #012 umgesetzt.
<Card
titel="E-Mail (SMTP)"
rahmen="primary"
werkzeuge={
emailKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Für Microsoft 365: Server <span className="pl-mono">smtp.office365.com</span>, Port 587,
Benutzer und Absender identisch mit dem Postfach. Das Passwort wird in der Datenbank gespeichert
und nie wieder angezeigt.
</p>
<EmailFormular werte={emailWerte} />
</Card>
<Card
titel="Webhook"
rahmen="info"
werkzeuge={
webhookKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Verschickt JSON mit Betreff, Text und strukturierten Feldern – damit lassen sich Matrix, Zabbix
oder Home Assistant anbinden.
</p>
<WebhookFormular werte={webhookWerte} />
</Card>
<Card titel="Zusammenfassung" rahmen="info">
<form action={zusammenfassungSpeichern} className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label" htmlFor="zeitraum">
Regelmäßige Übersicht
</label>
<select id="zeitraum" name="zeitraum" className="form-select" defaultValue={zeitraum}>
<option value="aus">aus</option>
<option value="taeglich">täglich um 07:00 Uhr</option>
<option value="woechentlich">wöchentlich, montags um 07:00 Uhr</option>
</select>
</div>
<div className="col-md-3">
<button type="submit" className="btn btn-primary w-100">
Speichern
</button>
</div>
</form>
<form action={zusammenfassungJetztSenden} className="mt-3">
<input
type="hidden"
name="zeitraum"
value={zeitraum === "woechentlich" ? "woechentlich" : "taeglich"}
/>
<button type="submit" className="btn btn-outline-secondary btn-sm">
Zusammenfassung jetzt senden
</button>
</form>
</Card>
<Card titel="Versandprotokoll" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Weg</th>
<th scope="col">Empfänger</th>
<th scope="col">Betreff</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
<td>{eintrag.kanal === "EMAIL" ? "E-Mail" : "Webhook"}</td>
<td className="small">{eintrag.empfaenger}</td>
<td className="small">{eintrag.betreff ?? "–"}</td>
<td>
{eintrag.status === "GESENDET" ? (
<Badge farbe="success">Gesendet</Badge>
) : eintrag.status === "FEHLGESCHLAGEN" ? (
<>
<Badge farbe="danger">Fehlgeschlagen</Badge>
<div className="small text-body-secondary">{eintrag.fehler}</div>
</>
) : (
<Badge farbe="neutral">Wartend</Badge>
)}
</td>
</tr>
))}
{protokoll.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
Noch nichts versendet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>
@@ -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>
</>
);
}
+40 -2
View File
@@ -1,7 +1,11 @@
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
export const dynamic = "force-dynamic";
/** CSV-Export der Host-Tabelle; übernimmt die Filter aus der Adresse. */
@@ -20,13 +24,48 @@ export async function GET(request: Request) {
};
const { gesamt } = await hostListe({ ...filter }, new Date());
const alle = [];
const alle: ExportHost[] = [];
for (let seite = 1; alle.length < gesamt; seite++) {
const teil = await hostListe({ ...filter, seite }, new Date());
if (teil.hosts.length === 0) break;
alle.push(...teil.hosts);
}
const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") {
return pdfAntwort(
Bericht({
titel: "Host-Liste",
untertitel: `${alle.length} Hosts`,
quer: true,
children: Tabelle({
spalten: [
{ titel: "Host", breite: 18, wert: (h: ExportHost) => h.hostname },
{ titel: "Kunde", breite: 18, wert: (h) => h.kunde?.name ?? "Nicht zugeordnet" },
{ titel: "Produkt", breite: 15, wert: (h) => PRODUKT_TEXT[h.produkt] },
{ titel: "Status", breite: 12, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
{ titel: "Key", breite: 15, wert: (h) => h.subKey ?? "–" },
{
titel: "Sockets",
breite: 8,
wert: (h) => `${h.subLizenzierteSockets ?? "–"} / ${h.sockets ?? "–"}`,
rechts: true,
},
{
titel: "Ablauf",
breite: 14,
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
rechts: true,
},
],
zeilen: alle,
}),
}),
`proxledger-hosts-${datum}.pdf`,
);
}
const inhalt = csvDatei(
[
"Hostname",
@@ -64,6 +103,5 @@ export async function GET(request: Request) {
]),
);
const datum = new Date().toISOString().slice(0, 10);
return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`);
}
+15 -7
View File
@@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<tr>
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
<SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Produkt</th>
<th scope="col" className="d-none d-lg-table-cell">
Produkt
</th>
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Key</th>
<th scope="col" className="d-none d-md-table-cell">
Key
</th>
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Sockets</th>
<th scope="col" className="d-none d-lg-table-cell">
Sockets
</th>
<SortierKopf
titel="Letzte Meldung"
feld="meldung"
@@ -165,17 +171,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<div className="small text-body-secondary">{host.standort.name}</div>
) : null}
</td>
<td>{PRODUKT_TEXT[host.produkt]}</td>
<td className="d-none d-lg-table-cell">{PRODUKT_TEXT[host.produkt]}</td>
<td>
<StatusBadge status={host.subStatus} />
</td>
<td>
<td className="d-none d-md-table-cell">
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td>
<td>
<td className="d-none d-lg-table-cell">
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? (
<i
@@ -184,7 +190,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
/>
) : null}
</td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td>
<td className="small d-none d-xl-table-cell">
{formatDatumZeit(host.letzterReportAm)}
</td>
</tr>
))}
{hosts.length === 0 ? (
+147 -46
View File
@@ -1,26 +1,31 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { AblaufBadge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { formatDatum, tageBis } from "@/lib/format";
import { HEUTE, hosts, kunden } from "@/mock/data";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatum, formatTageBis } from "@/lib/format";
import { ablaufDaten } from "@/server/queries/jahresbahn";
export const metadata = { title: "Jahresbahn" };
const MONATE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const GESAMT_TAGE = 365;
export default function JahresbahnSeite() {
const start = new Date(Date.UTC(HEUTE.getUTCFullYear(), HEUTE.getUTCMonth(), 1));
const gesamtTage = 365;
export default async function JahresbahnSeite() {
const jetzt = new Date();
const { gruppen, naechste90Tage, abgelaufen } = await ablaufDaten(jetzt);
function position(datum: string): number | null {
const tage = Math.round((new Date(datum).getTime() - start.getTime()) / 86_400_000);
if (tage < 0 || tage > gesamtTage) return null;
return (tage / gesamtTage) * 100;
// Die Bahn beginnt am Ersten des laufenden Monats und zeigt zwölf Monate.
const start = new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), 1));
function position(datum: Date): number | null {
const tage = Math.round((datum.getTime() - start.getTime()) / 86_400_000);
if (tage < 0 || tage > GESAMT_TAGE) return null;
return (tage / GESAMT_TAGE) * 100;
}
const heutePosition = position(HEUTE.toISOString().slice(0, 10)) ?? 0;
const monatsBeschriftung = Array.from({ length: 12 }, (_, i) => {
const heutePosition = position(jetzt) ?? 0;
const monate = Array.from({ length: 12 }, (_, i) => {
const datum = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + i, 1));
return `${MONATE[datum.getUTCMonth()]} ${String(datum.getUTCFullYear()).slice(2)}`;
});
@@ -31,79 +36,175 @@ export default function JahresbahnSeite() {
<div className="app-content">
<div className="container-fluid">
<Card titel="Abläufe der nächsten 12 Monate" rahmen="primary">
<Card titel="Abläufe der nächsten zwölf Monate" rahmen="primary">
{gruppen.length === 0 ? (
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : (
<div className="table-responsive">
<div style={{ minWidth: "760px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "12rem" }}>
{monatsBeschriftung.map((monat) => (
<div style={{ minWidth: "820px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
{monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
</div>
))}
</div>
{kunden.map((kunde) => {
const eigene = hosts.filter(
(h) => h.kundeId === kunde.id && h.subscription.nextduedate !== null,
);
if (eigene.length === 0) return null;
return (
<div key={kunde.id} className="mb-3">
{gruppen.map((gruppe) => (
<div key={gruppe.kundeId ?? "ohne"} className="mb-3">
<div className="fw-semibold small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
{gruppe.kundeId ? (
<Link href={`/kunden/${gruppe.kundeId}`} className="text-decoration-none">
{gruppe.name}
</Link>
) : (
<span className="badge pl-bg-neutral">{gruppe.name}</span>
)}
</div>
{eigene.map((host) => {
const datum = host.subscription.nextduedate!;
const links = position(datum.slice(0, 10));
const tage = tageBis(datum, HEUTE);
{gruppe.eintraege.map((eintrag) => {
const links = position(eintrag.ablauf);
const farbe =
tage === null || tage < 14
eintrag.tage < 14
? "var(--pl-danger)"
: tage < 30
: eintrag.tage < 30
? "var(--pl-warning)"
: "var(--pl-info)";
return (
<div key={host.id} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "12rem" }}>
<Link href={`/hosts/${host.id}`} className="text-decoration-none">
{host.hostname}
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "13rem" }}>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
</div>
<div className="pl-timeline-row flex-grow-1 position-relative">
<div className="pl-timeline-heute" style={{ left: `${heutePosition}%` }} />
{links === null ? (
<span className="small text-body-secondary ms-2">
außerhalb des Zeitraums ({formatDatum(datum)})
außerhalb des Zeitraums ({formatDatum(eintrag.ablauf)})
</span>
) : (
<div
className="pl-timeline-bar"
<Link
href={
eintrag.laufzeitId
? `/rechnungen/${eintrag.laufzeitId}`
: `/hosts/${eintrag.hostId}`
}
className="pl-timeline-bar text-decoration-none"
style={{
left: `${Math.max(0, links - 6)}%`,
left: `${Math.min(92, Math.max(0, links - 5))}%`,
backgroundColor: farbe,
color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff",
}}
title={`${host.hostname} läuft am ${formatDatum(datum)} ab`}
title={`${eintrag.hostname} läuft am ${formatDatum(eintrag.ablauf)} ab`}
>
{formatDatum(datum)}
</div>
{formatDatum(eintrag.ablauf)}
</Link>
)}
</div>
</div>
);
})}
</div>
);
})}
))}
</div>
</div>
)}
<p className="small text-body-secondary mb-0">
Die rote Linie markiert heute ({formatDatum(HEUTE)}). Farbe der Balken: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau.
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
</p>
</Card>
<div className="row">
<div className="col-lg-8">
<Card
titel={`Ablauf-Kalender: nächste 90 Tage (${naechste90Tage.length})`}
rahmen="warning"
tabelle
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
</tr>
</thead>
<tbody>
{naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}>
<td>
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
</td>
<td>
{eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td>
<td>
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td>
</tr>
))}
{naechste90Tage.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
In den nächsten 90 Tagen läuft nichts ab.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
<div className="col-lg-4">
<Card
titel={`Bereits abgelaufen (${abgelaufen.length})`}
rahmen={abgelaufen.length > 0 ? "danger" : "info"}
>
{abgelaufen.length === 0 ? (
<p className="mb-0 text-body-secondary">Keine abgelaufenen Subscriptions.</p>
) : (
<ul className="list-unstyled mb-0">
{abgelaufen.map((eintrag) => (
<li key={eintrag.hostId} className="mb-2">
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
{eintrag.hostname}
</Link>
<div className="small text-body-secondary">
{eintrag.kunde?.name ?? "Nicht zugeordnet"} · {formatDatum(eintrag.ablauf)} (
{formatTageBis(eintrag.tage)})
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
</div>
</div>
</>
@@ -0,0 +1,74 @@
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
export const dynamic = "force-dynamic";
/** Kundenunterlage als PDF: Hosts, Laufzeiten und Summen eines Kunden. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = await kundeMitDetails(id);
if (!kunde) return new Response("Nicht gefunden", { status: 404 });
const laufzeiten = await laufzeitenVonKunde(kunde.id);
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10);
return pdfAntwort(
Bericht({
titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [
Abschnitt({ titel: `Hosts (${kunde.hosts.length})` }),
Tabelle({
spalten: [
{ titel: "Host", breite: 28, wert: (h: (typeof kunde.hosts)[number]) => h.hostname },
{ titel: "Produkt", breite: 22, wert: (h) => PRODUKT_TEXT[h.produkt] },
{ titel: "Status", breite: 16, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
{ titel: "Key", breite: 20, wert: (h) => h.subKey ?? "–" },
{
titel: "Ablauf",
breite: 14,
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
rechts: true,
},
],
zeilen: kunde.hosts,
}),
Abschnitt({ titel: `Laufzeiten (${laufzeiten.length})` }),
Tabelle({
spalten: [
{
titel: "Zeitraum",
breite: 26,
wert: (l: (typeof laufzeiten)[number]) => `${formatDatum(l.start)} – ${formatDatum(l.ende)}`,
},
{ titel: "Host", breite: 18, wert: (l) => l.hostname },
{ titel: "Proxmox-Rechnung", breite: 18, wert: (l) => l.einkauf?.nummer ?? "–" },
{ titel: "Einkauf", breite: 12, wert: (l) => formatEuro(l.einkauf?.nettoBetrag), rechts: true },
{ titel: "Kundenrechnung", breite: 18, wert: (l) => l.verkauf?.nummer ?? "–" },
{ titel: "Verkauf", breite: 8, wert: (l) => formatEuro(l.verkauf?.nettoBetrag), rechts: true },
],
zeilen: laufzeiten,
summe: ["Summe", "", "", formatEuro(gesamt.einkauf), "", formatEuro(gesamt.verkauf)],
}),
Abschnitt({ titel: "Summen pro Jahr" }),
Tabelle({
spalten: [
{ titel: "Jahr", breite: 25, wert: (j: (typeof proJahr)[number]) => j.jahr },
{ titel: "Einkauf netto", breite: 25, wert: (j) => formatEuro(j.einkauf), rechts: true },
{ titel: "Verkauf netto", breite: 25, wert: (j) => formatEuro(j.verkauf), rechts: true },
{ titel: "Marge", breite: 25, wert: (j) => formatEuro(j.marge), rechts: true },
],
zeilen: proJahr,
summe: ["Gesamt", formatEuro(gesamt.einkauf), formatEuro(gesamt.verkauf), formatEuro(gesamt.marge)],
}),
],
}),
`proxledger-${kunde.kundennummer}-${datum}.pdf`,
);
}
+6
View File
@@ -41,10 +41,16 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
titel={kunde.name}
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]}
aktion={
<span className="d-flex gap-2">
<a href={`/kunden/${kunde.id}/export`} className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
Unterlage
</a>
<Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary">
<i className="bi bi-pencil me-1" aria-hidden="true" />
Bearbeiten
</Link>
</span>
}
/>
+107
View File
@@ -0,0 +1,107 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import {
kundenListe,
offeneRechnungspunkteProKunde,
laufzeitenVonKunde,
summen,
} from "@/server/queries/kunden";
export const dynamic = "force-dynamic";
/** Kundenübersicht als CSV oder PDF. */
export async function GET(request: Request) {
const format = new URL(request.url).searchParams.get("format") ?? "csv";
const [kunden, punkte] = await Promise.all([kundenListe(), offeneRechnungspunkteProKunde()]);
const zeilen = await Promise.all(
kunden.map(async (kunde) => {
const laufzeiten = await laufzeitenVonKunde(kunde.id);
const betraege = summen(laufzeiten);
return {
...kunde,
offeneRechnungspunkte: punkte.get(kunde.id)?.offen ?? 0,
...betraege,
};
}),
);
const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") {
return pdfAntwort(
Bericht({
titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`,
quer: true,
children: Tabelle({
spalten: [
{ titel: "Kunde", breite: 26, wert: (z: (typeof zeilen)[number]) => z.name },
{ titel: "Nummer", breite: 10, wert: (z) => z.kundennummer },
{ titel: "Hosts", breite: 7, wert: (z) => String(z.hosts), rechts: true },
{ titel: "Aktiv", breite: 7, wert: (z) => String(z.aktiv), rechts: true },
{
titel: "Nächster Ablauf",
breite: 13,
wert: (z) => (z.naechsterAblauf ? formatDatum(z.naechsterAblauf) : "–"),
},
{ titel: "Warnungen", breite: 9, wert: (z) => String(z.offeneAlerts), rechts: true },
{
titel: "Offene Rechn.",
breite: 10,
wert: (z) => String(z.offeneRechnungspunkte),
rechts: true,
},
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkauf), rechts: true },
{ titel: "Marge", breite: 9, wert: (z) => formatEuro(z.marge), rechts: true },
],
zeilen,
summe: [
"Summe",
"",
String(zeilen.reduce((s, z) => s + z.hosts, 0)),
String(zeilen.reduce((s, z) => s + z.aktiv, 0)),
"",
"",
"",
formatEuro(zeilen.reduce((s, z) => s + z.einkauf, 0)),
formatEuro(zeilen.reduce((s, z) => s + z.marge, 0)),
],
}),
}),
`proxledger-kunden-${datum}.pdf`,
);
}
const inhalt = csvDatei(
[
"Kunde",
"Kundennummer",
"Ansprechpartner",
"Hosts",
"Aktiv lizenziert",
"Nächster Ablauf",
"Offene Warnungen",
"Offene Rechnungspunkte",
"Einkauf netto",
"Verkauf netto",
"Marge netto",
],
zeilen.map((z) => [
z.name,
z.kundennummer,
z.ansprechpartner,
z.hosts,
z.aktiv,
z.naechsterAblauf,
z.offeneAlerts,
z.offeneRechnungspunkte,
z.einkauf,
z.verkauf,
z.marge,
]),
);
return csvAntwort(inhalt, `proxledger-kunden-${datum}.csv`);
}
+11
View File
@@ -21,10 +21,21 @@ export default async function KundenSeite() {
rahmen="primary"
tabelle
werkzeuge={
<>
{/* Downloads: bewusst kein <Link>, sonst würde Next.js die Datei vorab laden. */}
<a href="/kunden/export" download className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
<a href="/kunden/export?format=pdf" download className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
PDF
</a>
<Link href="/kunden/neu" className="btn btn-sm btn-primary">
<i className="bi bi-plus-lg me-1" aria-hidden="true" />
Kunde anlegen
</Link>
</>
}
>
<div className="table-responsive">
+10 -4
View File
@@ -1,6 +1,9 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
/**
@@ -9,9 +12,11 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
*/
export const dynamic = "force-dynamic";
const BENUTZER = "Florian";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
if (!sitzung) redirect("/login");
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
return (
@@ -25,12 +30,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
hostId: alert.hostId,
}))}
suchEintraege={suche}
benutzer={BENUTZER}
benutzer={sitzung.name}
/>
<Sidebar
hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
benutzer={BENUTZER}
offeneWarnungen={zahlen.offeneAlerts}
benutzer={sitzung.name}
/>
<main className="app-main">{children}</main>
<Footer />
+12 -4
View File
@@ -15,7 +15,7 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardSeite() {
const heute = new Date();
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([
const [zahlen, ohneKunde, ablaufListe, aufgabenDaten, kunden, rechnungspunkte] = await Promise.all([
kennzahlen(heute),
hostsOhneKunde(),
hostsNachAblauf(heute),
@@ -25,7 +25,7 @@ export default async function DashboardSeite() {
]);
const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({
...aufgabenDaten.aufgaben.map((aufgabe) => ({
id: aufgabe.id,
titel: aufgabe.titel,
detail: [
@@ -41,7 +41,7 @@ export default async function DashboardSeite() {
href: aufgabe.href,
aktion: aufgabe.aktion,
})),
...ohneKunde.map((host) => ({
...ohneKunde.slice(0, 5).map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
@@ -183,9 +183,17 @@ export default async function DashboardSeite() {
<Card
titel="Aufgaben"
rahmen="warning"
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>}
werkzeuge={
<span className="badge pl-bg-warning">{aufgabenDaten.gesamt + ohneKunde.length}</span>
}
>
<TodoList aufgaben={aufgaben} />
{aufgabenDaten.gesamt > aufgabenDaten.aufgaben.length ? (
<p className="small text-body-secondary mt-3 mb-0">
{aufgabenDaten.gesamt - aufgabenDaten.aufgaben.length} weitere Punkte unter{" "}
<Link href="/rechnungen">Rechnungen</Link>.
</p>
) : null}
</Card>
</div>
</div>
@@ -0,0 +1,108 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
export const dynamic = "force-dynamic";
/** Rechnungsliste als CSV (Buchhaltung) oder PDF, mit den Filtern der Ansicht. */
export async function GET(request: Request) {
const parameter = new URL(request.url).searchParams;
const format = parameter.get("format") ?? "csv";
const filter: RechnungsFilter = {
status: (parameter.get("status") as RechnungsFilter["status"]) ?? "alle",
kunde: parameter.get("kunde") ?? undefined,
jahr: parameter.get("jahr") ?? undefined,
q: parameter.get("q") ?? undefined,
};
const { zeilen, summen } = await rechnungsListe(filter);
const datum = new Date().toISOString().slice(0, 10);
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") {
return pdfAntwort(
Bericht({
titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true,
children: Tabelle({
spalten: [
{
titel: "Kunde",
breite: 18,
wert: (z: (typeof zeilen)[number]) => z.kunde?.name ?? "Nicht zugeordnet",
},
{ titel: "Host", breite: 13, wert: (z) => z.hostname },
{
titel: "Laufzeit",
breite: 16,
wert: (z) => `${formatDatum(z.start)} – ${formatDatum(z.ende)}`,
},
{ titel: "Proxmox-Rechnung", breite: 13, wert: (z) => z.einkaufNummer ?? "–" },
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkaufBetrag), rechts: true },
{ titel: "Bezahlt", breite: 7, wert: (z) => (z.einkaufBezahlt ? "ja" : "nein") },
{ titel: "Kundenrechnung", breite: 12, wert: (z) => z.verkaufNummer ?? "–" },
{ titel: "Verkauf", breite: 6, wert: (z) => formatEuro(z.verkaufBetrag), rechts: true },
{
titel: "Marge",
breite: 6,
wert: (z) => (z.marge === null ? "–" : formatEuro(z.marge)),
rechts: true,
},
],
zeilen,
summe: [
"Summe",
"",
"",
"",
formatEuro(summen.einkauf),
"",
"",
formatEuro(summen.verkauf),
formatEuro(summen.marge),
],
}),
}),
`proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.pdf`,
);
}
const inhalt = csvDatei(
[
"Kunde",
"Host",
"Key",
"Laufzeit von",
"Laufzeit bis",
"Proxmox-Rechnung",
"Einkauf netto",
"Proxmox bezahlt",
"Fällig am",
"Kundenrechnung",
"Verkauf netto",
"Kundenrechnung erstellt",
"Vom Kunden bezahlt",
"Marge netto",
],
zeilen.map((z) => [
z.kunde?.name ?? "Nicht zugeordnet",
z.hostname,
z.key,
z.start,
z.ende,
z.einkaufNummer,
z.einkaufBetrag,
z.einkaufBezahlt === null ? "" : z.einkaufBezahlt ? "ja" : "nein",
z.einkaufFaelligAm,
z.verkaufNummer,
z.verkaufBetrag,
z.verkaufErstellt ? "ja" : "nein",
z.verkaufBezahlt ? "ja" : "nein",
z.marge,
]),
);
return csvAntwort(inhalt, `proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.csv`);
}
+38 -10
View File
@@ -111,6 +111,21 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
rahmen="primary"
tabelle
werkzeuge={
<>
<a
href={adresse({}).replace("/rechnungen", "/rechnungen/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
<a
href={adresse({ format: "pdf" }).replace("/rechnungen", "/rechnungen/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
PDF
</a>
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
{(
[
@@ -129,6 +144,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</Link>
))}
</div>
</>
}
>
<form className="row g-2 p-3 border-bottom" role="search">
@@ -185,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col">Host</th>
<th scope="col">Laufzeit</th>
<th scope="col" className="d-none d-md-table-cell">
Host
</th>
<th scope="col" className="d-none d-lg-table-cell">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Marge</th>
<th scope="col" className="d-none d-xl-table-cell">
Marge
</th>
</tr>
</thead>
<tbody>
@@ -217,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<td className="d-none d-md-table-cell">
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td>
<td className="d-none d-lg-table-cell">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
@@ -236,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
) : null}
</td>
<td>{formatEuro(zeile.einkaufBetrag)}</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
@@ -250,7 +276,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<td>
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td>{formatEuro(zeile.verkaufBetrag)}</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
@@ -270,7 +296,9 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
<td
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td>
</tr>
+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>
</>
);
}
+13
View File
@@ -10,6 +10,8 @@ import {
} from "@/server/agent/http";
import { reportSchema } from "@/server/agent/schema";
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { neueAlertsMelden } from "@/server/notifications/versand";
export const dynamic = "force-dynamic";
@@ -30,6 +32,17 @@ 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 {
const auswertung = await alertsAbgleichen();
if (auswertung.geoeffnet.length > 0) {
await neueAlertsMelden(auswertung.geoeffnet.map((alert) => alert.fingerprint));
}
} catch (fehler) {
console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler);
}
return Response.json({
ok: true,
hostId: host.id,
+10
View File
@@ -0,0 +1,10 @@
import { cookies } from "next/headers";
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
export const dynamic = "force-dynamic";
/** Abmelden per Link; löscht das Sitzungs-Cookie. */
export async function GET(request: Request) {
(await cookies()).delete(SITZUNGS_COOKIE);
return Response.redirect(new URL("/login", request.url), 302);
}
+49
View File
@@ -0,0 +1,49 @@
import { cookies, headers } from "next/headers";
import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
export const dynamic = "force-dynamic";
/**
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
*/
export async function POST(request: Request) {
const formular = await request.formData();
const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? "";
const zielEingabe = formular.get("ziel")?.toString() || "/";
// Nur eigene Pfade zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
function zurueckZurAnmeldung(meldung: string) {
const adresse = new URL("/login", request.url);
adresse.searchParams.set("fehler", meldung);
if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
return Response.redirect(adresse, 303);
}
if (!email || !passwort) {
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
}
try {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
return Response.redirect(new URL(ziel, request.url), 303);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
console.error("Anmeldung fehlgeschlagen:", fehler);
return zurueckZurAnmeldung("Anmeldung derzeit nicht möglich.");
}
}
+41
View File
@@ -0,0 +1,41 @@
import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
/** Nimmt die Rückkehr vom Anmeldedienst entgegen und legt die Sitzung an. */
export async function GET(request: Request) {
const einstellungen = oidcEinstellungen();
const speicher = await cookies();
const pruefwert = speicher.get("pl_oidc_pruefwert")?.value;
const zustand = speicher.get("pl_oidc_zustand")?.value;
const ziel = speicher.get("pl_oidc_ziel")?.value ?? "/";
for (const name of ["pl_oidc_pruefwert", "pl_oidc_zustand", "pl_oidc_ziel"]) {
speicher.delete(name);
}
if (!einstellungen || !pruefwert || !zustand) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
try {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
});
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
} catch (fehler) {
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { cookies } from "next/headers";
import { ueberHttps } from "@/lib/sitzung";
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
/** Schickt den Browser zum Anmeldedienst und merkt sich PKCE-Prüfwert und Zustand im Cookie. */
export async function GET(request: Request) {
const einstellungen = oidcEinstellungen();
if (!einstellungen) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
const ziel = new URL(request.url).searchParams.get("ziel") ?? "/";
try {
const start = await anmeldungStarten(einstellungen);
const speicher = await cookies();
const optionen = {
httpOnly: true,
sameSite: "lax" as const,
secure: ueberHttps(),
path: "/",
maxAge: 600,
};
speicher.set("pl_oidc_pruefwert", start.pruefwert, optionen);
speicher.set("pl_oidc_zustand", start.zustand, optionen);
speicher.set("pl_oidc_ziel", ziel.startsWith("/") ? ziel : "/", optionen);
return Response.redirect(start.url, 302);
} catch (fehler) {
console.error("OIDC-Start fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Einfache Zustandsprüfung für Container und Reverse Proxy. */
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ ok: true, datenbank: "erreichbar", zeit: new Date().toISOString() });
} catch {
return Response.json({ ok: false, datenbank: "nicht erreichbar" }, { status: 503 });
}
}
+12
View File
@@ -19,6 +19,8 @@
--pl-sidebar-text: #c2c7d0;
--pl-sidebar-divider: #4b545c;
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
--pl-logo-p: #1e2a44;
--pl-logo-linien: #17a2b8;
--bs-primary: var(--pl-primary);
--bs-primary-rgb: 0, 123, 255;
@@ -33,6 +35,16 @@
:root[data-bs-theme="dark"] {
--pl-content-bg: #1b1f24;
--pl-sidebar-bg: #22272d;
/* Auf dunklem Grund wäre das dunkle P unsichtbar. */
--pl-logo-p: #e9ecef;
}
.pl-logo-p {
fill: var(--pl-logo-p);
}
.pl-logo-linien {
stroke: var(--pl-logo-linien);
}
body {
+17 -8
View File
@@ -1,6 +1,7 @@
/**
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen).
* Varianten: "standard" folgt dem Farbschema (im Dunkelmodus hell), "sidebar" ist immer weiß,
* "mono" einfarbig für PDF und Rechnungen.
*/
export type LogoVariante = "standard" | "sidebar" | "mono";
@@ -9,18 +10,19 @@ interface LogoProps {
variante?: LogoVariante;
groesse?: number;
className?: string;
/** Für dekorative Verwendung neben dem Schriftzug. */
/** Für dekorative Verwendung neben dem Schriftzug leer lassen. */
titel?: string;
}
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = {
standard: { p: "#1E2A44", linien: "#17A2B8" },
const FESTE_FARBEN: Record<Exclude<LogoVariante, "standard">, { p: string; linien: string }> = {
sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
mono: { p: "#1E2A44", linien: "#1E2A44" },
};
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
const farben = FARBEN[variante];
// Die Standardvariante nimmt ihre Farben aus dem Theme, damit sie auch auf dunklem Grund lesbar bleibt.
const farben = variante === "standard" ? null : FESTE_FARBEN[variante];
return (
<svg
viewBox="0 0 48 48"
@@ -36,14 +38,21 @@ export function Logo({ variante = "standard", groesse = 40, className, titel }:
<path
fillRule="evenodd"
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={farben.p}
className={farben ? undefined : "pl-logo-p"}
fill={farben?.p}
/>
<path
d="M22 36h20M22 43h14"
className={farben ? undefined : "pl-logo-linien"}
stroke={farben?.linien}
strokeWidth="3.5"
strokeLinecap="round"
/>
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
</svg>
);
}
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */
/** Logo mit Schriftzug, wie in Sidebar und Anmeldung verwendet. */
export function LogoMitSchriftzug({
variante = "standard",
groesse = 36,
+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>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort.
*/
export function AnmeldeFormular({ ziel }: { ziel: string }) {
return (
<form method="post" action="/api/auth/anmelden">
<input type="hidden" name="ziel" value={ziel} />
<div className="mb-3">
<label className="form-label" htmlFor="email">
E-Mail-Adresse
</label>
<input
id="email"
name="email"
type="email"
className="form-control"
autoComplete="username"
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="passwort">
Passwort
</label>
<input
id="passwort"
name="passwort"
type="password"
className="form-control"
autoComplete="current-password"
required
/>
</div>
<button type="submit" className="btn btn-primary w-100 pl-touch-target">
Anmelden
</button>
</form>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useActionState } from "react";
import { emailKanalSpeichern, webhookKanalSpeichern } from "@/server/actions/benachrichtigungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
return null;
}
export interface EmailWerte {
id: string | null;
name: string;
aktiv: boolean;
host: string;
port: number;
benutzer: string;
absender: string;
empfaenger: string;
passwortGesetzt: boolean;
}
export function EmailFormular({ werte }: { werte: EmailWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(emailKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-name">
Bezeichnung
</label>
<input id="smtp-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-5">
<label className="form-label" htmlFor="smtp-host">
SMTP-Server *
</label>
<input
id="smtp-host"
name="host"
className="form-control"
defaultValue={werte.host}
placeholder="smtp.office365.com"
required
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="smtp-port">
Port
</label>
<input
id="smtp-port"
name="port"
type="number"
className="form-control"
defaultValue={werte.port}
/>
<div className="form-text">587 = STARTTLS (Microsoft 365), 465 = SMTPS</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-benutzer">
Benutzer
</label>
<input
id="smtp-benutzer"
name="benutzer"
className="form-control"
defaultValue={werte.benutzer}
autoComplete="username"
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-passwort">
Passwort
</label>
<input
id="smtp-passwort"
name="passwort"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.passwortGesetzt ? "gespeichert – leer lassen zum Beibehalten" : ""}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-absender">
Absender
</label>
<input
id="smtp-absender"
name="absender"
className="form-control"
defaultValue={werte.absender}
placeholder="proxledger@example.de"
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="smtp-empfaenger">
Empfänger *
</label>
<input
id="smtp-empfaenger"
name="empfaenger"
className="form-control"
defaultValue={werte.empfaenger}
placeholder="florian@example.de, team@example.de"
required
/>
<div className="form-text">Mehrere Adressen mit Komma trennen.</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="smtp-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="smtp-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "E-Mail-Versand speichern"}
</button>
</form>
);
}
export interface WebhookWerte {
id: string | null;
name: string;
aktiv: boolean;
url: string;
geheimnisGesetzt: boolean;
}
export function WebhookFormular({ werte }: { werte: WebhookWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(webhookKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="hook-name">
Bezeichnung
</label>
<input id="hook-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-8">
<label className="form-label" htmlFor="hook-url">
Adresse *
</label>
<input
id="hook-url"
name="url"
className="form-control"
defaultValue={werte.url}
placeholder="https://matrix.example.de/hook/abc"
required
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="hook-geheimnis">
Geheimnis für die Signatur
</label>
<input
id="hook-geheimnis"
name="geheimnis"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.geheimnisGesetzt ? "gespeichert – leer lassen zum Beibehalten" : "optional"}
/>
<div className="form-text">
Wird als <span className="pl-mono">X-ProxLedger-Signatur: sha256=…</span> mitgeschickt.
</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="hook-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="hook-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Webhook speichern"}
</button>
</form>
);
}
+9 -3
View File
@@ -150,7 +150,7 @@ export function Header({
{alerts.slice(0, 6).map((alert) => (
<Link
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"
onClick={() => setGlockeOffen(false)}
>
@@ -162,7 +162,11 @@ export function Header({
</Link>
))}
<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
</Link>
</div>
@@ -210,7 +214,9 @@ export function Header({
<Link href="/einstellungen/system" className="dropdown-item">
Einstellungen
</Link>
<span className="dropdown-item text-body-secondary">Abmelden (ab #013)</span>
<a className="dropdown-item" href="/api/auth/abmelden">
Abmelden
</a>
</div>
</li>
</ul>
+10
View File
@@ -15,10 +15,12 @@ interface MenuEintrag {
export function Sidebar({
hostsAnzahl,
offeneRechnungspunkte,
offeneWarnungen,
benutzer,
}: {
hostsAnzahl: number;
offeneRechnungspunkte: number;
offeneWarnungen: number;
benutzer: string;
}) {
const pathname = usePathname();
@@ -44,11 +46,19 @@ export function Sidebar({
: undefined,
},
{ 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[] = [
{ 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/audit", titel: "Protokoll", icon: "bi-list-check" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
];
+75
View File
@@ -0,0 +1,75 @@
import { SignJWT, jwtVerify } from "jose";
/**
* Sitzungen als signiertes Token im Cookie – kein Serverzustand nötig.
* Der Schlüssel kommt aus `APP_SECRET`; ohne ihn startet die Anmeldung nicht.
*/
export const SITZUNGS_COOKIE = "pl_sitzung";
export const SITZUNGSDAUER_STUNDEN = 12;
export interface Sitzung {
benutzerId: string;
email: string;
name: string;
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
herkunft: "passwort" | "oidc";
}
function schluessel(): Uint8Array {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new Error(
"APP_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen). Beispiel: openssl rand -base64 48",
);
}
return new TextEncoder().encode(geheimnis);
}
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
.sign(schluessel());
}
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
if (typeof payload.benutzerId !== "string" || typeof payload.email !== "string") return null;
return {
benutzerId: payload.benutzerId,
email: payload.email,
name: typeof payload.name === "string" ? payload.name : payload.email,
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
};
} catch {
return null;
}
}
/**
* `Secure` richtet sich nach der tatsächlichen Adresse, nicht nach NODE_ENV:
* Ein produktiv gebautes Dashboard, das über http:// läuft (etwa im LAN oder beim lokalen Prüfen),
* würde sonst ein Cookie setzen, das der Browser verwirft – die Anmeldung liefe dann ins Leere.
*/
export function ueberHttps(): boolean {
const adresse = process.env.APP_URL;
if (adresse) return adresse.startsWith("https://");
return process.env.NODE_ENV === "production";
}
export function cookieOptionen() {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: ueberHttps(),
path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse, type NextRequest } from "next/server";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Schützt die Oberfläche: Ohne gültige Sitzung führt jeder Weg zur Anmeldung.
* Die Agent-API bleibt bewusst außen vor – sie authentifiziert sich mit eigenen Tokens.
* (In Next 16 heißt diese Datei `proxy.ts`, früher `middleware.ts`.)
*/
const OEFFENTLICH = [
"/login",
"/api/auth",
"/api/agent",
"/api/gesundheit",
"/install.sh",
"/agent.sh",
"/favicon.ico",
"/icon.svg",
];
export async function proxy(request: NextRequest) {
const pfad = request.nextUrl.pathname;
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
return NextResponse.next();
}
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
if (sitzung) return NextResponse.next();
const ziel = new URL("/login", request.url);
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
return NextResponse.redirect(ziel);
}
export const config = {
// Statische Dateien und Bilder brauchen keine Prüfung.
matcher: ["/((?!_next/static|_next/image|.*\\.(?:png|jpg|svg|ico|woff2)$).*)"],
};
+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.`,
};
}
+13
View File
@@ -0,0 +1,13 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
import { protokolliere } from "@/server/audit";
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
export async function abmelden() {
(await cookies()).delete(SITZUNGS_COOKIE);
await protokolliere("abmeldung", "Benutzer", null, {});
redirect("/login");
}
+158
View File
@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
import type { AktionsErgebnis } from "./kunden";
function neuLaden() {
revalidatePath("/einstellungen/benachrichtigungen");
}
function appUrl() {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Legt den E-Mail-Kanal an oder ändert ihn. Ein leeres Passwortfeld lässt das gespeicherte unangetastet. */
export async function emailKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587);
const empfaenger = (formular.get("empfaenger")?.toString() ?? "")
.split(/[,;\s]+/)
.map((eintrag) => eintrag.trim())
.filter(Boolean);
if (!host) return { fehler: "Bitte den SMTP-Server angeben." };
if (!Number.isInteger(port) || port < 1 || port > 65535) return { fehler: "Ungültiger Port." };
if (empfaenger.length === 0) return { fehler: "Bitte mindestens einen Empfänger angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
EmailKonfiguration | undefined)
: undefined;
const passwortEingabe = formular.get("passwort")?.toString() ?? "";
const konfiguration: EmailKonfiguration = {
host,
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
const daten = {
typ: "EMAIL" as const,
name: formular.get("name")?.toString().trim() || "E-Mail",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "EMAIL",
host,
empfaenger,
});
neuLaden();
return { erfolg: "E-Mail-Versand gespeichert." };
}
export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? "";
if (!/^https?:\/\//.test(url)) return { fehler: "Bitte eine vollständige Adresse mit https:// angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
WebhookKonfiguration | undefined)
: undefined;
const geheimnisEingabe = formular.get("geheimnis")?.toString() ?? "";
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
};
const daten = {
typ: "WEBHOOK" as const,
name: formular.get("name")?.toString().trim() || "Webhook",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "WEBHOOK",
url,
});
neuLaden();
return { erfolg: "Webhook gespeichert." };
}
export async function kanalLoeschen(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.benachrichtigungskanal.delete({ where: { id } });
await protokolliere("benachrichtigungskanal.geloescht", "Benachrichtigungskanal", id, {});
neuLaden();
}
/** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> {
const id = formular.get("id")?.toString();
if (!id) return;
try {
await ueberEinenKanalSenden(id, testNachricht(appUrl()));
} catch (fehler) {
// Das Ergebnis steht im Versandprotokoll auf der Seite – dort ist auch der Fehlertext zu sehen.
console.error("Testversand fehlgeschlagen:", fehler);
}
neuLaden();
}
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) {
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({
where: { schluessel: "zusammenfassung" },
update: { wert: { zeitraum } },
create: { schluessel: "zusammenfassung", wert: { zeitraum } },
});
await protokolliere("zusammenfassung.geaendert", "Einstellung", "zusammenfassung", { zeitraum });
neuLaden();
}
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum);
neuLaden();
}
+170
View File
@@ -0,0 +1,170 @@
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);
// Alle betroffenen Warnungen in einem Zug laden: die offenen und die, deren Fall wieder auftritt.
const fingerprints = kandidaten.map((k) => k.fingerprint);
const [offene, bekannte] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null } }),
fingerprints.length > 0
? prisma.alert.findMany({ where: { fingerprint: { in: fingerprints } } })
: Promise.resolve([]),
]);
const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert]));
const bekannteNachFingerprint = new Map(bekannte.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));
// Fälle, die es noch nie gab: in einem Rutsch anlegen.
const wirklichNeu = neu.filter((k) => !bekannteNachFingerprint.has(k.fingerprint));
if (wirklichNeu.length > 0) {
await prisma.alert.createMany({
data: wirklichNeu.map((kandidat) => ({
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,
})),
skipDuplicates: true,
});
}
// Früher erledigte Fälle, die wieder auftreten: denselben Eintrag erneut öffnen.
for (const kandidat of neu.filter((k) => bekannteNachFingerprint.has(k.fingerprint))) {
await prisma.alert.update({
where: { fingerprint: kandidat.fingerprint },
data: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
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),
);
}
+17 -2
View File
@@ -1,17 +1,32 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Schreibt einen Eintrag ins Audit-Log. Solange es keine Anmeldung gibt (#013),
* bleibt `benutzerId` leer und die Änderung gilt als vom System ausgelöst.
* Schreibt einen Eintrag ins Audit-Log. Läuft die Aktion in einer Anfrage, wird der angemeldete
* Benutzer vermerkt; im Worker oder in Skripten bleibt das Feld leer („System“).
*/
async function angemeldeterBenutzer(): Promise<string | null> {
try {
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
return sitzung?.benutzerId ?? null;
} catch {
// Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
return null;
}
}
export async function protokolliere(
aktion: string,
entitaet: string,
entitaetId: string | null,
aenderung?: Record<string, unknown>,
) {
const benutzerId = await angemeldeterBenutzer();
await prisma.auditEintrag.create({
data: {
benutzerId,
aktion,
entitaet,
entitaetId,
+77
View File
@@ -0,0 +1,77 @@
import { verify } from "@node-rs/argon2";
import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
export class AnmeldeFehler extends Error {}
/** Prüft E-Mail und Passwort. Fehlermeldungen verraten bewusst nicht, welcher Teil falsch war. */
export async function mitPasswortAnmelden(
email: string,
passwort: string,
quelle: string,
): Promise<BenutzerModel> {
const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
}
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer?.passwortHash || !benutzer.aktiv) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer?.id ?? null, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
const stimmt = await verify(benutzer.passwortHash, passwort).catch(() => false);
if (!stimmt) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer.id, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passwort" });
return benutzer;
}
/**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten.
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim();
const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
(await prisma.benutzer.findUnique({ where: { email: normalisiert } }));
if (vorhanden && !vorhanden.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
data: { oidcSubject: subject, name: name || vorhanden.name, letzterLoginAm: new Date() },
})
: await prisma.benutzer.create({
data: {
email: normalisiert,
name: name || normalisiert,
oidcSubject: subject,
letzterLoginAm: new Date(),
},
});
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
weg: "oidc",
neuAngelegt: vorhanden === null,
});
return benutzer;
}
+100
View File
@@ -0,0 +1,100 @@
import * as client from "openid-client";
/**
* Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen,
* damit Zugangsdaten nicht in der Datenbank liegen.
*/
export interface OidcEinstellungen {
ausstellerUrl: string;
clientId: string;
clientSecret: string;
/** Anzeigename auf dem Anmeldeknopf. */
bezeichnung: string;
}
export function oidcEinstellungen(): OidcEinstellungen | null {
const ausstellerUrl = process.env.OIDC_ISSUER;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
if (!ausstellerUrl || !clientId || !clientSecret) return null;
return {
ausstellerUrl,
clientId,
clientSecret,
bezeichnung: process.env.OIDC_NAME ?? "authentik",
};
}
export function rueckkehrAdresse(): string {
const basis = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
return `${basis}/api/auth/oidc/callback`;
}
async function konfiguration(einstellungen: OidcEinstellungen) {
return client.discovery(
new URL(einstellungen.ausstellerUrl),
einstellungen.clientId,
einstellungen.clientSecret,
);
}
export interface AnmeldeStart {
url: string;
pruefwert: string;
zustand: string;
}
/** Baut die Adresse beim Anbieter samt PKCE-Prüfwert. */
export async function anmeldungStarten(einstellungen: OidcEinstellungen): Promise<AnmeldeStart> {
const konfig = await konfiguration(einstellungen);
const pruefwert = client.randomPKCECodeVerifier();
const herausforderung = await client.calculatePKCECodeChallenge(pruefwert);
const zustand = client.randomState();
const url = client.buildAuthorizationUrl(konfig, {
redirect_uri: rueckkehrAdresse(),
scope: "openid email profile",
code_challenge: herausforderung,
code_challenge_method: "S256",
state: zustand,
});
return { url: url.href, pruefwert, zustand };
}
export interface OidcBenutzer {
subject: string;
email: string;
name: string;
}
/** Löst den Rückkehr-Code beim Anbieter ein und liest die Benutzerangaben. */
export async function anmeldungAbschliessen(
einstellungen: OidcEinstellungen,
aktuelleUrl: URL,
pruefwert: string,
zustand: string,
): Promise<OidcBenutzer> {
const konfig = await konfiguration(einstellungen);
const tokens = await client.authorizationCodeGrant(konfig, aktuelleUrl, {
pkceCodeVerifier: pruefwert,
expectedState: zustand,
});
const angaben = tokens.claims();
if (!angaben?.sub) throw new Error("Der Anbieter hat keine Benutzerkennung geliefert.");
const email = typeof angaben.email === "string" ? angaben.email : "";
if (!email) throw new Error("Der Anbieter hat keine E-Mail-Adresse geliefert.");
const name =
(typeof angaben.name === "string" && angaben.name) ||
(typeof angaben.preferred_username === "string" && angaben.preferred_username) ||
email;
return { subject: angaben.sub, email, name };
}
+164
View File
@@ -0,0 +1,164 @@
import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
* an den sich Matrix, Zabbix oder Home Assistant anbinden lassen.
*/
export interface EmailKonfiguration {
host: string;
port: number;
/** true = SMTPS (Port 465), false = STARTTLS (Port 587, so macht es Microsoft 365). */
secure: boolean;
benutzer: string;
passwort: string;
absender: string;
empfaenger: string[];
}
export interface WebhookKonfiguration {
url: string;
/** Optionales Geheimnis für die Signatur im Header `X-ProxLedger-Signatur`. */
geheimnis?: string;
kopfzeilen?: Record<string, string>;
}
export interface Nachricht {
betreff: string;
text: string;
html: string;
/** Strukturierte Fassung für den Webhook. */
daten: Record<string, unknown>;
}
export class VersandFehler extends Error {}
/** Verschickt eine Nachricht per SMTP. */
export async function perEmailSenden(konfiguration: EmailKonfiguration, nachricht: Nachricht): Promise<void> {
if (konfiguration.empfaenger.length === 0) {
throw new VersandFehler("Kein Empfänger hinterlegt.");
}
const transport = nodemailer.createTransport({
host: konfiguration.host,
port: konfiguration.port,
secure: konfiguration.secure,
auth: konfiguration.benutzer ? { user: konfiguration.benutzer, pass: konfiguration.passwort } : undefined,
// Microsoft 365 verlangt STARTTLS auf Port 587; `requireTLS` verhindert unverschlüsselten Versand.
requireTLS: !konfiguration.secure,
});
try {
await transport.sendMail({
from: konfiguration.absender,
to: konfiguration.empfaenger.join(", "),
subject: nachricht.betreff,
text: nachricht.text,
html: nachricht.html,
});
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "SMTP-Versand fehlgeschlagen.");
} finally {
transport.close();
}
}
/** Verschickt eine Nachricht an einen Webhook; bei Geheimnis mit HMAC-Signatur. */
export async function perWebhookSenden(
konfiguration: WebhookKonfiguration,
nachricht: Nachricht,
): Promise<void> {
const koerper = JSON.stringify({
betreff: nachricht.betreff,
text: nachricht.text,
...nachricht.daten,
});
const kopfzeilen: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "ProxLedger",
...konfiguration.kopfzeilen,
};
if (konfiguration.geheimnis) {
kopfzeilen["X-ProxLedger-Signatur"] = `sha256=${createHmac("sha256", konfiguration.geheimnis)
.update(koerper)
.digest("hex")}`;
}
let antwort: Response;
try {
antwort = await fetch(konfiguration.url, { method: "POST", headers: kopfzeilen, body: koerper });
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "Webhook nicht erreichbar.");
}
if (!antwort.ok) {
throw new VersandFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
}
}
export interface KanalEintrag {
id: string;
typ: NotificationKanal;
name: string;
aktiv: boolean;
konfiguration: EmailKonfiguration | WebhookKonfiguration;
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
id: kanal.id,
typ: kanal.typ,
name: kanal.name,
aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
}));
}
/** Verschickt über einen Kanal und schreibt das Ergebnis ins Versandprotokoll. */
export async function ueberKanalSenden(
kanal: KanalEintrag,
nachricht: Nachricht,
alertId: string | null = null,
): Promise<void> {
const empfaenger =
kanal.typ === "EMAIL"
? (kanal.konfiguration as EmailKonfiguration).empfaenger.join(", ")
: (kanal.konfiguration as WebhookKonfiguration).url;
try {
if (kanal.typ === "EMAIL") {
await perEmailSenden(kanal.konfiguration as EmailKonfiguration, nachricht);
} else {
await perWebhookSenden(kanal.konfiguration as WebhookKonfiguration, nachricht);
}
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "GESENDET",
versendetAm: new Date(),
},
});
} catch (fehler) {
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "FEHLGESCHLAGEN",
fehler: fehler instanceof Error ? fehler.message : String(fehler),
},
});
throw fehler;
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { AlertSchwere } from "@/generated/prisma/enums";
import { formatDatum } from "@/lib/format";
import type { Nachricht } from "./kanaele";
/**
* Textbausteine für Benachrichtigungen. Bewusst schlichtes HTML mit Inline-Stilen –
* E-Mail-Programme ignorieren Stylesheets.
*/
export interface AlertNachrichtDaten {
id: string;
regel: string;
schwere: AlertSchwere;
titel: string;
beschreibung: string;
hostId: string | null;
}
const FARBE: Record<AlertSchwere, string> = {
GEFAHR: "#C82333",
WARNUNG: "#FFC107",
INFO: "#138496",
};
const SCHWERE_TEXT: Record<AlertSchwere, string> = {
GEFAHR: "Gefahr",
WARNUNG: "Warnung",
INFO: "Hinweis",
};
function rahmen(inhalt: string, appUrl: string): string {
return `<div style="font-family:'Source Sans 3',Arial,sans-serif;font-size:15px;color:#1F2D3D;max-width:640px">
<div style="border-bottom:3px solid #1E2A44;padding-bottom:8px;margin-bottom:16px">
<span style="font-size:20px;color:#1E2A44;font-weight:600">ProxLedger</span>
<span style="color:#6C757D"> · Proxmox Subscription Dashboard</span>
</div>
${inhalt}
<p style="color:#6C757D;font-size:13px;margin-top:24px">
<a href="${appUrl}" style="color:#007BFF">Dashboard öffnen</a>
</p>
</div>`;
}
/** Eine einzelne neue Warnung. */
export function alertNachricht(alert: AlertNachrichtDaten, appUrl: string): Nachricht {
const betreff = `[ProxLedger] ${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}`;
const text = `${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}\n${alert.beschreibung}\n\nRegel: ${alert.regel}\n${appUrl}/warnungen`;
const html = rahmen(
`<p style="margin:0 0 4px"><span style="display:inline-block;padding:2px 8px;border-radius:4px;background:${FARBE[alert.schwere]};color:${
alert.schwere === "WARNUNG" ? "#1F2D3D" : "#FFFFFF"
};font-size:13px">${SCHWERE_TEXT[alert.schwere]}</span></p>
<h2 style="margin:8px 0;font-size:18px">${alert.titel}</h2>
<p style="margin:0 0 8px">${alert.beschreibung}</p>
<p style="color:#6C757D;font-size:13px;margin:0">Regel: ${alert.regel}</p>`,
appUrl,
);
return {
betreff,
text,
html,
daten: {
art: "warnung",
alertId: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
};
}
export interface ZusammenfassungDaten {
zeitraum: "taeglich" | "woechentlich";
offeneAlerts: AlertNachrichtDaten[];
neueAlerts: AlertNachrichtDaten[];
baldAblaufend: { hostname: string; kunde: string; datum: Date; tage: number }[];
offeneRechnungspunkte: number;
hostsOhneMeldung: number;
}
/** Tägliche oder wöchentliche Zusammenfassung. */
export function zusammenfassungNachricht(daten: ZusammenfassungDaten, appUrl: string): Nachricht {
const titel = daten.zeitraum === "taeglich" ? "Tagesübersicht" : "Wochenübersicht";
const betreff = `[ProxLedger] ${titel}: ${daten.offeneAlerts.length} offene Warnungen`;
const zeilen = [
`${titel} vom ${formatDatum(new Date())}`,
"",
`Offene Warnungen: ${daten.offeneAlerts.length} (davon neu: ${daten.neueAlerts.length})`,
`Offene Rechnungspunkte: ${daten.offeneRechnungspunkte}`,
`Hosts ohne Meldung: ${daten.hostsOhneMeldung}`,
"",
"Bald ablaufende Subscriptions:",
...(daten.baldAblaufend.length > 0
? daten.baldAblaufend.map(
(e) => ` · ${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
)
: [" keine in den nächsten 30 Tagen"]),
"",
"Offene Warnungen:",
...(daten.offeneAlerts.length > 0
? daten.offeneAlerts.map((a) => ` · [${SCHWERE_TEXT[a.schwere]}] ${a.titel} – ${a.beschreibung}`)
: [" keine"]),
"",
`${appUrl}/warnungen`,
];
const liste = (eintraege: string[]) =>
eintraege.length > 0
? `<ul style="margin:4px 0 16px;padding-left:20px">${eintraege.map((e) => `<li>${e}</li>`).join("")}</ul>`
: `<p style="color:#6C757D;margin:4px 0 16px">keine</p>`;
const html = rahmen(
`<h2 style="margin:0 0 12px;font-size:18px">${titel}</h2>
<table style="border-collapse:collapse;margin-bottom:16px">
<tr><td style="padding:2px 16px 2px 0">Offene Warnungen</td><td><strong>${daten.offeneAlerts.length}</strong> (neu: ${daten.neueAlerts.length})</td></tr>
<tr><td style="padding:2px 16px 2px 0">Offene Rechnungspunkte</td><td><strong>${daten.offeneRechnungspunkte}</strong></td></tr>
<tr><td style="padding:2px 16px 2px 0">Hosts ohne Meldung</td><td><strong>${daten.hostsOhneMeldung}</strong></td></tr>
</table>
<h3 style="font-size:15px;margin:0">Bald ablaufende Subscriptions</h3>
${liste(
daten.baldAblaufend.map(
(e) => `${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
),
)}
<h3 style="font-size:15px;margin:0">Offene Warnungen</h3>
${liste(
daten.offeneAlerts.map(
(a) =>
`<span style="color:${FARBE[a.schwere]}">${SCHWERE_TEXT[a.schwere]}</span> – ${a.titel}: ${a.beschreibung}`,
),
)}`,
appUrl,
);
return {
betreff,
text: zeilen.join("\n"),
html,
daten: {
art: "zusammenfassung",
zeitraum: daten.zeitraum,
offeneAlerts: daten.offeneAlerts.length,
neueAlerts: daten.neueAlerts.length,
offeneRechnungspunkte: daten.offeneRechnungspunkte,
hostsOhneMeldung: daten.hostsOhneMeldung,
baldAblaufend: daten.baldAblaufend.map((e) => ({
hostname: e.hostname,
kunde: e.kunde,
datum: e.datum.toISOString().slice(0, 10),
tage: e.tage,
})),
},
};
}
/** Testnachricht für den Knopf „Test senden“. */
export function testNachricht(appUrl: string): Nachricht {
return {
betreff: "[ProxLedger] Testnachricht",
text: `Diese Testnachricht bestätigt, dass der Versand funktioniert.\n${appUrl}`,
html: rahmen(
`<h2 style="margin:0 0 8px;font-size:18px">Testnachricht</h2>
<p style="margin:0">Der Versand funktioniert.</p>`,
appUrl,
),
daten: { art: "test" },
};
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/**
* Versandlogik: Wer bekommt wann was?
* Grundregel aus dem Auftrag: keine doppelten Benachrichtigungen für denselben offenen Alert.
* Das stellt die Prüfung gegen das Versandprotokoll sicher – auch wenn eine Auswertung mehrfach läuft.
*/
function appUrl(): string {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Wurde zu diesem Alert schon erfolgreich benachrichtigt? */
async function bereitsGemeldet(alertId: string): Promise<boolean> {
const vorhanden = await prisma.benachrichtigung.findFirst({
where: { alertId, status: "GESENDET" },
});
return vorhanden !== null;
}
export interface VersandErgebnis {
gesendet: number;
uebersprungen: number;
fehlgeschlagen: number;
}
/** Verschickt Benachrichtigungen für neu geöffnete Warnungen. */
export async function neueAlertsMelden(fingerprints: string[]): Promise<VersandErgebnis> {
if (fingerprints.length === 0) return { gesendet: 0, uebersprungen: 0, fehlgeschlagen: 0 };
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: fingerprints.length, fehlgeschlagen: 0 };
const alerts = await prisma.alert.findMany({
where: { fingerprint: { in: fingerprints }, erledigtAm: null },
});
let gesendet = 0;
let uebersprungen = 0;
let fehlgeschlagen = 0;
for (const alert of alerts) {
if (await bereitsGemeldet(alert.id)) {
uebersprungen += 1;
continue;
}
const nachricht = alertNachricht(
{
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
appUrl(),
);
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, alert.id);
gesendet += 1;
} catch (fehler) {
fehlgeschlagen += 1;
console.error(`Versand über ${kanal.name} fehlgeschlagen:`, fehler);
}
}
}
return { gesendet, uebersprungen, fehlgeschlagen };
}
/** Stellt die Zusammenfassung zusammen und verschickt sie. */
export async function zusammenfassungSenden(
zeitraum: "taeglich" | "woechentlich",
jetzt: Date = new Date(),
): Promise<VersandErgebnis> {
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: 1, fehlgeschlagen: 0 };
const seit = new Date(jetzt.getTime() - (zeitraum === "taeglich" ? 1 : 7) * 86_400_000);
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const [offene, neue, ablaufend, zahlen] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null }, orderBy: { schwere: "asc" } }),
prisma.alert.findMany({ where: { erledigtAm: null, geoeffnetAm: { gte: seit } } }),
prisma.host.findMany({
where: { subNextduedate: { gte: jetzt, lte: in30Tagen } },
orderBy: { subNextduedate: "asc" },
select: { hostname: true, subNextduedate: true, kunde: { select: { name: true } } },
}),
kennzahlen(jetzt),
]);
const alsDaten = (alert: (typeof offene)[number]): AlertNachrichtDaten => ({
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
});
const nachricht = zusammenfassungNachricht(
{
zeitraum,
offeneAlerts: offene.map(alsDaten),
neueAlerts: neue.map(alsDaten),
baldAblaufend: ablaufend.map((host) => ({
hostname: host.hostname,
kunde: host.kunde?.name ?? "Nicht zugeordnet",
datum: host.subNextduedate!,
tage: Math.round((host.subNextduedate!.getTime() - jetzt.getTime()) / 86_400_000),
})),
offeneRechnungspunkte: zahlen.offeneRechnungspunkte,
hostsOhneMeldung: zahlen.ohneMeldung,
},
appUrl(),
);
let gesendet = 0;
let fehlgeschlagen = 0;
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, null);
gesendet += 1;
} catch {
fehlgeschlagen += 1;
}
}
return { gesendet, uebersprungen: 0, fehlgeschlagen };
}
/** Verschickt eine Nachricht über genau einen Kanal – für „Test senden“. */
export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachricht): Promise<void> {
const kanaele = await aktiveKanaele();
const kanal =
kanaele.find((eintrag) => eintrag.id === kanalId) ??
(await (async () => {
const eintrag = await prisma.benachrichtigungskanal.findUnique({ where: { id: kanalId } });
if (!eintrag) return undefined;
return {
id: eintrag.id,
typ: eintrag.typ,
name: eintrag.name,
aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never,
};
})());
if (!kanal) throw new Error("Kanal nicht gefunden.");
await ueberKanalSenden(kanal, nachricht, null);
}
+168
View File
@@ -0,0 +1,168 @@
import { Document, Page, Path, StyleSheet, Svg, Text, View, renderToBuffer } from "@react-pdf/renderer";
import type { DocumentProps } from "@react-pdf/renderer";
import type { ReactElement } from "react";
/**
* Gemeinsames Aussehen aller PDF-Ausgaben: einfarbiges Logo, Titel, Datum, Seitenzahl.
* Gerendert wird serverseitig ohne Browser (@react-pdf/renderer).
*/
const NAVY = "#1E2A44";
const stile = StyleSheet.create({
seite: { paddingTop: 40, paddingBottom: 48, paddingHorizontal: 40, fontSize: 9, color: "#1F2D3D" },
kopf: { flexDirection: "row", alignItems: "center", marginBottom: 18 },
kopfText: { marginLeft: 10 },
marke: { fontSize: 14, color: NAVY },
titel: { fontSize: 16, marginBottom: 2 },
untertitel: { fontSize: 9, color: "#6C757D" },
tabellenKopf: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: NAVY,
paddingBottom: 4,
marginBottom: 2,
fontSize: 8,
color: NAVY,
},
zeile: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: "#DEE2E6",
paddingVertical: 3,
},
summenZeile: { flexDirection: "row", borderTopWidth: 1, borderTopColor: NAVY, paddingTop: 4, marginTop: 2 },
fuss: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
justifyContent: "space-between",
fontSize: 7,
color: "#6C757D",
},
abschnitt: { marginTop: 14, marginBottom: 6, fontSize: 11, color: NAVY },
});
function Logo() {
return (
<Svg viewBox="0 0 48 48" style={{ width: 28, height: 28 }}>
<Path
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={NAVY}
fillRule="evenodd"
/>
<Path d="M22 36h20M22 43h14" stroke={NAVY} strokeWidth={3.5} strokeLinecap="round" />
</Svg>
);
}
export interface Spalte<T> {
titel: string;
breite: number;
wert: (zeile: T) => string;
rechts?: boolean;
}
/** Etwas Luft zwischen den Spalten, sonst stoßen rechtsbündige Zahlen an den nächsten Text. */
const zelle = (breite: number, rechts?: boolean) => ({
width: `${breite}%`,
textAlign: (rechts ? "right" : "left") as "right" | "left",
paddingRight: 6,
});
export function Tabelle<T>({
spalten,
zeilen,
summe,
}: {
spalten: Spalte<T>[];
zeilen: T[];
summe?: string[];
}) {
return (
<View>
<View style={stile.tabellenKopf} fixed>
{spalten.map((spalte) => (
<Text key={spalte.titel} style={zelle(spalte.breite, spalte.rechts)}>
{spalte.titel}
</Text>
))}
</View>
{zeilen.map((zeile, index) => (
<View key={index} style={stile.zeile} wrap={false}>
{spalten.map((spalte) => (
<Text key={spalte.titel} style={zelle(spalte.breite, spalte.rechts)}>
{spalte.wert(zeile)}
</Text>
))}
</View>
))}
{summe ? (
<View style={stile.summenZeile}>
{summe.map((wert, index) => (
<Text key={index} style={zelle(spalten[index]?.breite ?? 10, spalten[index]?.rechts)}>
{wert}
</Text>
))}
</View>
) : null}
</View>
);
}
export function Abschnitt({ titel }: { titel: string }) {
return <Text style={stile.abschnitt}>{titel}</Text>;
}
export function Bericht({
titel,
untertitel,
children,
quer = false,
}: {
titel: string;
untertitel?: string;
children: React.ReactNode;
quer?: boolean;
}) {
const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
dateStyle: "long",
}).format(new Date());
return (
<Document title={titel} author="ProxLedger">
<Page size="A4" orientation={quer ? "landscape" : "portrait"} style={stile.seite}>
<View style={stile.kopf} fixed>
<Logo />
<View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text>
</View>
</View>
<Text style={stile.titel}>{titel}</Text>
{untertitel ? <Text style={stile.untertitel}>{untertitel}</Text> : null}
<View style={{ marginTop: 12 }}>{children}</View>
<View style={stile.fuss} fixed>
<Text>Erstellt am {erstellt}</Text>
<Text render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} />
</View>
</Page>
</Document>
);
}
export async function pdfAntwort(element: ReactElement<DocumentProps>, dateiname: string): Promise<Response> {
const puffer = await renderToBuffer(element);
return new Response(new Uint8Array(puffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${dateiname}"`,
"Cache-Control": "no-store",
},
});
}
+7 -3
View File
@@ -47,8 +47,12 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
});
}
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */
export async function offeneAufgaben() {
/**
* Offene Rechnungspunkte als Aufgabenliste fürs Dashboard.
* Begrenzt, weil die Karte sonst bei vielen Hosts endlos lang wird – die vollständige
* Liste steht unter „Rechnungen“.
*/
export async function offeneAufgaben(grenze = 12) {
const laufzeiten = await prisma.laufzeit.findMany({
where: {
status: "BESTAETIGT",
@@ -116,5 +120,5 @@ export async function offeneAufgaben() {
}
}
return aufgaben;
return { aufgaben: aufgaben.slice(0, grenze), gesamt: aufgaben.length };
}
+73
View File
@@ -0,0 +1,73 @@
import { prisma } from "@/lib/db";
/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */
export async function ablaufDaten(jetzt: Date = new Date()) {
const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null } },
orderBy: [{ subNextduedate: "asc" }],
select: {
id: true,
hostname: true,
subKey: true,
subNextduedate: true,
subStatus: true,
kunde: { select: { id: true, name: true } },
laufzeiten: {
where: { status: "BESTAETIGT" },
orderBy: { start: "desc" },
take: 2,
select: {
id: true,
start: true,
proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } },
kundenPosition: { select: { rechnung: { select: { erstellt: true } } } },
},
},
},
});
const eintraege = hosts.map((host) => {
const ablauf = host.subNextduedate!;
const verlaengerung = host.laufzeiten.find((l) => l.start.getTime() === ablauf.getTime());
return {
hostId: host.id,
hostname: host.hostname,
key: host.subKey,
ablauf,
status: host.subStatus,
kunde: host.kunde,
laufzeitId: verlaengerung?.id ?? null,
einkaufBezahlt: verlaengerung?.proxmoxPosition?.rechnung.bezahlt ?? null,
verkaufErstellt: verlaengerung?.kundenPosition?.rechnung.erstellt ?? null,
tage: Math.round((ablauf.getTime() - jetzt.getTime()) / 86_400_000),
};
});
// Nach Kunde gruppieren; Hosts ohne Kunde kommen ans Ende.
const gruppen = new Map<string, { name: string; kundeId: string | null; eintraege: typeof eintraege }>();
for (const eintrag of eintraege) {
const schluessel = eintrag.kunde?.id ?? "ohne";
const gruppe = gruppen.get(schluessel) ?? {
name: eintrag.kunde?.name ?? "Nicht zugeordnet",
kundeId: eintrag.kunde?.id ?? null,
eintraege: [],
};
gruppe.eintraege.push(eintrag);
gruppen.set(schluessel, gruppe);
}
const sortiert = [...gruppen.values()].sort((a, b) => {
if (a.kundeId === null) return 1;
if (b.kundeId === null) return -1;
return a.name.localeCompare(b.name, "de");
});
return {
gruppen: sortiert,
naechste90Tage: eintraege.filter((e) => e.tage >= 0 && e.tage <= 90),
abgelaufen: eintraege.filter((e) => e.tage < 0),
};
}
export type AblaufEintrag = Awaited<ReturnType<typeof ablaufDaten>>["naechste90Tage"][number];
+86
View File
@@ -0,0 +1,86 @@
import { Cron } from "croner";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/**
* Hintergrundprozess: wertet die Warnregeln regelmäßig aus, verschickt Benachrichtigungen für
* neue Warnungen und schickt die Zusammenfassung. Läuft als eigener Dienst neben der Web-Anwendung (#014).
*
* npm run worker
*/
const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr
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.`,
);
if (ergebnis.geoeffnet.length > 0) {
const versand = await neueAlertsMelden(ergebnis.geoeffnet.map((alert) => alert.fingerprint));
protokoll(
`Benachrichtigungen: ${versand.gesendet} gesendet, ${versand.uebersprungen} übersprungen, ${versand.fehlgeschlagen} fehlgeschlagen.`,
);
}
} catch (fehler) {
console.error("Auswertung fehlgeschlagen:", fehler);
}
}
/** Zeitraum der Zusammenfassung aus den Einstellungen (täglich, wöchentlich oder aus). */
async function zusammenfassungEinstellung(): Promise<"taeglich" | "woechentlich" | "aus"> {
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } });
const wert = (eintrag?.wert as { zeitraum?: string } | null)?.zeitraum;
return wert === "taeglich" || wert === "woechentlich" ? wert : "aus";
}
async function zusammenfassung() {
try {
const zeitraum = await zusammenfassungEinstellung();
if (zeitraum === "aus") return;
// Wöchentlich nur montags verschicken.
if (zeitraum === "woechentlich" && new Date().getDay() !== 1) return;
const ergebnis = await zusammenfassungSenden(zeitraum);
protokoll(
`Zusammenfassung (${zeitraum}): ${ergebnis.gesendet} gesendet, ${ergebnis.fehlgeschlagen} fehlgeschlagen.`,
);
} catch (fehler) {
console.error("Zusammenfassung fehlgeschlagen:", fehler);
}
}
async function start() {
await warnregelnSicherstellen();
protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`,
);
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
await durchlauf();
const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
];
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
protokoll("Worker wird beendet.");
for (const aufgabe of aufgaben) aufgabe.stop();
process.exit(0);
});
}
}
void start();

Some files were not shown because too many files have changed in this diff Show More