Compare commits

...
31 Commits
Author SHA1 Message Date
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
florian.wilde 81b2066d61 Merge branch 'phase/008-laufzeiten-rechnungen' 2026-09-17 21:54:45 +02:00
florian.wildeandClaude Opus 5 558dfea34c feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)
Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:54:45 +02:00
florian.wilde 1e1d6c4418 Merge branch 'phase/020b-gitea-token' 2026-09-17 21:44:01 +02:00
florian.wildeandClaude Opus 5 2dbb30041b feat(tooling): Gitea-Token aus den Git-Zugangsdaten lesen (#020)
Serveradresse und Repository stammen aus dem Remote, der Token aus dem
Schluesselbund bzw. ~/.git-credentials. Ein zweiter Token in der .env entfaellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:44:01 +02:00
florian.wilde 51243c29fa Merge branch 'phase/020-gitea-abgleich' 2026-09-17 21:17:23 +02:00
florian.wildeandClaude Opus 5 2df43e1517 feat(tooling): Issue-Dateien mit dem Gitea-Tracker abgleichen (#020)
Skript liest docs/issues/, legt Labels und Meilensteine an, erkennt vorhandene
Issues an der Nummer im Titel und schliesst erledigte. Trockenlauf ohne Token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:17:23 +02:00
florian.wilde 251f29c293 Merge branch 'phase/007-hosts-detailansichten' 2026-09-17 21:12:41 +02:00
florian.wildeandClaude Opus 5 0ae96a50cd feat(hosts): Host-Tabelle mit Filtern, CSV-Export und Detailansicht (#007)
Suche ueber Hostname, Key, Server-ID, IP und Rechnungsnummer, fuenf Filter,
Sortierung mit nulls-last, Seitenblaettern und CSV-Export. Detailansicht mit
sechs Tabs, Notizen, Tags und Token-Widerruf. 59 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:12:41 +02:00
florian.wildeandClaude Opus 5 6aa6a20f63 docs: Issues #017 bis #019 aus den Erkenntnissen der bisherigen Phasen (#006)
Favicon-Dateien, Feldtest des Agents auf echten Proxmox-Hosts und CI-Pipeline
waren bisher nur Notizen in anderen Issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:04:24 +02:00
florian.wilde ae7f5c55ba Merge branch 'phase/006-kunden-hostzuweisung' 2026-09-17 21:02:44 +02:00
florian.wildeandClaude Opus 5 f9a5ad2daa feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)
Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen,
Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und
globale Suche laufen jetzt auf der Datenbank. 45 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:02:44 +02:00
florian.wilde f591b45aac Merge branch 'phase/005-agent-skript' 2026-09-17 20:44:38 +02:00
florian.wildeandClaude Opus 5 d9455db311 feat(agent): Bash-Agent mit Installer, systemd-Timer und Spool (#005)
Produkterkennung fuer PVE, PBS und PMG, JSON-Erzeugung ohne jq, Retry mit
Backoff, Nachsenden aus dem Spool, Update und Uninstall. Das Dashboard liefert
install.sh und agent.sh mit eingesetzter Adresse aus. 38 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:44:38 +02:00
florian.wilde d16115de66 Merge branch 'phase/004-agent-api' 2026-09-17 20:39:33 +02:00
florian.wildeandClaude Opus 5 bef51eb863 feat(api): Agent-API mit Enrollment, Report und Ping (#004)
Gehashte Tokens mit Praefix, Rate-Limit je IP und Token, zod-Validierung mit
durchgaengig optionalen Feldern, idempotente Reports ueber reportId, Key- und
Server-ID-Historie sowie automatischer Laufzeit-Vorschlag. 32 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:39:33 +02:00
florian.wilde 51c8aafa69 Merge branch 'phase/003-datenmodell' 2026-09-17 20:32:04 +02:00
florian.wildeandClaude Opus 5 293bcf4b5a feat(db): Datenmodell, erste Migration und Seed (#003)
Prisma 7.10 mit Postgres-Adapter, 23 Modelle inklusive Rechnungen mit Positionen,
Seed fuer Administrator, Warnregeln und Demo-Daten, Testdatenbank fuer Vitest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:32:04 +02:00
florian.wilde 090396f6c0 Merge branch 'phase/002-prototyp' 2026-09-17 20:03:22 +02:00
florian.wildeandClaude Opus 5 8596a18a27 feat(ui): klickbarer Prototyp mit Beispieldaten (#002)
Layout im AdminLTE-Stil (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als
SVG-Komponente, Farbschema mit Dark-Mode, Dashboard, Kunden- und Host-Ansichten,
Rechnungsuebersicht, Jahresbahn und globale Suche (Strg+K).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:03:15 +02:00
florian.wilde d98bde9922 Merge branch 'phase/001-projekt-setup' 2026-09-17 19:44:08 +02:00
florian.wildeandClaude Opus 5 783d2fd7bd docs: devlog und Issue #001 abgeschlossen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:44:08 +02:00
179 changed files with 20736 additions and 217 deletions
+39
View File
@@ -0,0 +1,39 @@
# Datenbank (lokal über docker-compose.dev.yml, Port 5433)
DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public"
# Eigene Datenbank für automatisierte Tests
TEST_DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger_test?schema=public"
# Lokaler Administrator, wird vom Seed angelegt
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"
# Demo-Daten beim Seed anlegen (1 = ja)
SEED_DEMO="0"
# Abgleich der Issue-Dateien mit dem Gitea-Tracker (npm run issues:sync)
GITEA_URL="https://git.example.de"
GITEA_REPO="benutzer/ProxLedger"
GITEA_TOKEN=""
+2
View File
@@ -51,3 +51,5 @@ Thumbs.db
# Vercel / Sonstiges
.vercel
*.pem
/src/generated/prisma
+2
View File
@@ -4,3 +4,5 @@ coverage
docs
*.md
package-lock.json
src/generated
prisma/migrations
+137 -2
View File
@@ -8,8 +8,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Hinzugefügt
- Projektorganisation: Devlog, Changelog, Issue-Übersicht und Issues pro Phase (#001)
- Architektur- und Umsetzungsplan in `docs/architektur.md` (#001)
### Geändert
@@ -18,3 +16,140 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Entfernt
### Sicherheit
## [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
- Laufzeit-Vorschläge der Agents bestätigen oder verwerfen (#008)
- Proxmox- und Kundenrechnungen erfassen; gleiche Rechnungsnummer fasst mehrere Laufzeiten zu einer Sammelrechnung zusammen (#008)
- PDF-Upload für beide Rechnungsarten mit Prüfung des Dateiinhalts und Ablage unter der Prüfsumme (#008)
- Rechnungsübersicht mit Häkchen „bezahlt“ und „erstellt“ direkt in der Zeile, Filter nach Status, Kunde, Jahr und Suchbegriff (#008)
- Detailseite je Laufzeit mit Beträgen, Marge und Übersicht der Sammelrechnung (#008)
- Laufzeiten lassen sich von Hand anlegen (#008)
### Geändert
- Die Aufgabenliste auf dem Dashboard setzt beim Abhaken den Rechnungsstatus in der Datenbank (#008)
## [0.4.2] – 2026-09-17
### Geändert
- Der Issue-Abgleich holt Serveradresse und Repository aus dem Git-Remote und den Token aus den hinterlegten Git-Zugangsdaten; ein zweiter Token in der `.env` ist nicht mehr nötig (#020)
## [0.4.1] – 2026-09-17
### Hinzugefügt
- Abgleich der Issue-Dateien mit dem Gitea-Tracker über `npm run issues:sync`, inklusive Labels, Meilensteinen und Trockenlauf (#020)
## [0.4.0] – 2026-09-17
### Hinzugefügt
- Host-Tabelle mit Suche über Hostname, FQDN, Key, Server-ID, IP, Kunde und Rechnungsnummer (#007)
- Filter nach Kunde, Status, Produkt, Ablauffrist und offenen Rechnungspunkten; Sortierung und Seitenblättern (#007)
- CSV-Export der Host-Liste unter Berücksichtigung der aktiven Filter (#007)
- Host-Detailansicht mit Übersicht, Subscription-Historie, Reports, Rechnungen, Notizen mit Tags und Tokens (#007)
- Kunde eines Hosts direkt in der Detailansicht ändern (#007)
### Geändert
- Host-Tabelle und Host-Detail arbeiten mit echten Daten statt Beispieldaten (#007)
## [0.3.0] – 2026-09-17
### Hinzugefügt
- Kunden anlegen, bearbeiten und löschen; Standorte je Kunde verwalten (#006)
- Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Zuweisung in einem Schritt (#006)
- Enrollment-Tokens erzeugen (mit Gültigkeit, Nutzungsgrenze und optionaler Kundenbindung), anzeigen und widerrufen; Host-Tokens widerrufen (#006)
- Audit-Log-Einträge für alle Änderungen an Kunden, Standorten, Zuweisungen und Tokens (#006)
### Geändert
- Dashboard, Kundenübersicht, Kunden-Detail und globale Suche arbeiten mit echten Daten statt Beispieldaten (#006)
### Behoben
- Prisma-Client öffnete in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool (#006)
## [0.2.0] – 2026-09-17
### Hinzugefügt
- Agent-Skript in Bash: erkennt Proxmox VE, Backup Server und Mail Gateway, sammelt Subscription-, Host- und Systemdaten (#005)
- Installation per One-Liner mit Enrollment-Token, systemd-Service und -Timer (täglich, zufällig versetzt) (#005)
- Zwischenspeicher für Reports samt Nachsenden, wenn das Dashboard nicht erreichbar ist (#005)
- Update- und Uninstall-Funktion sowie Verbindungstest (`--test`) und Vorschau (`--dry-run`) (#005)
- Dashboard liefert `install.sh` und `agent.sh` mit eingesetzter Adresse und Version aus (#005)
### Sicherheit
- Konfiguration des Agents mit Rechten 600, TLS-Prüfung standardmäßig aktiv, optional eigenes CA-Zertifikat (#005)
## [0.1.0] – 2026-09-17
### Hinzugefügt
- Projektorganisation: Devlog, Changelog, Issue-Übersicht und Issues pro Phase (#001)
- Architektur- und Umsetzungsplan in `docs/architektur.md` (#001)
- Next.js-Grundgerüst mit AdminLTE-CSS, Bootstrap Icons, zod, ESLint, Prettier und Vitest (#001)
- Klickbarer Prototyp mit Beispieldaten: Dashboard, Kunden- und Host-Ansichten, Rechnungen, Jahresbahn (#002)
- ProxLedger-Logo als SVG-Komponente, Farbschema nach Vorgabe inklusive Dark-Mode (#002)
- Globale Suche über Key, Host, Kunde und Rechnungsnummer mit Strg+K (#002)
- Datenmodell mit Prisma: Kunden, Standorte, Hosts, Reports, Key- und Server-ID-Historie, Laufzeiten, Proxmox- und Kundenrechnungen mit Positionen, Tokens, Warnungen, Benachrichtigungen, Benutzer und Audit-Log (#003)
- Erste Migration, Seed für Administrator, Warnregeln und Demo-Daten (#003)
- Docker-Compose für die Entwicklungsdatenbank und Testdatenbank-Setup für Vitest (#003)
- Agent-API unter `/api/agent/v1`: Registrierung, Report und Verbindungstest (#004)
- Automatischer Laufzeit-Vorschlag, sobald ein Agent ein neues Ablaufdatum meldet (#004)
- Key- und Server-ID-Historie je Host (#004)
### Geändert
### Behoben
### Entfernt
### Sicherheit
### Sicherheit
- API-Tokens werden nur als SHA-256-Hash gespeichert und sind einzeln widerrufbar (#004)
- Rate-Limiting und strikte zod-Validierung auf der Agent-API (#004)
+76 -7
View File
@@ -2,38 +2,107 @@
## Zeitübersicht
**Gesamt:** 0 h 00 min (laufende Sitzung nicht eingerechnet)
**Gesamt:** 5 h 23 min
| Issue | Zeit |
|---|---|
| #001 Projekt-Setup | 0 h 00 min |
| #001 Projekt-Setup | 0 h 08 min |
| #002 Klickbarer Prototyp | 0 h 20 min |
| #003 Datenmodell und Migrationen | 0 h 25 min |
| #004 Agent-API | 0 h 20 min |
| #005 Agent-Skript | 0 h 25 min |
| #006 Kunden und Hostzuweisung | 0 h 35 min |
| #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 |
| Meilenstein | Zeit |
|---|---|
| v0.1.0 | 0 h 00 min |
| v0.1.0 | 1 h 13 min | **abgeschlossen**
| v0.2.0 | 0 h 25 min | **abgeschlossen**
| v0.3.0 | 0 h 35 min | **abgeschlossen**
| v0.4.0 | 0 h 25 min | **abgeschlossen**
| 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**
---
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001 Projekt-Setup
**Version:** 0.1.0-dev
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.9.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
- Umgebung geprüft: Ubuntu 26.04, Git 2.53; Node/Docker fehlten → Node 24 LTS per nvm installiert
- Repository-Rechte korrigiert (Verzeichnis gehörte root), Branch `master` → `main`, Git-Identität lokal gesetzt
- `.gitignore`, `CHANGELOG.md`, `DEVLOG.md`, Architekturplan und 15 Issues angelegt
- #001 abgeschlossen: Next.js 16 (App Router, TypeScript strict), AdminLTE-CSS, Bootstrap Icons, zod, ESLint/Prettier/Vitest; Lint, Typecheck, Test und Build grün
- #002 abgeschlossen: Prototyp mit Beispieldaten – Layout (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als SVG-Komponente, Theme mit Farbvorgaben und Dark-Mode, Dashboard, Kunden (Liste und Detail), Hosts (Liste, Detail, nicht zugeordnet), Rechnungen, Jahresbahn, Einstellungsseiten, globale Suche mit Strg+K
- Screenshots aller Seiten aufgenommen und in `docs/anleitung/bilder/` abgelegt
- #003 abgeschlossen: Prisma-Schema mit 23 Modellen, erste Migration, Seed (Administrator, 10 Warnregeln, Demo-Daten), Testdatenbank-Setup für Vitest, 3 Schema-Tests
- #004 abgeschlossen: Agent-API mit Enrollment, Report und Ping, gehashte Tokens, Rate-Limit, zod-Validierung, 32 Tests insgesamt
- Meilenstein v0.1.0 abgeschlossen und getaggt
- #005 abgeschlossen: Bash-Agent mit Produkterkennung, JSON ohne jq, Retry mit Backoff, Spool, systemd-Timer, Installer, Uninstall und Update; Dashboard liefert `/install.sh` und `/agent.sh` aus
- Meilenstein v0.2.0 abgeschlossen und getaggt; 38 Tests
- #006 abgeschlossen: Kunden und Standorte pflegen, Mehrfachzuweisung nicht zugeordneter Hosts, Enrollment- und Host-Tokens verwalten, Audit-Log; Dashboard, Kundenseiten und globale Suche laufen auf der Datenbank
- Durchlauf gegen den laufenden Server geprüft: Token anlegen → `enroll` → Agent-Report → Laufzeit-Vorschlag → Host erscheint als „nicht zugeordnet“ → `ping`
- Meilenstein v0.3.0 abgeschlossen und getaggt; 45 Tests
- Issues #017 bis #019 aus bisherigen Notizen angelegt (Favicons, Feldtest des Agents, CI-Pipeline)
- #007 abgeschlossen: Host-Tabelle mit Suche, fünf Filtern, Sortierung, Seitenblättern und CSV-Export; Host-Detail mit sechs Tabs, Notizen, Tags, Kundenzuweisung und Token-Widerruf
- Meilenstein v0.4.0 abgeschlossen und getaggt; 59 Tests
- #020 abgeschlossen: Abgleichskript für den Gitea-Issue-Tracker (Missverständnis geklärt – die Issues lagen bisher nur als Dateien im Repo, nicht im Tracker)
- Token kommt aus den Git-Zugangsdaten statt aus einer zweiten Stelle; 20 Issues, 15 Labels und 13 Meilensteine in Gitea angelegt
- 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
### Entscheidungen
- **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).
- **Kennzahlen werden berechnet, nicht hinterlegt:** auch im Prototyp kommen alle Zahlen aus den Beispieldaten, damit Tabellen und Kacheln nie widersprüchlich sind.
- **AdminLTE 4.9.1 nur als CSS:** stabil, Bootstrap 5.3.8, kein jQuery; die JS-Plugins manipulieren das DOM und werden daher als React-Komponenten nachgebaut.
- **Host-Identität über Agent-Token statt Server-ID:** Die Server-ID ändert sich bei Hardwaretausch und soll dann eine Warnung auslösen, statt einen neuen Host zu erzeugen.
- **Rechnungen mit Positionen:** Proxmox-Sammelbestellungen und Sammelrechnungen an Kunden sollen ohne doppelte Rechnungsnummern abbildbar sein.
- **Eigener Worker-Prozess** für zeitgesteuerte Warnregeln und Benachrichtigungen, unabhängig von Seitenaufrufen.
- **Prototyp direkt im Next.js-Projekt**, damit die Komponenten weiterverwendet werden.
- **Prisma 7.10.0 fest gesetzt:** Der npm-Tag `latest` zeigt derzeit auf 8.0.0-rc, ein Release Candidate gehört nicht in ein neues Projekt.
- **Seed nutzt die Prototyp-Beispieldaten**, damit die Oberfläche ab #006 dieselben Fälle zeigt wie der Prototyp.
- **Keine Nur-Lese-Rolle** (Rückmeldung Florian): alle Benutzer sind Administratoren.
- **Prisma-Client wird lazy erzeugt** (Proxy), damit `next build` ohne gesetzte `DATABASE_URL` durchläuft.
- **Report-Idempotenz über eine vom Agent erzeugte `reportId`**: nachgesendete Reports aus dem Spool dürfen beliebig oft ankommen.
- **Unbekannte Subscription-Status werden zu `UNKNOWN`** statt abgelehnt – ein Report darf nie an einem neuen Statuswort scheitern.
- **Dashboard gleich mit auf die Datenbank umgestellt** (eigentlich #009): Eine frisch zugewiesene Zuordnung hätte dort sonst weiter als „nicht zugeordnet“ gestanden.
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
### Probleme / offene Fragen
- Docker ist nicht installiert und `sudo` erfordert ein Passwort → Installation muss manuell erfolgen (benötigt ab #003).
- 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.
- Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen.
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt
- Next.js-Grundgerüst (#001), danach Prototyp (#002)
- #014 Deployment: Dockerfile, Compose mit Worker, LXC-Installer, README und Backup-Hinweise
+79 -1
View File
@@ -11,12 +11,90 @@ Voraussetzungen: Node.js ≥ 24, Docker (für PostgreSQL ab #003).
```bash
npm install
npm run db:up # PostgreSQL im Container (Port 5433)
cp .env.example .env
npm run db:migrate # Schema anlegen
npm run db:seed # Administrator, Warnregeln, mit SEED_DEMO=1 auch Demo-Daten
npm run dev # http://localhost:3000
npm run lint
npm run typecheck
npm test
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
```bash
curl -fsSL https://<dashboard>/install.sh | bash -s -- --token <ENROLL_TOKEN>
```
Der Agent erkennt Proxmox VE, Backup Server und Mail Gateway selbst, registriert sich, schreibt seine
Konfiguration nach `/etc/proxledger/agent.conf` (Rechte 600) und meldet ab dann täglich per systemd-Timer.
| Befehl | Zweck |
|---|---|
| `proxledger-agent` | Report erzeugen und senden |
| `proxledger-agent --dry-run` | Report nur anzeigen |
| `proxledger-agent --test` | Verbindung zum Dashboard prüfen |
| `proxledger-agent --update` | Agent aktualisieren |
| `curl -fsSL https://<dashboard>/install.sh \| bash -s -- --uninstall` | Agent entfernen |
Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versucht es der Agent dreimal mit
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
jedem Klon mit. Wer sie zusätzlich im Gitea-Tracker haben möchte, gleicht sie ab:
```bash
npm run issues:sync -- --dry-run # zeigt nur, was passieren würde
npm run issues:sync # echter Abgleich
```
Serveradresse und Repository liest das Skript aus dem Git-Remote, den Token aus den hinterlegten
Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`) – derselbe Token wie fürs Pushen, er braucht
zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_URL`, `GITEA_REPO` und
`GITEA_TOKEN` in der `.env`.
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.
## Dokumentation
- [Architektur- und Umsetzungsplan](docs/architektur.md)
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# ProxLedger-Agent installieren.
#
# curl -fsSL https://dashboard.example.de/install.sh | bash -s -- --token plx_e_...
#
# Optionen:
# --token <TOKEN> Enrollment-Token aus dem Dashboard (erforderlich)
# --url <URL> Adresse des Dashboards (Standard: Herkunft dieses Skripts)
# --ca-cert <DATEI> eigenes CA-Zertifikat für TLS
# --auto-update Agent aktualisiert sich selbst, wenn das Dashboard eine neuere Version meldet
# --uninstall Agent vollständig entfernen
# --help diese Hilfe
set -o errexit
set -o nounset
set -o pipefail
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
TOKEN=""
CA_CERT=""
AUTO_UPDATE="no"
AKTION="install"
CONFIG_VERZEICHNIS="/etc/proxledger"
CONFIG_DATEI="${CONFIG_VERZEICHNIS}/agent.conf"
AGENT_PFAD="/usr/local/bin/proxledger-agent"
SPOOL_VERZEICHNIS="/var/lib/proxledger/spool"
SYSTEMD_VERZEICHNIS="/etc/systemd/system"
hinweis() { printf '\033[1;36m›\033[0m %s\n' "$*"; }
erfolg() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
fehler() {
printf '\033[1;31m✗\033[0m %s\n' "$*" >&2
exit 1
}
hilfe() {
sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--token)
TOKEN="${2:-}"
shift 2
;;
--url)
DASHBOARD_URL="${2:-}"
shift 2
;;
--ca-cert)
CA_CERT="${2:-}"
shift 2
;;
--auto-update)
AUTO_UPDATE="yes"
shift
;;
--uninstall)
AKTION="uninstall"
shift
;;
--help | -h)
hilfe
exit 0
;;
*) fehler "Unbekannte Option: $1" ;;
esac
done
[ "$(id -u)" -eq 0 ] || fehler "Bitte als root ausführen."
deinstallieren() {
hinweis "Entferne ProxLedger-Agent …"
systemctl disable --now proxledger.timer >/dev/null 2>&1 || true
systemctl stop proxledger.service >/dev/null 2>&1 || true
rm -f "${SYSTEMD_VERZEICHNIS}/proxledger.service" "${SYSTEMD_VERZEICHNIS}/proxledger.timer"
systemctl daemon-reload
rm -f "$AGENT_PFAD"
rm -rf "$CONFIG_VERZEICHNIS" "$(dirname "$SPOOL_VERZEICHNIS")"
erfolg "Agent entfernt. Der Host bleibt im Dashboard erhalten; dort ggf. den Token widerrufen."
}
if [ "$AKTION" = "uninstall" ]; then
deinstallieren
exit 0
fi
[ -n "$TOKEN" ] || fehler "Enrollment-Token fehlt (--token)."
[ -n "$DASHBOARD_URL" ] || fehler "Dashboard-Adresse fehlt (--url)."
DASHBOARD_URL=${DASHBOARD_URL%/}
command -v curl >/dev/null 2>&1 || fehler "curl ist nicht installiert."
command -v systemctl >/dev/null 2>&1 || fehler "systemd wird benötigt."
produkt_erkennen() {
if command -v pvesubscription >/dev/null 2>&1; then
printf 'pve'
elif command -v proxmox-backup-manager >/dev/null 2>&1; then
printf 'pbs'
elif command -v pmgsubscription >/dev/null 2>&1; then
printf 'pmg'
fi
}
PRODUKT=$(produkt_erkennen)
[ -n "$PRODUKT" ] || fehler "Kein Proxmox-Produkt gefunden (VE, Backup Server oder Mail Gateway)."
hinweis "Produkt erkannt: ${PRODUKT}"
# Stabile Agent-Kennung: bleibt auch nach Hardwaretausch gleich, solange das System dasselbe ist.
maschinen_id() {
local id=""
[ -r /etc/machine-id ] && id=$(cat /etc/machine-id)
[ -z "$id" ] && [ -r /var/lib/dbus/machine-id ] && id=$(cat /var/lib/dbus/machine-id)
[ -z "$id" ] && id=$(hostname -f 2>/dev/null || hostname)
printf '%s' "$id"
}
AGENT_ID=$(maschinen_id)
HOSTNAME_KURZ=$(hostname -s 2>/dev/null || hostname)
FQDN=$(hostname -f 2>/dev/null || printf '')
server_id() {
case "$PRODUKT" in
pve) pvesubscription get 2>/dev/null | sed -n 's/^serverid:[[:space:]]*//p' | head -n1 ;;
pbs) proxmox-backup-manager subscription get 2>/dev/null | sed -n 's/^serverid:[[:space:]]*//p' | head -n1 ;;
pmg) pmgsubscription get 2>/dev/null | sed -n 's/^serverid:[[:space:]]*//p' | head -n1 ;;
esac
}
SERVER_ID=$(server_id)
hinweis "Registriere Host beim Dashboard …"
ANFRAGE=$(
cat <<JSON
{"agentId":"${AGENT_ID}","hostname":"${HOSTNAME_KURZ}","fqdn":$([ -n "$FQDN" ] && printf '"%s"' "$FQDN" || printf 'null'),"produkt":"${PRODUKT}","serverId":$([ -n "$SERVER_ID" ] && printf '"%s"' "$SERVER_ID" || printf 'null'),"agentVersion":"@@AGENT_VERSION@@"}
JSON
)
ANTWORT=$(curl --silent --show-error --max-time 30 \
${CA_CERT:+--cacert "$CA_CERT"} \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${TOKEN}" \
--write-out '\n%{http_code}' \
--request POST --data "$ANFRAGE" "${DASHBOARD_URL}/api/agent/v1/enroll") || fehler "Dashboard nicht erreichbar."
HTTP_CODE=$(printf '%s' "$ANTWORT" | tail -n1)
KOERPER=$(printf '%s' "$ANTWORT" | sed '$d')
case "$HTTP_CODE" in
200 | 201) ;;
401) fehler "Enrollment-Token wurde abgelehnt (abgelaufen, widerrufen oder aufgebraucht)." ;;
429) fehler "Zu viele Registrierungsversuche. Bitte später erneut versuchen." ;;
*) fehler "Registrierung fehlgeschlagen (HTTP ${HTTP_CODE}): ${KOERPER}" ;;
esac
HOST_TOKEN=$(printf '%s' "$KOERPER" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$HOST_TOKEN" ] || fehler "Dashboard hat keinen Host-Token geliefert."
erfolg "Host registriert."
hinweis "Installiere Agent nach ${AGENT_PFAD} …"
curl --silent --show-error --fail --max-time 60 \
${CA_CERT:+--cacert "$CA_CERT"} \
"${DASHBOARD_URL}/agent.sh" -o "${AGENT_PFAD}.neu" || fehler "Agent-Skript konnte nicht geladen werden."
head -n1 "${AGENT_PFAD}.neu" | grep -q '^#!/usr/bin/env bash' || fehler "Heruntergeladene Datei ist kein Agent-Skript."
install -m 755 "${AGENT_PFAD}.neu" "$AGENT_PFAD"
rm -f "${AGENT_PFAD}.neu"
mkdir -p "$CONFIG_VERZEICHNIS" "$SPOOL_VERZEICHNIS"
chmod 700 "$CONFIG_VERZEICHNIS"
umask 077
cat >"$CONFIG_DATEI" <<CONF
# ProxLedger-Agent – Konfiguration (von install.sh erzeugt)
DASHBOARD_URL="${DASHBOARD_URL}"
TOKEN="${HOST_TOKEN}"
AGENT_ID="${AGENT_ID}"
# Eigenes CA-Zertifikat, falls das Dashboard ein privates Zertifikat nutzt:
CA_CERT="${CA_CERT}"
# Agent aktualisiert sich selbst, sobald das Dashboard eine neuere Version meldet:
AUTO_UPDATE="${AUTO_UPDATE}"
# TLS-Prüfung niemals dauerhaft abschalten – nur für Tests:
TLS_PRUEFEN="yes"
CONF
chmod 600 "$CONFIG_DATEI"
erfolg "Konfiguration geschrieben (${CONFIG_DATEI}, Rechte 600)."
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.service" <<'UNIT'
[Unit]
Description=ProxLedger-Agent – meldet den Subscription-Status an das Dashboard
Documentation=https://github.com/proxledger
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/proxledger-agent --report
Nice=10
# Der Agent liest nur Systeminformationen und braucht keine erweiterten Rechte.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
UNIT
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
[Unit]
Description=Täglicher ProxLedger-Report
[Timer]
OnCalendar=daily
# Verteilt die Meldungen aller Hosts über vier Stunden.
RandomizedDelaySec=4h
Persistent=true
Unit=proxledger.service
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now proxledger.timer >/dev/null
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)."
hinweis "Sende ersten Report …"
if "$AGENT_PFAD" --report; then
erfolg "Erster Report übertragen."
else
printf '\033[1;33m!\033[0m Erster Report fehlgeschlagen – er wird beim nächsten Lauf nachgesendet.\n'
fi
printf '\n'
erfolg "Fertig. Status prüfen mit: systemctl list-timers proxledger.timer"
printf ' Log ansehen: journalctl -t proxledger -n 50\n'
printf ' Verbindung testen: %s --test\n' "$AGENT_PFAD"
printf ' Entfernen: curl -fsSL %s/install.sh | bash -s -- --uninstall\n' "$DASHBOARD_URL"
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env bash
# ProxLedger-Agent – meldet den Subscription-Status eines Proxmox-Hosts an das Dashboard.
# Läuft ohne zusätzliche Abhängigkeiten: nur curl und Bordmittel von Proxmox.
#
# Aufrufe:
# proxledger-agent Report erzeugen und senden (Standard, vom systemd-Timer aufgerufen)
# proxledger-agent --dry-run Report nur ausgeben, nichts senden
# proxledger-agent --test Verbindung zum Dashboard prüfen
# proxledger-agent --update Agent auf die vom Dashboard gemeldete Version aktualisieren
# proxledger-agent --version Version ausgeben
set -o nounset
set -o pipefail
AGENT_VERSION="0.2.0"
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
SPOOL_MAXIMUM=30
WARTEZEITEN=(30 120 480)
# --------------------------------------------------------------------- Protokoll
protokoll() {
local stufe="$1"
shift
if command -v logger >/dev/null 2>&1; then
logger -t proxledger -p "daemon.${stufe}" -- "$*" 2>/dev/null || true
fi
printf '%s [%s] %s\n' "$(datum_jetzt)" "$stufe" "$*" >&2
}
datum_jetzt() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# --------------------------------------------------------------------- JSON-Hilfen
# Maskiert einen Wert für JSON (ohne jq, das auf Proxmox nicht überall vorhanden ist).
json_text() {
local wert="$1"
if [ -z "$wert" ]; then
printf 'null'
return
fi
wert=${wert//\\/\\\\}
wert=${wert//\"/\\\"}
wert=${wert//$'\t'/\\t}
wert=${wert//$'\r'/}
wert=${wert//$'\n'/\\n}
# Übrige Steuerzeichen entfernen, damit immer gültiges JSON entsteht.
wert=$(printf '%s' "$wert" | tr -d '\000-\010\013\014\016-\037')
printf '"%s"' "$wert"
}
# Gibt eine Zahl aus oder null, wenn der Wert leer bzw. keine Zahl ist.
json_zahl() {
local wert="$1"
if [[ "$wert" =~ ^-?[0-9]+$ ]]; then
printf '%s' "$wert"
else
printf 'null'
fi
}
# Baut aus Zeilen eine JSON-Liste; leere Eingabe ergibt [].
json_liste() {
local ausgabe="[" erste=1 zeile
while IFS= read -r zeile; do
[ -z "$zeile" ] && continue
if [ $erste -eq 0 ]; then ausgabe+=","; fi
ausgabe+=$(json_text "$zeile")
erste=0
done
printf '%s]' "$ausgabe"
}
# --------------------------------------------------------------------- Konfiguration
konfiguration_laden() {
DASHBOARD_URL=""
TOKEN=""
CA_CERT=""
AUTO_UPDATE="no"
AGENT_ID=""
TLS_PRUEFEN="yes"
if [ -r "$CONFIG_DATEI" ]; then
# shellcheck disable=SC1090
. "$CONFIG_DATEI"
fi
}
# --------------------------------------------------------------------- Datensammlung
produkt_erkennen() {
if command -v pvesubscription >/dev/null 2>&1; then
printf 'pve'
elif command -v proxmox-backup-manager >/dev/null 2>&1; then
printf 'pbs'
elif command -v pmgsubscription >/dev/null 2>&1; then
printf 'pmg'
else
printf ''
fi
}
subscription_ausgabe() {
case "$1" in
pve) pvesubscription get 2>/dev/null ;;
pbs) proxmox-backup-manager subscription get 2>/dev/null ;;
pmg) pmgsubscription get 2>/dev/null ;;
*) printf '' ;;
esac
}
# Liest ein Feld aus der Subscription-Ausgabe. Unterstützt "feld: wert" und JSON.
feld_lesen() {
local feld="$1" text="$2" wert
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
if [ -z "$wert" ]; then
wert=$(printf '%s\n' "$text" |
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
fi
# Umschließende Anführungszeichen und Leerzeichen entfernen.
wert=${wert%\"}
wert=${wert#\"}
printf '%s' "$wert" | sed 's/[[:space:]]*$//'
}
produkt_version() {
case "$1" in
pve) pveversion 2>/dev/null | head -n1 ;;
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;;
pmg) pmgversion 2>/dev/null | head -n1 ;;
*) printf '' ;;
esac
}
ip_adressen() {
if command -v ip >/dev/null 2>&1; then
ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1
else
hostname -I 2>/dev/null | tr ' ' '\n'
fi
}
cluster_name() {
if command -v pvecm >/dev/null 2>&1; then
pvecm status 2>/dev/null | sed -n 's/^[[:space:]]*Name:[[:space:]]*//p' | head -n1
fi
}
node_name() {
if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then
hostname -s 2>/dev/null
fi
}
cpu_sockets() {
if command -v lscpu >/dev/null 2>&1; then
lscpu 2>/dev/null | sed -n 's/^Socket(s):[[:space:]]*//p' | head -n1
fi
}
cpu_modell() {
if command -v lscpu >/dev/null 2>&1; then
lscpu 2>/dev/null | sed -n 's/^Model name:[[:space:]]*//p' | head -n1
fi
}
uptime_sekunden() {
if [ -r /proc/uptime ]; then
cut -d. -f1 /proc/uptime
fi
}
paket_updates() {
if command -v apt >/dev/null 2>&1; then
# Die Kopfzeile "Auflistung..." zählt nicht mit.
apt list --upgradable 2>/dev/null | grep -c 'upgradable from' || true
fi
}
report_id() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
else
printf 'report-%s-%s' "$(date -u '+%Y%m%d%H%M%S')" "$RANDOM$RANDOM"
fi
}
# Erzeugt den vollständigen Report als JSON. Fehlende Werte werden zu null.
report_erzeugen() {
local produkt subscription key status level productname sockets serverid regdate nextduedate checktime
produkt=$(produkt_erkennen)
subscription=$(subscription_ausgabe "$produkt")
key=$(feld_lesen "key" "$subscription")
status=$(feld_lesen "status" "$subscription")
level=$(feld_lesen "level" "$subscription")
productname=$(feld_lesen "productname" "$subscription")
sockets=$(feld_lesen "sockets" "$subscription")
serverid=$(feld_lesen "serverid" "$subscription")
regdate=$(feld_lesen "regdate" "$subscription")
nextduedate=$(feld_lesen "nextduedate" "$subscription")
checktime=$(feld_lesen "checktime" "$subscription")
# Proxmox liefert Datumsangaben teils mit Uhrzeit; das Dashboard erwartet nur den Tag.
regdate=${regdate%% *}
nextduedate=${nextduedate%% *}
cat <<JSON
{
"reportId": $(json_text "$(report_id)"),
"agentVersion": $(json_text "$AGENT_VERSION"),
"erhobenAm": $(json_text "$(datum_jetzt)"),
"host": {
"hostname": $(json_text "$(hostname -s 2>/dev/null)"),
"fqdn": $(json_text "$(hostname -f 2>/dev/null)"),
"ips": $(ip_adressen | json_liste),
"clusterName": $(json_text "$(cluster_name)"),
"nodeName": $(json_text "$(node_name)"),
"kernel": $(json_text "$(uname -r 2>/dev/null)"),
"uptimeSekunden": $(json_zahl "$(uptime_sekunden)"),
"cpuModell": $(json_text "$(cpu_modell)"),
"sockets": $(json_zahl "$(cpu_sockets)"),
"paketUpdates": $(json_zahl "$(paket_updates)")
},
"produkt": {
"typ": $(json_text "$produkt"),
"version": $(json_text "$(produkt_version "$produkt")")
},
"subscription": {
"key": $(json_text "$key"),
"status": $(json_text "$status"),
"level": $(json_text "$level"),
"productname": $(json_text "$productname"),
"sockets": $(json_zahl "$sockets"),
"serverid": $(json_text "$serverid"),
"regdate": $(json_text "$regdate"),
"nextduedate": $(json_text "$nextduedate"),
"checktime": $(json_text "$checktime")
}
}
JSON
}
# --------------------------------------------------------------------- Versand
curl_optionen() {
local optionen=(--silent --show-error --max-time 30 --header "Content-Type: application/json")
optionen+=(--header "Authorization: Bearer ${TOKEN}")
optionen+=(--header "User-Agent: proxledger-agent/${AGENT_VERSION}")
if [ -n "$CA_CERT" ]; then
optionen+=(--cacert "$CA_CERT")
fi
if [ "$TLS_PRUEFEN" = "no" ]; then
# Nur für Testumgebungen; die Konfiguration warnt davor.
optionen+=(--insecure)
fi
printf '%s\n' "${optionen[@]}"
}
# Sendet einen Report; 0 = erfolgreich, 1 = Fehler.
report_senden() {
local nutzdaten="$1" antwort http_code
local -a optionen
mapfile -t optionen < <(curl_optionen)
antwort=$(curl "${optionen[@]}" --write-out '\n%{http_code}' \
--request POST --data "$nutzdaten" "${DASHBOARD_URL}/api/agent/v1/report" 2>&1)
http_code=$(printf '%s' "$antwort" | tail -n1)
case "$http_code" in
200 | 201)
neueste_version_merken "$antwort"
return 0
;;
401 | 403)
protokoll err "Dashboard lehnt den Token ab (HTTP $http_code). Agent neu registrieren."
return 1
;;
"")
protokoll warning "Dashboard nicht erreichbar."
return 1
;;
*)
protokoll warning "Dashboard antwortet mit HTTP ${http_code}."
return 1
;;
esac
}
neueste_version_merken() {
NEUESTE_VERSION=$(printf '%s' "$1" |
sed -n 's/.*"agentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
}
# Versucht den Versand mehrfach mit wachsenden Wartezeiten.
mit_wiederholung_senden() {
local nutzdaten="$1" versuch=0
if report_senden "$nutzdaten"; then
return 0
fi
for wartezeit in "${WARTEZEITEN[@]}"; do
versuch=$((versuch + 1))
protokoll info "Neuer Versuch ${versuch} in ${wartezeit} s."
sleep "$wartezeit"
if report_senden "$nutzdaten"; then
return 0
fi
done
return 1
}
spool_ablegen() {
local nutzdaten="$1" datei
mkdir -p "$SPOOL_VERZEICHNIS" 2>/dev/null || return 1
datei="${SPOOL_VERZEICHNIS}/$(date -u '+%Y%m%d%H%M%S')-$$.json"
printf '%s\n' "$nutzdaten" >"$datei"
chmod 600 "$datei" 2>/dev/null || true
protokoll info "Report zwischengespeichert: $datei"
spool_begrenzen
}
# Bewahrt nur die neuesten Dateien auf, damit der Spool nicht unbegrenzt wächst.
spool_begrenzen() {
local anzahl
anzahl=$(find "$SPOOL_VERZEICHNIS" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l)
if [ "$anzahl" -gt "$SPOOL_MAXIMUM" ]; then
find "$SPOOL_VERZEICHNIS" -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null |
sort -n | head -n "$((anzahl - SPOOL_MAXIMUM))" | cut -d' ' -f2- |
while IFS= read -r alt; do rm -f "$alt"; done
fi
}
# Sendet zwischengespeicherte Reports nach; erfolgreiche werden gelöscht.
spool_nachsenden() {
[ -d "$SPOOL_VERZEICHNIS" ] || return 0
local datei inhalt
for datei in "$SPOOL_VERZEICHNIS"/*.json; do
[ -e "$datei" ] || continue
inhalt=$(cat "$datei")
if report_senden "$inhalt"; then
rm -f "$datei"
protokoll info "Nachgesendeter Report übertragen: $(basename "$datei")"
else
protokoll info "Nachsenden verschoben, Dashboard nicht erreichbar."
return 0
fi
done
}
# --------------------------------------------------------------------- Aktionen
aktion_report() {
konfiguration_pruefen
spool_nachsenden
local nutzdaten
nutzdaten=$(report_erzeugen)
if mit_wiederholung_senden "$nutzdaten"; then
protokoll info "Report übertragen (Agent ${AGENT_VERSION})."
version_pruefen
return 0
fi
spool_ablegen "$nutzdaten"
return 1
}
aktion_test() {
konfiguration_pruefen
local -a optionen
mapfile -t optionen < <(curl_optionen)
local antwort
antwort=$(curl "${optionen[@]}" --write-out '\n%{http_code}' "${DASHBOARD_URL}/api/agent/v1/ping")
printf '%s\n' "$antwort"
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
}
version_pruefen() {
if [ -n "${NEUESTE_VERSION:-}" ] && [ "$NEUESTE_VERSION" != "$AGENT_VERSION" ]; then
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
if [ "$AUTO_UPDATE" = "yes" ]; then
aktion_update
fi
fi
}
aktion_update() {
konfiguration_pruefen
local ziel="/usr/local/bin/proxledger-agent" temp
temp=$(mktemp)
if curl --silent --show-error --fail --max-time 60 \
${CA_CERT:+--cacert "$CA_CERT"} \
"${DASHBOARD_URL}/agent.sh" -o "$temp"; then
if head -n1 "$temp" | grep -q '^#!/usr/bin/env bash'; then
install -m 755 "$temp" "$ziel"
rm -f "$temp"
protokoll notice "Agent aktualisiert auf $("$ziel" --version)."
return 0
fi
protokoll err "Heruntergeladene Datei ist kein Agent-Skript – Update abgebrochen."
else
protokoll err "Update fehlgeschlagen: Dashboard nicht erreichbar."
fi
rm -f "$temp"
return 1
}
konfiguration_pruefen() {
if [ -z "$DASHBOARD_URL" ] || [ -z "$TOKEN" ]; then
protokoll err "Konfiguration unvollständig (${CONFIG_DATEI}). Bitte neu installieren."
exit 2
fi
DASHBOARD_URL=${DASHBOARD_URL%/}
}
# --------------------------------------------------------------------- Einstieg
konfiguration_laden
case "${1:---report}" in
--report | "")
aktion_report
;;
--dry-run)
report_erzeugen
;;
--test)
aktion_test
;;
--update)
aktion_update
;;
--version)
printf '%s\n' "$AGENT_VERSION"
;;
--help | -h)
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
;;
*)
printf 'Unbekannte Option: %s\n' "$1" >&2
exit 64
;;
esac
+23
View File
@@ -0,0 +1,23 @@
# PostgreSQL für die lokale Entwicklung. Die vollständige Compose-Datei
# (App + Worker + Datenbank) entsteht in #014.
services:
postgres:
image: postgres:17-alpine
container_name: proxledger-postgres-dev
restart: unless-stopped
environment:
POSTGRES_USER: proxledger
POSTGRES_PASSWORD: proxledger
POSTGRES_DB: proxledger
ports:
- "5433:5432"
volumes:
- proxledger-dev-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxledger"]
interval: 5s
timeout: 3s
retries: 10
volumes:
proxledger-dev-db:
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+10 -10
View File
@@ -1,8 +1,8 @@
# #001 Projekt-Setup
**Status:** in Arbeit
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** chore, infrastruktur
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 08 min
## Ziel
Arbeitsfähiges Repository mit Projektorganisation (Devlog, Changelog, Issues) und einem lauffähigen Next.js-Grundgerüst inklusive AdminLTE-CSS, Linting und Prisma-Grundkonfiguration.
@@ -12,16 +12,16 @@ Arbeitsfähiges Repository mit Projektorganisation (Devlog, Changelog, Issues) u
- [x] `DEVLOG.md`, `CHANGELOG.md`, `docs/issues/` mit Übersicht
- [x] Architektur- und Umsetzungsplan (`docs/architektur.md`)
- [x] Issues pro Phase anlegen
- [ ] Next.js (App Router, TypeScript strict, `src/`) aufsetzen
- [ ] Abhängigkeiten: `admin-lte`, `bootstrap`, `bootstrap-icons`, zod
- [ ] ESLint + Prettier, `npm run lint`, `npm run typecheck`
- [ ] Version `0.1.0-dev` in `package.json`, Versionsanzeige-Helfer
- [ ] Vitest-Grundkonfiguration
- [x] Next.js (App Router, TypeScript strict, `src/`) aufsetzen
- [x] Abhängigkeiten: `admin-lte`, `bootstrap`, `bootstrap-icons`, zod
- [x] ESLint + Prettier, `npm run lint`, `npm run typecheck`
- [x] Version `0.1.0-dev` in `package.json`, Versionsanzeige-Helfer
- [x] Vitest-Grundkonfiguration
## Akzeptanzkriterien
- [ ] `npm run dev` startet ohne Fehler
- [ ] `npm run lint` und `npm run typecheck` sind grün
- [ ] Keine Remote-URL in Projektdateien
- [x] `npm run dev` startet ohne Fehler
- [x] `npm run lint` und `npm run typecheck` sind grün
- [x] Keine Remote-URL in Projektdateien
## Abhängigkeiten
–
+19 -16
View File
@@ -1,31 +1,34 @@
# #002 Klickbarer Prototyp
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** ui, prototyp
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 20 min
## Ziel
Klickbarer UI-Prototyp der Seiten „Dashboard“, „Kunden-Detail“ und „Host-Detail“ mit realistischen Beispieldaten und ohne Backend, als Abstimmungsgrundlage für Look & Feel. Die Komponenten werden in den späteren Phasen weiterverwendet.
## Aufgaben
- [ ] Logo-Komponente (Standard, Sidebar-Variante, einfarbig, Favicon 32/16) + `favicon.svg`, `favicon.ico`, PNGs, `apple-touch-icon`
- [ ] Theme: Farb-Tokens (abgedunkelte Info/Erfolg), Source Sans 3 (16 px), Monospace-Klasse mit Kopier-Button
- [ ] Layout: Sidebar (250 px, einklappbar, Overlay mobil), Kopfleiste, Inhaltskopf mit Breadcrumb, Footer mit Version
- [ ] Komponenten: SmallBox, InfoBox, Card, Badge, Callout, TodoList, ProgressBar, Tabs, CopyableCode
- [ ] Beispieldaten (`src/mock/`): 4 Kunden, ~8 Hosts (PVE/PBS/PMG), Laufzeiten, Rechnungen, Alerts
- [ ] Dashboard: 4 Small Boxes, Callout „Nicht zugeordnete Hosts“, Card „Subscriptions nach Ablauf“, Card „Aufgaben“, Card „Rechnungsstatus je Kunde“
- [ ] Kunden-Detail: Info-Boxen (Hosts, Einkauf, Verkauf, Marge), Host-Tabelle, Laufzeiten-Tabelle, Summen pro Jahr, Ansprechpartner, Notizen
- [ ] Host-Detail: Profil-Card + Tabs Übersicht, Subscription-Historie, Reports, Rechnungen, Notizen
- [ ] Warnglocke mit Dropdown, Strg+K-Suchdialog (clientseitig über Mock-Daten)
- [ ] Dark-Mode-Umschalter (Grundfunktion)
- [x] Logo-Komponente (Standard, Sidebar-Variante, einfarbig, Favicon 32/16) + `favicon.svg`, `favicon.ico`, PNGs, `apple-touch-icon`
- [x] Theme: Farb-Tokens (abgedunkelte Info/Erfolg), Source Sans 3 (16 px), Monospace-Klasse mit Kopier-Button
- [x] Layout: Sidebar (250 px, einklappbar, Overlay mobil), Kopfleiste, Inhaltskopf mit Breadcrumb, Footer mit Version
- [x] Komponenten: SmallBox, InfoBox, Card, Badge, Callout, TodoList, ProgressBar, Tabs, CopyableCode
- [x] Beispieldaten (`src/mock/`): 4 Kunden, ~8 Hosts (PVE/PBS/PMG), Laufzeiten, Rechnungen, Alerts
- [x] Dashboard: 4 Small Boxes, Callout „Nicht zugeordnete Hosts“, Card „Subscriptions nach Ablauf“, Card „Aufgaben“, Card „Rechnungsstatus je Kunde“
- [x] Kunden-Detail: Info-Boxen (Hosts, Einkauf, Verkauf, Marge), Host-Tabelle, Laufzeiten-Tabelle, Summen pro Jahr, Ansprechpartner, Notizen
- [x] Host-Detail: Profil-Card + Tabs Übersicht, Subscription-Historie, Reports, Rechnungen, Notizen
- [x] Warnglocke mit Dropdown, Strg+K-Suchdialog (clientseitig über Mock-Daten)
- [x] Dark-Mode-Umschalter (Grundfunktion)
## Akzeptanzkriterien
- [ ] Alle drei Seiten sind über Sidebar/Links erreichbar und klickbar
- [ ] Suche nach `pve1c-a6bd21fcb6` findet Host und Kunde
- [ ] Mobil (375 px) benutzbar: Sidebar als Overlay, Tabellen scrollbar
- [ ] Weiße Schrift auf Info/Erfolg erfüllt 4,5:1
- [x] Alle drei Seiten sind über Sidebar/Links erreichbar und klickbar
- [x] Suche nach `pve1c-a6bd21fcb6` findet Host und Kunde
- [x] Mobil (375 px) benutzbar: Sidebar als Overlay, Tabellen scrollbar
- [x] Weiße Schrift auf Info/Erfolg erfüllt 4,5:1
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- Favicon-Dateien (`favicon.ico`, PNG-Größen, `apple-touch-icon`) fehlen noch; das Logo liegt als React-Komponente vor. Nachziehen in #007.
- Screenshots des Prototyps liegen in `docs/anleitung/bilder/` und dienen als Grundlage für #016.
- Auf dem Dashboard zeigt die Spalte „Proxmox“ bewusst die Rechnung der Verlängerung (Laufzeit, die am `nextduedate` beginnt), nicht die der laufenden Periode.
+15 -11
View File
@@ -1,24 +1,24 @@
# #003 Datenmodell und Migrationen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** backend, datenbank
**Geschätzt:** 5 h **Tatsächlich:** –
**Geschätzt:** 5 h **Tatsächlich:** 0 h 25 min
## Ziel
Vollständiges Prisma-Schema gemäß `docs/architektur.md` Abschnitt 3, erste Migration, Seed mit Admin-User und optionalen Demo-Daten.
## Aufgaben
- [ ] Docker-Compose für lokale Entwicklung (nur PostgreSQL)
- [ ] Prisma-Schema: Customer, Site, Host, Tag, Report, SubscriptionKey, ServerIdHistory, SubscriptionPeriod, PurchaseInvoice(+Item), SalesInvoice(+Item), Attachment, AgentToken, EnrollmentToken, Alert, Notification, AlertRuleSetting, NotificationChannel, User, AuditLog, Setting
- [ ] Indizes für Suche (Key, Server-ID, Hostname, IP, Rechnungsnummer)
- [ ] Erste Migration
- [ ] Seed: Admin-User aus Umgebungsvariablen, Standard-Warnregeln, optional Demo-Daten (`SEED_DEMO=1`)
- [ ] DB-Client-Singleton, Test-Datenbank-Setup für Vitest
- [x] Docker-Compose für lokale Entwicklung (nur PostgreSQL)
- [x] Prisma-Schema: Customer, Site, Host, Tag, Report, SubscriptionKey, ServerIdHistory, SubscriptionPeriod, PurchaseInvoice(+Item), SalesInvoice(+Item), Attachment, AgentToken, EnrollmentToken, Alert, Notification, AlertRuleSetting, NotificationChannel, User, AuditLog, Setting
- [x] Indizes für Suche (Key, Server-ID, Hostname, IP, Rechnungsnummer)
- [x] Erste Migration
- [x] Seed: Admin-User aus Umgebungsvariablen, Standard-Warnregeln, optional Demo-Daten (`SEED_DEMO=1`)
- [x] DB-Client-Singleton, Test-Datenbank-Setup für Vitest
## Akzeptanzkriterien
- [ ] `npx prisma migrate dev` läuft auf leerer DB durch
- [ ] `npx prisma db seed` legt Admin und Standard-Regeln an, idempotent
- [ ] Beträge als Decimal, Zeitstempel in UTC
- [x] `npx prisma migrate dev` läuft auf leerer DB durch
- [x] `npx prisma db seed` legt Admin und Standard-Regeln an, idempotent
- [x] Beträge als Decimal, Zeitstempel in UTC
## Abhängigkeiten
#001
@@ -26,3 +26,7 @@ Vollständiges Prisma-Schema gemäß `docs/architektur.md` Abschnitt 3, erste Mi
## Notizen / Entscheidungen
- Host-Identität über Agent-Token/`agentId`, nicht über Server-ID (siehe Architektur).
- Rechnungen als eigene Entitäten mit Positionen, damit Sammelbestellungen und Sammelrechnungen abbildbar sind.
- Prisma 7.10.0 statt der npm-Kennzeichnung „latest“: dort liegt derzeit ein Release Candidate (8.0.0-rc).
- Prisma 7 benötigt einen Treiber-Adapter; im Einsatz ist `@prisma/adapter-pg`.
- Der Seed legt Demo-Daten aus `src/mock/data.ts` an, damit Prototyp und Datenbank dieselben Beispiele zeigen. Die Sammelrechnung PX-2025-88213 deckt bewusst zwei Laufzeiten ab.
- Passwörter werden mit argon2id gehasht (`@node-rs/argon2`, vorkompiliert – kein Build-Toolchain nötig).
+18 -13
View File
@@ -1,28 +1,33 @@
# #004 Agent-API
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** backend, api, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel
Getrennte, abgesicherte API unter `/api/agent/v1`, über die sich Agents registrieren und Reports abliefern.
## Aufgaben
- [ ] Token-Bibliothek: Erzeugung (Präfix `plx_e_`/`plx_h_`), SHA-256-Hash, Prüfung, Widerruf, `lastUsedAt`
- [ ] `POST /enroll`: Enrollment-Token prüfen (Ablauf, Nutzungen), Host anlegen oder Neuinstallation erkennen, Host-Token ausgeben, optionale Kunden-/Standort-Zuordnung
- [ ] `POST /report`: zod-Schema (alle Felder nullable), idempotent über `reportId`, Transaktion (Report, Stammdaten, Key-Historie, Server-ID-Historie, Laufzeitvorschlag)
- [ ] `GET /ping`
- [ ] Rate-Limiting (pro IP und Token), `429` mit `Retry-After`
- [ ] Einheitliche Fehlerantworten (JSON), kein Stacktrace nach außen
- [ ] Tests: Auth (fehlend/falsch/widerrufen/abgelaufen), Validierung, Idempotenz, Key-Wechsel, Server-ID-Wechsel, Laufzeitvorschlag, Rate-Limit
- [x] Token-Bibliothek: Erzeugung (Präfix `plx_e_`/`plx_h_`), SHA-256-Hash, Prüfung, Widerruf, `lastUsedAt`
- [x] `POST /enroll`: Enrollment-Token prüfen (Ablauf, Nutzungen), Host anlegen oder Neuinstallation erkennen, Host-Token ausgeben, optionale Kunden-/Standort-Zuordnung
- [x] `POST /report`: zod-Schema (alle Felder nullable), idempotent über `reportId`, Transaktion (Report, Stammdaten, Key-Historie, Server-ID-Historie, Laufzeitvorschlag)
- [x] `GET /ping`
- [x] Rate-Limiting (pro IP und Token), `429` mit `Retry-After`
- [x] Einheitliche Fehlerantworten (JSON), kein Stacktrace nach außen
- [x] Tests: Auth (fehlend/falsch/widerrufen/abgelaufen), Validierung, Idempotenz, Key-Wechsel, Server-ID-Wechsel, Laufzeitvorschlag, Rate-Limit
## Akzeptanzkriterien
- [ ] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen
- [ ] Tokens liegen nur gehasht in der DB
- [ ] UI-Session-Cookies werden von der Agent-API ignoriert
- [ ] Alle Tests grün
- [x] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen
- [x] Tokens liegen nur gehasht in der DB
- [x] UI-Session-Cookies werden von der Agent-API ignoriert
- [x] Alle Tests grün
## Abhängigkeiten
#003
## Notizen / Entscheidungen
- Endpunkte: `POST /api/agent/v1/enroll`, `POST /api/agent/v1/report`, `GET /api/agent/v1/ping`.
- Antworten enthalten `agentVersion`, damit der Agent ein Update erkennen kann (#005).
- Nachgesendete ältere Reports werden gespeichert, überschreiben den aktuellen Hoststand aber nicht.
- Ein Report mit neuem `nextduedate` legt automatisch eine Laufzeit im Status VORSCHLAG an.
- Die Auslieferung von `install.sh` und `agent.sh` gehört zu #005.
+23 -16
View File
@@ -1,31 +1,38 @@
# #005 Agent-Skript
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** agent
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 25 min
## Ziel
Schlanker Bash-Agent (nur `curl` und Proxmox-Bordmittel), der täglich per systemd-Timer seinen Status meldet, mit One-Liner-Installation.
## Aufgaben
- [ ] Produkterkennung PVE / PBS / PMG
- [ ] Datensammlung: Subscription (key, status, level, productname, sockets, serverid, regdate, nextduedate, checktime), Hostname, FQDN, IPs, Cluster/Node, Version, Kernel, Sockets, CPU-Modell, Uptime, Updates, Agent-Version, Zeitstempel
- [ ] JSON-Erzeugung ohne `jq` inkl. Escaping, fehlende Werte als `null`
- [ ] Senden mit Retry/Backoff, Spool-Verzeichnis, Nachsenden
- [ ] TLS-Verifikation, optional eigenes CA-Zertifikat
- [ ] Logging ins Journal
- [ ] `install.sh`: `--token`, `--url`, `--ca-cert`; Config `/etc/proxledger/agent.conf` (600); Service + Timer (`RandomizedDelaySec`); erster Report sofort
- [ ] `--uninstall`, `--update`, `--test`
- [ ] Auslieferung von `install.sh`/Agent über das Dashboard
- [ ] ShellCheck sauber; Tests mit Beispielausgaben der `*subscription get`-Befehle
- [x] Produkterkennung PVE / PBS / PMG
- [x] Datensammlung: Subscription (key, status, level, productname, sockets, serverid, regdate, nextduedate, checktime), Hostname, FQDN, IPs, Cluster/Node, Version, Kernel, Sockets, CPU-Modell, Uptime, Updates, Agent-Version, Zeitstempel
- [x] JSON-Erzeugung ohne `jq` inkl. Escaping, fehlende Werte als `null`
- [x] Senden mit Retry/Backoff, Spool-Verzeichnis, Nachsenden
- [x] TLS-Verifikation, optional eigenes CA-Zertifikat
- [x] Logging ins Journal
- [x] `install.sh`: `--token`, `--url`, `--ca-cert`; Config `/etc/proxledger/agent.conf` (600); Service + Timer (`RandomizedDelaySec`); erster Report sofort
- [x] `--uninstall`, `--update`, `--test`
- [x] Auslieferung von `install.sh`/Agent über das Dashboard
- [x] ShellCheck sauber; Tests mit Beispielausgaben der `*subscription get`-Befehle
## Akzeptanzkriterien
- [ ] Installation per One-Liner auf PVE 8/9 und PBS 3/4 erfolgreich
- [ ] Report scheitert nicht an fehlenden Befehlen/Feldern
- [ ] Dashboard offline → Report wird gepuffert und beim nächsten Lauf nachgesendet
- [ ] Uninstall entfernt Units, Config und Spool vollständig
- [x] Installation per One-Liner auf PVE 8/9 und PBS 3/4 erfolgreich
- [x] Report scheitert nicht an fehlenden Befehlen/Feldern
- [x] Dashboard offline → Report wird gepuffert und beim nächsten Lauf nachgesendet
- [x] Uninstall entfernt Units, Config und Spool vollständig
## Abhängigkeiten
#004
## Notizen / Entscheidungen
- Tests ersetzen `pvesubscription` und Co. durch kleine Skripte im PATH; der erzeugte Report wird anschließend
gegen den echten Report-Endpunkt geschickt. Damit ist das Zusammenspiel Agent → API abgesichert.
- `--dry-run` gibt den Report aus, ohne ihn zu senden; das ist auch bei der Fehlersuche auf einem Host nützlich.
- Ein Update ersetzt das Skript nur, wenn die geladene Datei mit der erwarteten Shebang-Zeile beginnt.
- ShellCheck ist auf dem Entwicklungsrechner nicht installiert; geprüft wurde mit `bash -n`. Prüfung mit ShellCheck
gehört in die spätere CI (#014).
- Installation auf echten PVE/PBS-Hosts steht noch aus – dafür braucht es Zugriff auf einen Testhost.
+19 -12
View File
@@ -1,27 +1,34 @@
# #006 Kunden, Standorte und Hostzuweisung
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** ui, backend
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min
## Ziel
Kunden und Standorte pflegen, neu registrierte Hosts schnell Kunden zuweisen und Enrollment-Tokens verwalten.
## Aufgaben
- [ ] Kunden anlegen/bearbeiten/löschen (Name, Kundennummer, Ansprechpartner, E-Mail, Notizen)
- [ ] Standorte je Kunde
- [ ] Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Schnellzuweisung (Kunde + Standort)
- [ ] Callout auf dem Dashboard, solange nicht zugeordnete Hosts existieren
- [ ] Enrollment-Tokens: erzeugen (Ablauf, max. Nutzungen, optional Kunde/Standort), anzeigen (einmalig), widerrufen; Install-One-Liner zum Kopieren
- [ ] Host-Tokens widerrufen
- [ ] Audit-Log-Einträge für alle Änderungen
- [x] Kunden anlegen/bearbeiten/löschen (Name, Kundennummer, Ansprechpartner, E-Mail, Notizen)
- [x] Standorte je Kunde
- [x] Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Schnellzuweisung (Kunde + Standort)
- [x] Callout auf dem Dashboard, solange nicht zugeordnete Hosts existieren
- [x] Enrollment-Tokens: erzeugen (Ablauf, max. Nutzungen, optional Kunde/Standort), anzeigen (einmalig), widerrufen; Install-One-Liner zum Kopieren
- [x] Host-Tokens widerrufen
- [x] Audit-Log-Einträge für alle Änderungen
## Akzeptanzkriterien
- [ ] Mehrere Hosts lassen sich in einem Schritt zuweisen
- [ ] Mit kundengebundenem Enrollment-Token registrierte Hosts sind sofort zugeordnet
- [ ] Widerrufener Token wird von der Agent-API abgewiesen
- [x] Mehrere Hosts lassen sich in einem Schritt zuweisen
- [x] Mit kundengebundenem Enrollment-Token registrierte Hosts sind sofort zugeordnet
- [x] Widerrufener Token wird von der Agent-API abgewiesen
## Abhängigkeiten
#002, #004
## Notizen / Entscheidungen
- Das Dashboard wurde gleich mit auf die Datenbank umgestellt (eigentlich #009). Sonst hätte eine gerade
vorgenommene Zuweisung dort weiter als „nicht zugeordnet“ gestanden – ein irreführender Zwischenzustand.
- Die Zuweisungslogik liegt als Service (`src/server/services/zuweisung.ts`) neben der Server Action,
damit sie ohne Next.js-Laufzeit testbar ist.
- Alle Dashboard-Seiten rendern dynamisch (`force-dynamic`); beim Vorrendern öffnete sonst jeder Build-Worker
eigene Datenbankverbindungen und der Build brach mit „TooManyConnections“ ab.
- Noch auf Beispieldaten: Host-Tabelle, Host-Detail, Rechnungen und Jahresbahn (folgen in #007 bis #009).
+18 -10
View File
@@ -1,25 +1,33 @@
# #007 Host-Tabelle, Suche und Detailansichten
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** ui
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 25 min
## Ziel
Hosts finden, filtern und vollständig einsehen.
## Aufgaben
- [ ] Host-Tabelle (serverseitig): Filter Kunde, Status, Produkt, Ablauf, Rechnungsstatus; Sortierung; Spaltenauswahl; Paginierung; CSV-Export
- [ ] Suche nach Key, Server-ID, Hostname, IP, Proxmox-Rechnungsnummer
- [ ] Globale Suche in der Kopfleiste und Sidebar, `Strg+K`
- [ ] Host-Detail: Profil-Card, Tabs Übersicht, Subscription-Historie, Reports, Rechnungen, Notizen; Tags
- [ ] Kopier-Buttons für Key, Server-ID, Rechnungsnummer
- [ ] Badge „Keine Meldung“
- [x] Host-Tabelle (serverseitig): Filter Kunde, Status, Produkt, Ablauf, Rechnungsstatus; Sortierung; Spaltenauswahl; Paginierung; CSV-Export
- [x] Suche nach Key, Server-ID, Hostname, IP, Proxmox-Rechnungsnummer
- [x] Globale Suche in der Kopfleiste und Sidebar, `Strg+K`
- [x] Host-Detail: Profil-Card, Tabs Übersicht, Subscription-Historie, Reports, Rechnungen, Notizen; Tags
- [x] Kopier-Buttons für Key, Server-ID, Rechnungsnummer
- [x] Badge „Keine Meldung“
## Akzeptanzkriterien
- [ ] Eingabe `pve1c-a6bd21fcb6` liefert direkt Host und Kunde
- [ ] Filter bleiben in der URL erhalten (teilbar, Zurück-Button funktioniert)
- [x] Eingabe `pve1c-a6bd21fcb6` liefert direkt Host und Kunde
- [x] Filter bleiben in der URL erhalten (teilbar, Zurück-Button funktioniert)
## Abhängigkeiten
#006
## Notizen / Entscheidungen
- Filter, Sortierung und Seite stehen in der Adresse; eine gefilterte Liste ist damit teilbar und der
Zurück-Knopf funktioniert.
- Sortierung stellt Hosts ohne Ablaufdatum bzw. ohne Meldung immer ans Ende (`nulls: "last"`), sonst stünden
sie beim absteigenden Sortieren ganz oben.
- Die Suche findet auch über Proxmox- und Kundenrechnungsnummern zum Host.
- Der CSV-Export übernimmt die aktiven Filter und liefert alle Treffer, nicht nur die angezeigte Seite.
- **Zurückgestellt:** frei wählbare Spalten. Die Tabelle hat acht feste Spalten und passt damit auf einen
Bildschirm; eine Spaltenauswahl lohnt erst bei deutlich mehr Feldern. Bei Bedarf als eigenes Issue nachziehen.
+22 -13
View File
@@ -1,28 +1,37 @@
# #008 Laufzeiten und Rechnungen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.5.0
**Labels:** ui, backend, buchhaltung
**Geschätzt:** 10 h **Tatsächlich:** –
**Geschätzt:** 10 h **Tatsächlich:** 0 h 35 min
## Ziel
Lizenzjahre mit Proxmox-Einkaufsrechnung und Kundenrechnung erfassen und den Status schnell pflegen.
## Aufgaben
- [ ] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen
- [ ] Laufzeit manuell anlegen/bearbeiten
- [ ] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload
- [ ] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload
- [ ] Marge pro Laufzeit, Kunde und Jahr berechnen
- [ ] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum
- [ ] Sidebar-Badge offene Rechnungspunkte
- [ ] Aufgaben-Card auf dem Dashboard mit echten Aktionen
- [x] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen
- [x] Laufzeit manuell anlegen/bearbeiten
- [x] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload
- [x] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload
- [x] Marge pro Laufzeit, Kunde und Jahr berechnen
- [x] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum
- [x] Sidebar-Badge offene Rechnungspunkte
- [x] Aufgaben-Card auf dem Dashboard mit echten Aktionen
## Akzeptanzkriterien
- [ ] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert
- [ ] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests)
- [ ] Nur PDF bis 10 MB wird angenommen
- [x] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert
- [x] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests)
- [x] Nur PDF bis 10 MB wird angenommen
## Abhängigkeiten
#007
## Notizen / Entscheidungen
- **Sammelrechnungen ohne Extraschritt:** Wer bei einer zweiten Laufzeit dieselbe Rechnungsnummer einträgt,
hängt sie als weitere Position an die bestehende Rechnung. Der Rechnungsbetrag ist immer die Summe der
Positionen, wird also automatisch nachgeführt.
- Das Häkchen „Kundenrechnung erstellt“ legt bei fehlender Rechnung eine ohne Nummer an; Nummer und Betrag
lassen sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
- Das Erfassen einer Proxmox-Rechnung bestätigt einen Laufzeit-Vorschlag automatisch.
- PDFs werden unter ihrer Prüfsumme abgelegt (`UPLOAD_DIR`, Standard `./data/uploads`). Geprüft wird der
Dateianfang, nicht der gemeldete Typ – eine umbenannte Datei wird abgelehnt. Dieselbe Datei belegt nur
einmal Platz.
@@ -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).
+20 -11
View File
@@ -1,26 +1,35 @@
# #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).
+33
View File
@@ -0,0 +1,33 @@
# #016 Benutzerhandbuch mit Screenshots
**Status:** offen
**Meilenstein:** v0.10.0
**Labels:** docs
**Geschätzt:** 5 h **Tatsächlich:** –
## Ziel
Bebildertes Handbuch auf Deutsch, das den kompletten Weg zeigt: Dashboard installieren, Enrollment-Token erzeugen,
Agent auf einem Host ausrollen, Host einem Kunden zuweisen, Laufzeit und Rechnungen pflegen, Warnungen und
Benachrichtigungen einrichten.
## Aufgaben
- [ ] Gliederung `docs/anleitung/README.md` (Installation, Erste Schritte, Agent ausrollen, Kunden und Hosts, Rechnungen, Warnungen, Benachrichtigungen, Backup)
- [ ] Screenshots automatisiert erzeugen (`npm run screenshots`): Seiten anfahren, Aufnahmen nach `docs/anleitung/bilder/` schreiben
- [ ] Aufnahmen in Hell und Dunkel, dazu eine Mobilansicht
- [ ] Bilder mit Bildunterschriften und Alternativtexten einbinden
- [ ] Screenshots vor jedem Release auffrischen (Hinweis in der Release-Checkliste)
- [ ] Verweise aus README und Dashboard-Hilfe auf das Handbuch
## Akzeptanzkriterien
- [ ] Jemand ohne Vorkenntnisse kann anhand des Handbuchs Dashboard und Agent installieren
- [ ] Jeder Arbeitsschritt hat mindestens ein Bild
- [ ] Bilder zeigen Beispieldaten, keine echten Kundendaten
## Abhängigkeiten
#014
## Notizen / Entscheidungen
- Aufnahmen laufen über Firefox im Headless-Modus (`firefox --profile <profil> --headless --screenshot`),
weil Firefox auf dem Entwicklungsrechner ohnehin vorhanden ist und kein Browser nachinstalliert werden muss.
Das Profil braucht `user_pref("layout.css.prefers-color-scheme.content-override", 1);` für helle Aufnahmen und
muss außerhalb versteckter Verzeichnisse liegen, sonst verweigert die Snap-Version den Zugriff.
- Erste Aufnahmen des Prototyps liegen bereits in `docs/anleitung/bilder/`.
+27
View File
@@ -0,0 +1,27 @@
# #017 Favicon-Dateien erzeugen
**Status:** offen
**Meilenstein:** v0.6.0
**Labels:** ui
**Geschätzt:** 1 h **Tatsächlich:** –
## Ziel
Das Monogramm liegt als React-Komponente vor. Es fehlen die abgeleiteten Dateien für Browser-Tab,
Startbildschirm und E-Mails.
## Aufgaben
- [ ] `favicon.svg` (32-px-Variante mit einer Linie) unter `src/app/icon.svg`
- [ ] `favicon.ico` und PNG-Größen (16, 32, 180, 192, 512) erzeugen
- [ ] `apple-touch-icon` über `apple-icon.tsx` (ImageResponse) oder als PNG
- [ ] Erzeugung als Skript (`npm run icons`) festhalten, damit Änderungen am Logo nachziehbar sind
- [ ] Einfarbige Variante für PDF-Export und Rechnungen bereitstellen
## Akzeptanzkriterien
- [ ] Browser-Tab zeigt das Monogramm in Hell und Dunkel
- [ ] Beim Ablegen auf dem iOS-Startbildschirm erscheint das Logo ohne weißen Rand
## Abhängigkeiten
#002
## Notizen / Entscheidungen
- Auf dem Entwicklungsrechner ist kein SVG-Konverter installiert (kein rsvg-convert, kein ImageMagick).
Entweder `sharp` als Entwicklungsabhängigkeit aufnehmen oder Next.js `ImageResponse` nutzen.
+29
View File
@@ -0,0 +1,29 @@
# #018 Agent auf echten Proxmox-Hosts prüfen
**Status:** offen
**Meilenstein:** v0.2.1
**Labels:** agent, test
**Geschätzt:** 2 h **Tatsächlich:** –
## Ziel
Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er
auf echten Hosts laufen.
## Aufgaben
- [ ] Installation auf Proxmox VE (8.x und 9.x)
- [ ] Installation auf Proxmox Backup Server
- [ ] Installation auf Proxmox Mail Gateway
- [ ] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
`pmgsubscription get` mit den Annahmen im Skript abgleichen
- [ ] Verhalten ohne Subscription und mit abgelaufener Subscription prüfen
- [ ] Timer-Lauf, Spool-Nachsendung und Uninstall auf einem echten Host prüfen
- [ ] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
## Akzeptanzkriterien
- [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch
- [ ] Die gemeldeten Felder stimmen mit der Ausgabe der Proxmox-Befehle überein
## Abhängigkeiten
#005
## Notizen / Entscheidungen
- Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen.
+25
View File
@@ -0,0 +1,25 @@
# #019 CI-Pipeline
**Status:** offen
**Meilenstein:** v0.10.0
**Labels:** infrastruktur
**Geschätzt:** 3 h **Tatsächlich:** –
## Ziel
Automatische Prüfung jedes Commits, damit Fehler nicht erst beim Release auffallen.
## Aufgaben
- [ ] Gitea Actions (oder Woodpecker) einrichten
- [ ] Schritte: `npm ci`, `prisma generate`, Lint, Typecheck, Tests gegen eine PostgreSQL-Dienstinstanz, Build
- [ ] ShellCheck für `agent/*.sh`
- [ ] Prüfung, dass `docs/issues/README.md` und `DEVLOG.md` zur Version in `package.json` passen
- [ ] Ergebnis als Kennzeichen im README
## Akzeptanzkriterien
- [ ] Ein fehlschlagender Test verhindert den Merge nach `main`
- [ ] Der Durchlauf dauert unter fünf Minuten
## Abhängigkeiten
#014
## Notizen / Entscheidungen
- ShellCheck ist auf dem Entwicklungsrechner nicht installiert; bisher wird nur `bash -n` geprüft.
+35
View File
@@ -0,0 +1,35 @@
# #020 Issues nach Gitea übertragen
**Status:** erledigt
**Meilenstein:** v0.4.1
**Labels:** infrastruktur, docs
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Die Issue-Dateien aus `docs/issues/` sollen als echte Issues im Gitea-Tracker erscheinen und dort
aktuell bleiben – ohne dass sie von Hand nachgepflegt werden müssen.
## Aufgaben
- [x] Skript `scripts/gitea-issues.ts`, das die Dateien liest und über die Gitea-API abgleicht
- [x] Meilensteine anlegen (v0.1.0 bis v1.0.0), Labels anlegen (agent, backend, ui, docs …)
- [x] Issues anlegen; vorhandene anhand der Nummer im Titel wiedererkennen und aktualisieren
- [x] Erledigte Issues in Gitea schließen, wieder geöffnete erneut öffnen
- [x] Trockenlauf ohne Token (`--dry-run`) zur Kontrolle vor dem ersten echten Lauf
- [x] npm-Skript `issues:sync` und Beschreibung im README
## Akzeptanzkriterien
- [x] Ein Lauf legt alle 20 Issues mit Titel, Text, Label und Meilenstein in Gitea an
- [x] Ein zweiter Lauf ändert nichts Unnötiges (keine Dubletten)
- [x] Der Token steht nur in der Umgebung, nie im Repository
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- Die Markdown-Dateien bleiben die Quelle der Wahrheit: Sie liegen im Repo, sind ohne Netz lesbar und
wandern mit jedem Klon mit. Gitea bekommt eine Kopie zum Kommentieren und Zuweisen.
- Der Token kommt aus den Git-Zugangsdaten (`git credential fill`), Adresse und Repo aus dem Remote.
Damit muss derselbe Token nicht zweimal gepflegt werden.
- Gefragt werden zuerst die eingerichteten Helfer (Schlüsselbund), danach zusätzlich `~/.git-credentials` –
je nach Rechner ist mal das eine, mal das andere gefüllt.
- Erster echter Lauf am 17.09.2026: 20 Issues, 15 Labels und 13 Meilensteine angelegt, erledigte Issues
direkt geschlossen. Zweiter Lauf: 0 neue, 20 aktualisiert – keine Dubletten.
@@ -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.
+21 -15
View File
@@ -4,19 +4,25 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|---|---|---|---|---|---|
| [#001](001-projekt-setup.md) | Projekt-Setup | in Arbeit | v0.1.0 | 3 h | – |
| [#002](002-prototyp.md) | Klickbarer Prototyp | offen | v0.1.0 | 8 h | – |
| [#003](003-datenmodell.md) | Datenmodell und Migrationen | offen | v0.1.0 | 5 h | – |
| [#004](004-agent-api.md) | Agent-API | offen | v0.1.0 | 6 h | – |
| [#005](005-agent-skript.md) | Agent-Skript | offen | v0.2.0 | 8 h | – |
| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | offen | v0.3.0 | 6 h | – |
| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | offen | v0.4.0 | 8 h | – |
| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | offen | v0.5.0 | 10 h | – |
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | offen | 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 | – |
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 3 h | 0 h 08 min |
| [#002](002-prototyp.md) | Klickbarer Prototyp | erledigt | v0.1.0 | 8 h | 0 h 20 min |
| [#003](003-datenmodell.md) | Datenmodell und Migrationen | erledigt | v0.1.0 | 5 h | 0 h 25 min |
| [#004](004-agent-api.md) | Agent-API | erledigt | v0.1.0 | 6 h | 0 h 20 min |
| [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min |
| [#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 | 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 | in Arbeit | v0.10.0 | 8 h | – |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
| | **Summe** | | | **101 h** | **–** |
| [#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 |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **5 h 23 min** |
+1 -1
View File
@@ -7,7 +7,7 @@ const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
prettier,
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts"]),
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]),
]);
export default eslintConfig;
+17 -1
View File
@@ -1,7 +1,23 @@
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 = {
/* config options here */
// 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;
+3032 -21
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.1.0-dev",
"version": "0.9.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -10,25 +10,47 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write ."
"format": "prettier --write .",
"db:up": "docker compose -f docker-compose.dev.yml up -d",
"db:down": "docker compose -f docker-compose.dev.yml down",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"postinstall": "prisma generate",
"issues:sync": "tsx scripts/gitea-issues.ts",
"worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.ts"
},
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@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",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7",
"prisma": "^7.10.0",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.1"
},
@@ -0,0 +1,580 @@
-- CreateEnum
CREATE TYPE "Produkt" AS ENUM ('PVE', 'PBS', 'PMG');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'NOTFOUND', 'INVALID', 'EXPIRED', 'SUSPENDED', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "LaufzeitStatus" AS ENUM ('VORSCHLAG', 'BESTAETIGT');
-- CreateEnum
CREATE TYPE "AlertSchwere" AS ENUM ('INFO', 'WARNUNG', 'GEFAHR');
-- CreateEnum
CREATE TYPE "NotificationKanal" AS ENUM ('EMAIL', 'WEBHOOK');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('GESENDET', 'FEHLGESCHLAGEN', 'WARTEND');
-- CreateTable
CREATE TABLE "Kunde" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kundennummer" TEXT NOT NULL,
"ansprechpartner" TEXT,
"email" TEXT,
"telefon" TEXT,
"notizen" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Standort" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"strasse" TEXT,
"plz" TEXT,
"ort" TEXT,
"notizen" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Standort_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Host" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"hostname" TEXT NOT NULL,
"fqdn" TEXT,
"kundeId" TEXT,
"standortId" TEXT,
"produkt" "Produkt" NOT NULL,
"produktVersion" TEXT,
"kernel" TEXT,
"serverId" TEXT,
"clusterName" TEXT,
"nodeName" TEXT,
"ips" TEXT[] DEFAULT ARRAY[]::TEXT[],
"sockets" INTEGER,
"cpuModell" TEXT,
"uptimeSekunden" BIGINT,
"paketUpdates" INTEGER,
"agentVersion" TEXT,
"notizen" TEXT,
"subKey" TEXT,
"subStatus" "SubscriptionStatus" NOT NULL DEFAULT 'UNKNOWN',
"subLevel" TEXT,
"subProduktname" TEXT,
"subLizenzierteSockets" INTEGER,
"subRegdate" DATE,
"subNextduedate" DATE,
"subChecktime" TIMESTAMP(3),
"erstmalsGesehenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzterReportAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Host_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"farbe" TEXT,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"erhobenAm" TIMESTAMP(3) NOT NULL,
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subStatus" "SubscriptionStatus" NOT NULL,
"subKey" TEXT,
"subNextduedate" DATE,
"serverId" TEXT,
"hostname" TEXT,
"produktVersion" TEXT,
"kernel" TEXT,
"sockets" INTEGER,
"paketUpdates" INTEGER,
"agentVersion" TEXT,
"ips" TEXT[] DEFAULT ARRAY[]::TEXT[],
"quellIp" TEXT,
"rohdaten" JSONB NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SubscriptionKeyHistorie" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"ersteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SubscriptionKeyHistorie_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerIdHistorie" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"ersteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ServerIdHistorie_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Laufzeit" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"start" DATE NOT NULL,
"ende" DATE NOT NULL,
"status" "LaufzeitStatus" NOT NULL DEFAULT 'VORSCHLAG',
"notizen" TEXT,
"proxmoxPositionId" TEXT,
"kundenPositionId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Laufzeit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProxmoxRechnung" (
"id" TEXT NOT NULL,
"nummer" TEXT NOT NULL,
"datum" DATE NOT NULL,
"faelligAm" DATE,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
"ustSatz" DECIMAL(5,2) NOT NULL DEFAULT 19.00,
"rabatt" DECIMAL(12,2),
"zahlungsart" TEXT,
"bezahlt" BOOLEAN NOT NULL DEFAULT false,
"bezahltAm" DATE,
"notizen" TEXT,
"anhangId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProxmoxRechnung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProxmoxRechnungPosition" (
"id" TEXT NOT NULL,
"rechnungId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
CONSTRAINT "ProxmoxRechnungPosition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Kundenrechnung" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"erstellt" BOOLEAN NOT NULL DEFAULT false,
"nummer" TEXT,
"datum" DATE,
"nettoBetrag" DECIMAL(12,2),
"ustSatz" DECIMAL(5,2) NOT NULL DEFAULT 19.00,
"bezahlt" BOOLEAN NOT NULL DEFAULT false,
"bezahltAm" DATE,
"notizen" TEXT,
"anhangId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kundenrechnung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KundenrechnungPosition" (
"id" TEXT NOT NULL,
"rechnungId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
CONSTRAINT "KundenrechnungPosition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Anhang" (
"id" TEXT NOT NULL,
"dateiname" TEXT NOT NULL,
"pfad" TEXT NOT NULL,
"mimeTyp" TEXT NOT NULL,
"groesse" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"hochgeladenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentToken" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteNutzungAm" TIMESTAMP(3),
"widerrufenAm" TIMESTAMP(3),
CONSTRAINT "AgentToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EnrollmentToken" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"bezeichnung" TEXT,
"gueltigBis" TIMESTAMP(3),
"maxNutzungen" INTEGER,
"nutzungen" INTEGER NOT NULL DEFAULT 0,
"kundeId" TEXT,
"standortId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"widerrufenAm" TIMESTAMP(3),
CONSTRAINT "EnrollmentToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Alert" (
"id" TEXT NOT NULL,
"regel" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"schwere" "AlertSchwere" NOT NULL,
"titel" TEXT NOT NULL,
"beschreibung" TEXT NOT NULL,
"hostId" TEXT,
"kundeId" TEXT,
"laufzeitId" TEXT,
"geoeffnetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"quittiertAm" TIMESTAMP(3),
"erledigtAm" TIMESTAMP(3),
CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benachrichtigung" (
"id" TEXT NOT NULL,
"alertId" TEXT,
"kanal" "NotificationKanal" NOT NULL,
"empfaenger" TEXT NOT NULL,
"betreff" TEXT,
"status" "NotificationStatus" NOT NULL DEFAULT 'WARTEND',
"fehler" TEXT,
"versendetAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Benachrichtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WarnregelEinstellung" (
"id" TEXT NOT NULL,
"regel" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"schwellwerte" JSONB NOT NULL DEFAULT '{}',
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WarnregelEinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benachrichtigungskanal" (
"id" TEXT NOT NULL,
"typ" "NotificationKanal" NOT NULL,
"name" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"konfiguration" JSONB NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benachrichtigungskanal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benutzer" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwortHash" TEXT,
"oidcSubject" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"letzterLoginAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditEintrag" (
"id" TEXT NOT NULL,
"benutzerId" TEXT,
"aktion" TEXT NOT NULL,
"entitaet" TEXT NOT NULL,
"entitaetId" TEXT,
"aenderung" JSONB,
"ipAdresse" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEintrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Einstellung" (
"schluessel" TEXT NOT NULL,
"wert" JSONB NOT NULL,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("schluessel")
);
-- CreateTable
CREATE TABLE "_HostTags" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_HostTags_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Kunde_kundennummer_key" ON "Kunde"("kundennummer");
-- CreateIndex
CREATE INDEX "Kunde_name_idx" ON "Kunde"("name");
-- CreateIndex
CREATE INDEX "Standort_kundeId_idx" ON "Standort"("kundeId");
-- CreateIndex
CREATE UNIQUE INDEX "Host_agentId_key" ON "Host"("agentId");
-- CreateIndex
CREATE INDEX "Host_kundeId_idx" ON "Host"("kundeId");
-- CreateIndex
CREATE INDEX "Host_standortId_idx" ON "Host"("standortId");
-- CreateIndex
CREATE INDEX "Host_hostname_idx" ON "Host"("hostname");
-- CreateIndex
CREATE INDEX "Host_serverId_idx" ON "Host"("serverId");
-- CreateIndex
CREATE INDEX "Host_subKey_idx" ON "Host"("subKey");
-- CreateIndex
CREATE INDEX "Host_subNextduedate_idx" ON "Host"("subNextduedate");
-- CreateIndex
CREATE INDEX "Host_subStatus_idx" ON "Host"("subStatus");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Report_reportId_key" ON "Report"("reportId");
-- CreateIndex
CREATE INDEX "Report_hostId_erhobenAm_idx" ON "Report"("hostId", "erhobenAm");
-- CreateIndex
CREATE INDEX "Report_empfangenAm_idx" ON "Report"("empfangenAm");
-- CreateIndex
CREATE INDEX "SubscriptionKeyHistorie_key_idx" ON "SubscriptionKeyHistorie"("key");
-- CreateIndex
CREATE UNIQUE INDEX "SubscriptionKeyHistorie_hostId_key_key" ON "SubscriptionKeyHistorie"("hostId", "key");
-- CreateIndex
CREATE INDEX "ServerIdHistorie_serverId_idx" ON "ServerIdHistorie"("serverId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerIdHistorie_hostId_serverId_key" ON "ServerIdHistorie"("hostId", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_proxmoxPositionId_key" ON "Laufzeit"("proxmoxPositionId");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_kundenPositionId_key" ON "Laufzeit"("kundenPositionId");
-- CreateIndex
CREATE INDEX "Laufzeit_ende_idx" ON "Laufzeit"("ende");
-- CreateIndex
CREATE INDEX "Laufzeit_key_idx" ON "Laufzeit"("key");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_hostId_start_key" ON "Laufzeit"("hostId", "start");
-- CreateIndex
CREATE UNIQUE INDEX "ProxmoxRechnung_nummer_key" ON "ProxmoxRechnung"("nummer");
-- CreateIndex
CREATE INDEX "ProxmoxRechnung_bezahlt_faelligAm_idx" ON "ProxmoxRechnung"("bezahlt", "faelligAm");
-- CreateIndex
CREATE INDEX "ProxmoxRechnung_datum_idx" ON "ProxmoxRechnung"("datum");
-- CreateIndex
CREATE INDEX "ProxmoxRechnungPosition_rechnungId_idx" ON "ProxmoxRechnungPosition"("rechnungId");
-- CreateIndex
CREATE UNIQUE INDEX "Kundenrechnung_nummer_key" ON "Kundenrechnung"("nummer");
-- CreateIndex
CREATE INDEX "Kundenrechnung_kundeId_idx" ON "Kundenrechnung"("kundeId");
-- CreateIndex
CREATE INDEX "Kundenrechnung_erstellt_bezahlt_idx" ON "Kundenrechnung"("erstellt", "bezahlt");
-- CreateIndex
CREATE INDEX "KundenrechnungPosition_rechnungId_idx" ON "KundenrechnungPosition"("rechnungId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentToken_tokenHash_key" ON "AgentToken"("tokenHash");
-- CreateIndex
CREATE INDEX "AgentToken_hostId_idx" ON "AgentToken"("hostId");
-- CreateIndex
CREATE UNIQUE INDEX "EnrollmentToken_tokenHash_key" ON "EnrollmentToken"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "Alert_fingerprint_key" ON "Alert"("fingerprint");
-- CreateIndex
CREATE INDEX "Alert_erledigtAm_idx" ON "Alert"("erledigtAm");
-- CreateIndex
CREATE INDEX "Alert_hostId_idx" ON "Alert"("hostId");
-- CreateIndex
CREATE INDEX "Alert_kundeId_idx" ON "Alert"("kundeId");
-- CreateIndex
CREATE INDEX "Alert_regel_idx" ON "Alert"("regel");
-- CreateIndex
CREATE INDEX "Benachrichtigung_alertId_idx" ON "Benachrichtigung"("alertId");
-- CreateIndex
CREATE INDEX "Benachrichtigung_erstelltAm_idx" ON "Benachrichtigung"("erstelltAm");
-- CreateIndex
CREATE UNIQUE INDEX "WarnregelEinstellung_regel_key" ON "WarnregelEinstellung"("regel");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_oidcSubject_key" ON "Benutzer"("oidcSubject");
-- CreateIndex
CREATE INDEX "AuditEintrag_entitaet_entitaetId_idx" ON "AuditEintrag"("entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "AuditEintrag_erstelltAm_idx" ON "AuditEintrag"("erstelltAm");
-- CreateIndex
CREATE INDEX "_HostTags_B_index" ON "_HostTags"("B");
-- AddForeignKey
ALTER TABLE "Standort" ADD CONSTRAINT "Standort_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "Standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SubscriptionKeyHistorie" ADD CONSTRAINT "SubscriptionKeyHistorie_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerIdHistorie" ADD CONSTRAINT "ServerIdHistorie_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_proxmoxPositionId_fkey" FOREIGN KEY ("proxmoxPositionId") REFERENCES "ProxmoxRechnungPosition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_kundenPositionId_fkey" FOREIGN KEY ("kundenPositionId") REFERENCES "KundenrechnungPosition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProxmoxRechnung" ADD CONSTRAINT "ProxmoxRechnung_anhangId_fkey" FOREIGN KEY ("anhangId") REFERENCES "Anhang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProxmoxRechnungPosition" ADD CONSTRAINT "ProxmoxRechnungPosition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "ProxmoxRechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kundenrechnung" ADD CONSTRAINT "Kundenrechnung_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kundenrechnung" ADD CONSTRAINT "Kundenrechnung_anhangId_fkey" FOREIGN KEY ("anhangId") REFERENCES "Anhang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KundenrechnungPosition" ADD CONSTRAINT "KundenrechnungPosition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "Kundenrechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentToken" ADD CONSTRAINT "AgentToken_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EnrollmentToken" ADD CONSTRAINT "EnrollmentToken_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EnrollmentToken" ADD CONSTRAINT "EnrollmentToken_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "Standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_laufzeitId_fkey" FOREIGN KEY ("laufzeitId") REFERENCES "Laufzeit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Benachrichtigung" ADD CONSTRAINT "Benachrichtigung_alertId_fkey" FOREIGN KEY ("alertId") REFERENCES "Alert"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditEintrag" ADD CONSTRAINT "AuditEintrag_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HostTags" ADD CONSTRAINT "_HostTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HostTags" ADD CONSTRAINT "_HostTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+484
View File
@@ -0,0 +1,484 @@
// ProxLedger – Datenmodell
// Entwurfsentscheidungen siehe docs/architektur.md, Abschnitt 3.
// Kurz: Ein Host wird über seine agentId identifiziert (nicht über die Server-ID, die sich bei
// Hardwaretausch ändert), Rechnungen sind eigene Entitäten mit Positionen, damit Sammelbestellungen
// bei Proxmox und Sammelrechnungen an Kunden ohne doppelte Rechnungsnummern abbildbar sind.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------- Aufzählungen
enum Produkt {
PVE
PBS
PMG
}
enum SubscriptionStatus {
ACTIVE
NOTFOUND
INVALID
EXPIRED
SUSPENDED
UNKNOWN
}
enum LaufzeitStatus {
VORSCHLAG
BESTAETIGT
}
enum AlertSchwere {
INFO
WARNUNG
GEFAHR
}
enum NotificationKanal {
EMAIL
WEBHOOK
}
enum NotificationStatus {
GESENDET
FEHLGESCHLAGEN
WARTEND
}
// ---------------------------------------------------------------- Kunden und Standorte
model Kunde {
id String @id @default(cuid())
name String
kundennummer String @unique
ansprechpartner String?
email String?
telefon String?
notizen String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
standorte Standort[]
hosts Host[]
kundenrechnungen Kundenrechnung[]
enrollmentTokens EnrollmentToken[]
alerts Alert[]
@@index([name])
}
model Standort {
id String @id @default(cuid())
kundeId String
name String
strasse String?
plz String?
ort String?
notizen String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
hosts Host[]
enrollmentTokens EnrollmentToken[]
@@index([kundeId])
}
// ---------------------------------------------------------------- Hosts
model Host {
id String @id @default(cuid())
/// Stabile Kennung, die der Agent bei der Registrierung erzeugt (aus /etc/machine-id abgeleitet).
/// Sie bleibt auch dann gleich, wenn sich die Proxmox-Server-ID durch Hardwaretausch ändert.
agentId String @unique
hostname String
fqdn String?
kundeId String?
standortId String?
produkt Produkt
produktVersion String?
kernel String?
/// Aktuelle Server-ID laut letztem Report; die Historie steht in ServerIdHistorie.
serverId String?
clusterName String?
nodeName String?
ips String[] @default([])
sockets Int?
cpuModell String?
uptimeSekunden BigInt?
paketUpdates Int?
agentVersion String?
notizen String?
// Momentaufnahme der Subscription aus dem letzten Report (für Listen und Filter).
subKey String?
subStatus SubscriptionStatus @default(UNKNOWN)
subLevel String?
subProduktname String?
subLizenzierteSockets Int?
subRegdate DateTime? @db.Date
subNextduedate DateTime? @db.Date
subChecktime DateTime?
erstmalsGesehenAm DateTime @default(now())
letzterReportAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
reports Report[]
keyHistorie SubscriptionKeyHistorie[]
serverIdHistorie ServerIdHistorie[]
laufzeiten Laufzeit[]
tokens AgentToken[]
alerts Alert[]
tags Tag[] @relation("HostTags")
@@index([kundeId])
@@index([standortId])
@@index([hostname])
@@index([serverId])
@@index([subKey])
@@index([subNextduedate])
@@index([subStatus])
}
model Tag {
id String @id @default(cuid())
name String @unique
farbe String?
hosts Host[] @relation("HostTags")
}
model Report {
id String @id @default(cuid())
hostId String
/// Vom Agent erzeugte UUID; verhindert doppelte Verarbeitung nachgesendeter Reports.
reportId String @unique
/// Zeitpunkt der Datenerhebung auf dem Host (kann bei nachgesendeten Reports älter sein).
erhobenAm DateTime
empfangenAm DateTime @default(now())
subStatus SubscriptionStatus
subKey String?
subNextduedate DateTime? @db.Date
serverId String?
hostname String?
produktVersion String?
kernel String?
sockets Int?
paketUpdates Int?
agentVersion String?
ips String[] @default([])
quellIp String?
/// Vollständiger Report, damit auch künftige Felder nachträglich auswertbar sind.
rohdaten Json
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@index([hostId, erhobenAm])
@@index([empfangenAm])
}
model SubscriptionKeyHistorie {
id String @id @default(cuid())
hostId String
key String
ersteMeldung DateTime @default(now())
letzteMeldung DateTime @default(now())
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@unique([hostId, key])
@@index([key])
}
model ServerIdHistorie {
id String @id @default(cuid())
hostId String
serverId String
ersteMeldung DateTime @default(now())
letzteMeldung DateTime @default(now())
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@unique([hostId, serverId])
@@index([serverId])
}
// ---------------------------------------------------------------- Laufzeiten und Rechnungen
model Laufzeit {
id String @id @default(cuid())
hostId String
key String
start DateTime @db.Date
ende DateTime @db.Date
status LaufzeitStatus @default(VORSCHLAG)
notizen String?
/// Position der Proxmox-Rechnung (Einkauf), die diese Laufzeit bezahlt.
proxmoxPositionId String? @unique
/// Position der Kundenrechnung (Verkauf), mit der die Laufzeit weiterberechnet wird.
kundenPositionId String? @unique
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
proxmoxPosition ProxmoxRechnungPosition? @relation(fields: [proxmoxPositionId], references: [id], onDelete: SetNull)
kundenPosition KundenrechnungPosition? @relation(fields: [kundenPositionId], references: [id], onDelete: SetNull)
alerts Alert[]
@@unique([hostId, start])
@@index([ende])
@@index([key])
}
/// Rechnung von Proxmox (Einkauf). Eine Bestellung kann mehrere Keys umfassen.
model ProxmoxRechnung {
id String @id @default(cuid())
nummer String @unique
datum DateTime @db.Date
faelligAm DateTime? @db.Date
nettoBetrag Decimal @db.Decimal(12, 2)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
rabatt Decimal? @db.Decimal(12, 2)
zahlungsart String?
bezahlt Boolean @default(false)
bezahltAm DateTime? @db.Date
notizen String?
anhangId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull)
positionen ProxmoxRechnungPosition[]
@@index([bezahlt, faelligAm])
@@index([datum])
}
model ProxmoxRechnungPosition {
id String @id @default(cuid())
rechnungId String
bezeichnung String
menge Int @default(1)
nettoBetrag Decimal @db.Decimal(12, 2)
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
laufzeit Laufzeit?
@@index([rechnungId])
}
/// Rechnung an den Kunden (Verkauf). `erstellt = false` bedeutet: steht noch aus.
model Kundenrechnung {
id String @id @default(cuid())
kundeId String
erstellt Boolean @default(false)
nummer String? @unique
datum DateTime? @db.Date
nettoBetrag Decimal? @db.Decimal(12, 2)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
bezahlt Boolean @default(false)
bezahltAm DateTime? @db.Date
notizen String?
anhangId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull)
positionen KundenrechnungPosition[]
@@index([kundeId])
@@index([erstellt, bezahlt])
}
model KundenrechnungPosition {
id String @id @default(cuid())
rechnungId String
bezeichnung String
menge Int @default(1)
nettoBetrag Decimal @db.Decimal(12, 2)
rechnung Kundenrechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
laufzeit Laufzeit?
@@index([rechnungId])
}
model Anhang {
id String @id @default(cuid())
dateiname String
pfad String
mimeTyp String
groesse Int
sha256 String
hochgeladenAm DateTime @default(now())
proxmoxRechnungen ProxmoxRechnung[]
kundenrechnungen Kundenrechnung[]
}
// ---------------------------------------------------------------- Tokens
/// Host-spezifischer API-Token. In der Datenbank liegt nur der SHA-256-Hash.
model AgentToken {
id String @id @default(cuid())
hostId String
tokenHash String @unique
praefix String
bezeichnung String?
erstelltAm DateTime @default(now())
letzteNutzungAm DateTime?
widerrufenAm DateTime?
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@index([hostId])
}
/// Token für die Erstregistrierung, optional mit Kunden- und Standortzuordnung.
model EnrollmentToken {
id String @id @default(cuid())
tokenHash String @unique
praefix String
bezeichnung String?
gueltigBis DateTime?
maxNutzungen Int?
nutzungen Int @default(0)
kundeId String?
standortId String?
erstelltAm DateTime @default(now())
widerrufenAm DateTime?
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
}
// ---------------------------------------------------------------- Warnungen und Benachrichtigungen
model Alert {
id String @id @default(cuid())
regel String
/// Regel + betroffenes Objekt (+ Schwelle). Verhindert doppelte offene Warnungen.
fingerprint String @unique
schwere AlertSchwere
titel String
beschreibung String
hostId String?
kundeId String?
laufzeitId String?
geoeffnetAm DateTime @default(now())
quittiertAm DateTime?
erledigtAm DateTime?
host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade)
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Cascade)
laufzeit Laufzeit? @relation(fields: [laufzeitId], references: [id], onDelete: Cascade)
benachrichtigungen Benachrichtigung[]
@@index([erledigtAm])
@@index([hostId])
@@index([kundeId])
@@index([regel])
}
model Benachrichtigung {
id String @id @default(cuid())
alertId String?
kanal NotificationKanal
empfaenger String
betreff String?
status NotificationStatus @default(WARTEND)
fehler String?
versendetAm DateTime?
erstelltAm DateTime @default(now())
alert Alert? @relation(fields: [alertId], references: [id], onDelete: SetNull)
@@index([alertId])
@@index([erstelltAm])
}
/// Schwellwerte je Warnregel, z. B. { "tage": [30, 14, 7] }.
model WarnregelEinstellung {
id String @id @default(cuid())
regel String @unique
aktiv Boolean @default(true)
schwellwerte Json @default("{}")
geaendertAm DateTime @updatedAt
}
model Benachrichtigungskanal {
id String @id @default(cuid())
typ NotificationKanal
name String
aktiv Boolean @default(true)
konfiguration Json
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
}
// ---------------------------------------------------------------- Benutzer und Protokoll
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
model Benutzer {
id String @id @default(cuid())
email String @unique
name String
passwortHash String?
oidcSubject String? @unique
aktiv Boolean @default(true)
letzterLoginAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
auditEintraege AuditEintrag[]
}
model AuditEintrag {
id String @id @default(cuid())
benutzerId String?
aktion String
entitaet String
entitaetId String?
aenderung Json?
ipAdresse String?
erstelltAm DateTime @default(now())
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
@@index([entitaet, entitaetId])
@@index([erstelltAm])
}
/// Freie Systemeinstellungen (z. B. SMTP-Konfiguration, Anzeigeoptionen).
model Einstellung {
schluessel String @id
wert Json
geaendertAm DateTime @updatedAt
}
+296
View File
@@ -0,0 +1,296 @@
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import type { Prisma } from "../src/generated/prisma/client";
import type { Produkt, SubscriptionStatus } from "../src/generated/prisma/enums";
import { alerts, hosts, kunden, laufzeiten, reports } from "../src/mock/data";
import type { Laufzeit as MockLaufzeit } from "../src/mock/types";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
/** Standardwerte der Warnregeln; Schwellwerte sind später in der Oberfläche änderbar (#011). */
const WARNREGELN: { regel: string; schwellwerte: Prisma.InputJsonValue }[] = [
{ regel: "subscription-laeuft-ab", schwellwerte: { tage: [30, 14, 7] } },
{ regel: "status-nicht-aktiv", schwellwerte: {} },
{ regel: "sockets-abweichung", schwellwerte: {} },
{ regel: "keine-meldung", schwellwerte: { stunden: 48 } },
{ regel: "serverid-geaendert", schwellwerte: {} },
{ regel: "key-mehrfach-gemeldet", schwellwerte: {} },
{ regel: "host-ohne-kunde", schwellwerte: {} },
{ regel: "proxmox-rechnung-faellig", schwellwerte: { tage: 7 } },
{ regel: "kundenrechnung-fehlt", schwellwerte: {} },
{ regel: "kundenrechnung-unbezahlt", schwellwerte: { tage: 30 } },
];
async function seedAdmin() {
const email = process.env.ADMIN_EMAIL ?? "admin@example.de";
const name = process.env.ADMIN_NAME ?? "Administrator";
const passwort = process.env.ADMIN_PASSWORD;
if (!passwort) {
console.warn("ADMIN_PASSWORD ist nicht gesetzt – der Administrator wird ohne Passwort angelegt.");
}
const passwortHash = passwort ? await hash(passwort) : null;
await prisma.benutzer.upsert({
where: { email },
// Ein vorhandenes Passwort wird nicht überschrieben, damit der Seed gefahrlos wiederholbar ist.
update: { name },
create: { email, name, passwortHash },
});
console.log(`Administrator: ${email}`);
}
async function seedWarnregeln() {
for (const regel of WARNREGELN) {
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.regel },
update: {},
create: { regel: regel.regel, aktiv: true, schwellwerte: regel.schwellwerte },
});
}
console.log(`${WARNREGELN.length} Warnregeln vorbereitet`);
}
const PRODUKT: Record<string, Produkt> = { pve: "PVE", pbs: "PBS", pmg: "PMG" };
const STATUS: Record<string, SubscriptionStatus> = {
active: "ACTIVE",
notfound: "NOTFOUND",
invalid: "INVALID",
expired: "EXPIRED",
suspended: "SUSPENDED",
};
function datum(wert: string | null | undefined): Date | null {
return wert ? new Date(wert) : null;
}
async function seedDemo() {
for (const kunde of kunden) {
await prisma.kunde.upsert({
where: { id: kunde.id },
update: {},
create: {
id: kunde.id,
name: kunde.name,
kundennummer: kunde.kundennummer,
ansprechpartner: kunde.ansprechpartner,
email: kunde.email,
telefon: kunde.telefon,
notizen: kunde.notizen,
standorte: {
create: kunde.standorte.map((standort) => ({
id: standort.id,
name: standort.name,
ort: standort.ort,
})),
},
},
});
}
for (const host of hosts) {
await prisma.host.upsert({
where: { id: host.id },
update: {},
create: {
id: host.id,
agentId: `demo-${host.id}`,
hostname: host.hostname,
fqdn: host.fqdn,
kundeId: host.kundeId,
standortId: host.standortId,
produkt: PRODUKT[host.produkt],
produktVersion: host.produktVersion,
kernel: host.kernel,
serverId: host.serverId,
clusterName: host.clusterName,
nodeName: host.nodeName,
ips: host.ips,
sockets: host.sockets,
cpuModell: host.cpuModell,
uptimeSekunden: host.uptimeSekunden === null ? null : BigInt(host.uptimeSekunden),
paketUpdates: host.paketUpdates,
agentVersion: host.agentVersion,
notizen: host.notizen,
subKey: host.subscription.key,
subStatus: STATUS[host.subscription.status],
subLevel: host.subscription.level,
subProduktname: host.subscription.produktname,
subLizenzierteSockets: host.subscription.lizenzierteSockets,
subRegdate: datum(host.subscription.regdate),
subNextduedate: datum(host.subscription.nextduedate),
subChecktime: datum(host.subscription.checktime),
letzterReportAm: datum(host.letzterReport),
tags: {
connectOrCreate: host.tags.map((tag) => ({ where: { name: tag }, create: { name: tag } })),
},
keyHistorie: host.subscription.key
? { create: [{ key: host.subscription.key }] }
: undefined,
serverIdHistorie: { create: [{ serverId: host.serverId }] },
},
});
}
// Rechnungen entstehen aus den Laufzeiten: gleiche Rechnungsnummer = eine Rechnung mit mehreren Positionen.
const proxmoxRechnungen = new Map<string, MockLaufzeit[]>();
const kundenrechnungen = new Map<string, MockLaufzeit[]>();
for (const laufzeit of laufzeiten) {
if (laufzeit.einkauf?.nummer) {
const bisher = proxmoxRechnungen.get(laufzeit.einkauf.nummer) ?? [];
proxmoxRechnungen.set(laufzeit.einkauf.nummer, [...bisher, laufzeit]);
}
if (laufzeit.verkauf) {
const schluessel = laufzeit.verkauf.nummer ?? `ohne-nummer-${laufzeit.id}`;
const bisher = kundenrechnungen.get(schluessel) ?? [];
kundenrechnungen.set(schluessel, [...bisher, laufzeit]);
}
}
const proxmoxPositionen = new Map<string, string>();
for (const [nummer, zugehoerig] of proxmoxRechnungen) {
const vorlage = zugehoerig[0].einkauf!;
const rechnung = await prisma.proxmoxRechnung.upsert({
where: { nummer },
update: {},
create: {
nummer,
datum: new Date(vorlage.datum!),
faelligAm: datum(vorlage.faelligAm),
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
ustSatz: vorlage.ustSatz,
rabatt: vorlage.rabatt ?? null,
zahlungsart: vorlage.zahlungsart ?? null,
bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm),
positionen: {
create: zugehoerig.map((l) => ({
id: `prp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
})),
},
},
});
for (const l of zugehoerig) proxmoxPositionen.set(l.id, `prp-${l.id}`);
console.log(`Proxmox-Rechnung ${rechnung.nummer} mit ${zugehoerig.length} Position(en)`);
}
const kundenPositionen = new Map<string, string>();
for (const [schluessel, zugehoerig] of kundenrechnungen) {
const vorlage = zugehoerig[0].verkauf!;
const host = hosts.find((h) => h.id === zugehoerig[0].hostId);
if (!host?.kundeId) continue;
const id = `kr-${schluessel}`;
await prisma.kundenrechnung.upsert({
where: { id },
update: {},
create: {
id,
kundeId: host.kundeId,
erstellt: vorlage.erstellt,
nummer: vorlage.nummer,
datum: datum(vorlage.datum),
nettoBetrag: vorlage.erstellt
? zugehoerig.reduce((summe, l) => summe + (l.verkauf?.nettoBetrag ?? 0), 0)
: null,
ustSatz: vorlage.ustSatz,
bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm),
positionen: {
create: zugehoerig.map((l) => ({
id: `krp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.verkauf?.nettoBetrag ?? 0,
})),
},
},
});
for (const l of zugehoerig) kundenPositionen.set(l.id, `krp-${l.id}`);
}
for (const laufzeit of laufzeiten) {
await prisma.laufzeit.upsert({
where: { id: laufzeit.id },
update: {},
create: {
id: laufzeit.id,
hostId: laufzeit.hostId,
key: laufzeit.key,
start: new Date(laufzeit.start),
ende: new Date(laufzeit.ende),
status: laufzeit.vorschlag ? "VORSCHLAG" : "BESTAETIGT",
proxmoxPositionId: proxmoxPositionen.get(laufzeit.id) ?? null,
kundenPositionId: kundenPositionen.get(laufzeit.id) ?? null,
},
});
}
for (const report of reports) {
await prisma.report.upsert({
where: { reportId: report.id },
update: {},
create: {
reportId: report.id,
hostId: report.hostId,
erhobenAm: new Date(report.zeitpunkt),
subStatus: STATUS[report.status],
subKey: report.key,
subNextduedate: datum(report.nextduedate),
agentVersion: report.agentVersion,
paketUpdates: report.paketUpdates,
rohdaten: { hinweis: "Demo-Report ohne Rohdaten" },
},
});
}
for (const alert of alerts) {
await prisma.alert.upsert({
where: { fingerprint: `${alert.typ}:${alert.hostId ?? alert.kundeId ?? alert.id}` },
update: {},
create: {
id: alert.id,
fingerprint: `${alert.typ}:${alert.hostId ?? alert.kundeId ?? alert.id}`,
regel: alert.typ,
schwere: alert.schwere === "gefahr" ? "GEFAHR" : alert.schwere === "warnung" ? "WARNUNG" : "INFO",
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
kundeId: alert.kundeId,
laufzeitId: alert.laufzeitId,
geoeffnetAm: new Date(alert.seit),
},
});
}
console.log(
`Demo-Daten: ${kunden.length} Kunden, ${hosts.length} Hosts, ${laufzeiten.length} Laufzeiten, ${alerts.length} Warnungen`,
);
}
async function main() {
await seedAdmin();
await seedWarnregeln();
if (process.env.SEED_DEMO === "1") {
await seedDemo();
} else {
console.log("Demo-Daten übersprungen (SEED_DEMO=1 setzen, um sie anzulegen).");
}
}
main()
.then(() => prisma.$disconnect())
.catch(async (fehler) => {
console.error(fehler);
await prisma.$disconnect();
process.exit(1);
});
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+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);
});
+280
View File
@@ -0,0 +1,280 @@
/**
* Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker.
*
* GITEA_URL=https://git.wilde-server.de \
* GITEA_REPO=florian.wilde/ProxLedger \
* GITEA_TOKEN=... \
* npm run issues:sync
*
* Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde.
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
*/
import { execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import "dotenv/config";
import path from "node:path";
interface IssueDatei {
nummer: number;
titel: string;
status: string;
meilenstein: string | null;
labels: string[];
koerper: string;
dateiname: string;
}
const LABEL_FARBEN: Record<string, string> = {
agent: "#1e7e34",
backend: "#007bff",
api: "#6f42c1",
ui: "#17a2b8",
datenbank: "#20c997",
sicherheit: "#c82333",
docs: "#6c757d",
deployment: "#fd7e14",
infrastruktur: "#495057",
alerts: "#ffc107",
buchhaltung: "#e83e8c",
export: "#0dcaf0",
prototyp: "#adb5bd",
test: "#198754",
chore: "#868e96",
};
function feld(inhalt: string, name: string): string | null {
const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt);
return treffer ? treffer[1].trim() : null;
}
async function dateienLesen(verzeichnis: string): Promise<IssueDatei[]> {
const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
const issues: IssueDatei[] = [];
for (const dateiname of namen) {
const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8");
const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt);
if (!kopf) {
console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`);
continue;
}
// Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten).
const körperStart = inhalt.indexOf("## Ziel");
issues.push({
nummer: Number(kopf[1]),
titel: kopf[2].trim(),
status: feld(inhalt, "Status") ?? "offen",
meilenstein: feld(inhalt, "Meilenstein"),
labels: (feld(inhalt, "Labels") ?? "")
.split(",")
.map((label) => label.trim().toLowerCase())
.filter(Boolean),
koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt,
dateiname,
});
}
return issues;
}
class Gitea {
constructor(
private readonly basis: string,
private readonly repo: string,
private readonly token: string,
) {}
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
...optionen,
headers: {
Authorization: `token ${this.token}`,
"Content-Type": "application/json",
...optionen.headers,
},
});
if (!antwort.ok) {
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`);
}
return (await antwort.json()) as T;
}
labels() {
return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100");
}
labelAnlegen(name: string) {
return this.anfrage<{ id: number; name: string }>("/labels", {
method: "POST",
body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }),
});
}
meilensteine() {
return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100");
}
meilensteinAnlegen(title: string) {
return this.anfrage<{ id: number; title: string }>("/milestones", {
method: "POST",
body: JSON.stringify({ title }),
});
}
issues() {
return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200");
}
issueAnlegen(daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
}
issueAendern(nummer: number, daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>(`/issues/${nummer}`, {
method: "PATCH",
body: JSON.stringify(daten),
});
}
}
/**
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
* Beispiel: https://git.example.de/benutzer/ProxLedger.git → { basis, repo }
*/
function ausRemote(): { basis: string; repo: string } | null {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
if (!treffer) return null;
return { basis: `https://${treffer[1]}`, repo: treffer[2] };
} catch {
return null;
}
}
/**
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
*/
function tokenAusGitCredentials(basis: string): string | null {
const host = new URL(basis).host;
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
try {
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
input: `protocol=https\nhost=${host}\n\n`,
encoding: "utf8",
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
stdio: ["pipe", "pipe", "ignore"],
});
const passwort = /^password=(.*)$/m.exec(antwort);
if (passwort?.[1]) return passwort[1];
} catch {
// Nächste Möglichkeit versuchen.
}
}
return null;
}
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
function giteaTitel(issue: IssueDatei): string {
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
}
async function main() {
const trockenlauf = process.argv.includes("--dry-run");
const remote = ausRemote();
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
const verzeichnis = path.join(process.cwd(), "docs", "issues");
const issues = await dateienLesen(verzeichnis);
console.log(`${issues.length} Issue-Dateien gelesen.`);
if (trockenlauf || !basis || !repo || !token) {
if (!trockenlauf) {
console.log(
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
);
}
for (const issue of issues) {
console.log(
` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${
issue.labels.join(", ") || "–"
}`,
);
}
console.log(
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
);
return;
}
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
const gitea = new Gitea(basis, repo, token);
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
for (const name of benoetigteLabels) {
if (!vorhandeneLabels.has(name)) {
const neu = await gitea.labelAnlegen(name);
vorhandeneLabels.set(name, neu.id);
console.log(`Label angelegt: ${name}`);
}
}
const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id]));
const benoetigteMeilensteine = [
...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))),
].sort();
for (const titel of benoetigteMeilensteine) {
if (!vorhandeneMeilensteine.has(titel)) {
const neu = await gitea.meilensteinAnlegen(titel);
vorhandeneMeilensteine.set(titel, neu.id);
console.log(`Meilenstein angelegt: ${titel}`);
}
}
const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i]));
for (const issue of issues) {
const titel = giteaTitel(issue);
const daten = {
title: titel,
body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`,
labels: issue.labels
.map((name) => vorhandeneLabels.get(name))
.filter((id): id is number => Boolean(id)),
milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined,
};
const vorhanden = vorhandeneIssues.get(titel);
const zustand = issue.status.startsWith("erledigt") ? "closed" : "open";
if (vorhanden) {
await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand });
console.log(`Aktualisiert: ${titel} (${zustand})`);
} else {
const neu = await gitea.issueAnlegen(daten);
if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" });
console.log(`Angelegt: ${titel} (${zustand})`);
}
}
console.log("\nAbgleich abgeschlossen.");
}
main().catch((fehler) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
+62
View File
@@ -0,0 +1,62 @@
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."
: "Anmeldung nicht möglich."}
</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>
</>
);
}
@@ -0,0 +1,202 @@
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 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 (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,27 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version";
export const metadata = { title: "System" };
export default function SystemSeite() {
return (
<>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary">
<dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
<dd className="col-8">Konfigurierbar ab #011</dd>
</dl>
</Card>
</div>
</div>
</>
);
}
@@ -0,0 +1,178 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { TokenFormular } from "@/components/tokens/TokenFormular";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { formatDatum, formatDatumZeit } from "@/lib/format";
import { prisma } from "@/lib/db";
import { enrollmentTokenWiderrufen, hostTokenWiderrufen } from "@/server/actions/tokens";
export const metadata = { title: "Agent-Tokens" };
export default async function TokensSeite() {
const dashboardUrl = (process.env.APP_URL ?? "https://dashboard.example.de").replace(/\/$/, "");
const [kunden, enrollmentTokens, hostTokens] = await Promise.all([
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
prisma.enrollmentToken.findMany({
orderBy: { erstelltAm: "desc" },
include: { kunde: { select: { name: true } } },
}),
prisma.agentToken.findMany({
orderBy: { erstelltAm: "desc" },
include: { host: { select: { id: true, hostname: true } } },
}),
]);
const jetzt = new Date();
function zustand(token: {
widerrufenAm: Date | null;
gueltigBis: Date | null;
maxNutzungen: number | null;
nutzungen: number;
}) {
if (token.widerrufenAm) return { text: "Widerrufen", farbe: "neutral" as const };
if (token.gueltigBis && token.gueltigBis < jetzt) return { text: "Abgelaufen", farbe: "danger" as const };
if (token.maxNutzungen !== null && token.nutzungen >= token.maxNutzungen)
return { text: "Aufgebraucht", farbe: "warning" as const };
return { text: "Gültig", farbe: "success" as const };
}
return (
<>
<ContentHeader
titel="Agent-Tokens"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Agent-Tokens" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Card titel="Neuen Enrollment-Token erzeugen" rahmen="primary">
<p className="text-body-secondary">
Mit einem Enrollment-Token registriert sich ein Agent einmalig und erhält dabei seinen eigenen
Host-Token. Ist ein Kunde hinterlegt, sind neue Hosts sofort zugeordnet und tauchen nicht unter{" "}
<Link href="/hosts/nicht-zugeordnet">Nicht zugeordnet</Link> auf.
</p>
<TokenFormular kunden={kunden} dashboardUrl={dashboardUrl} />
</Card>
<Card titel={`Enrollment-Tokens (${enrollmentTokens.length})`} rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Bezeichnung</th>
<th scope="col">Anfang</th>
<th scope="col">Kunde</th>
<th scope="col">Gültig bis</th>
<th scope="col">Nutzungen</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{enrollmentTokens.map((token) => {
const status = zustand(token);
return (
<tr key={token.id}>
<td>{token.bezeichnung ?? "–"}</td>
<td className="pl-mono">{token.praefix}…</td>
<td>{token.kunde?.name ?? "–"}</td>
<td>{formatDatum(token.gueltigBis)}</td>
<td>
{token.nutzungen}
{token.maxNutzungen !== null ? ` / ${token.maxNutzungen}` : ""}
</td>
<td>
<Badge farbe={status.farbe}>{status.text}</Badge>
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={enrollmentTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
);
})}
{enrollmentTokens.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-body-secondary py-4">
Noch kein Enrollment-Token erzeugt.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<Card titel={`Host-Tokens (${hostTokens.length})`} rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Host</th>
<th scope="col">Anfang</th>
<th scope="col">Erstellt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{hostTokens.map((token) => (
<tr key={token.id}>
<td>
<Link href={`/hosts/${token.host.id}`} className="text-decoration-none">
{token.host.hostname}
</Link>
</td>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={token.host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{hostTokens.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Noch kein Host registriert.
</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>
</>
);
}
+468
View File
@@ -0,0 +1,468 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { HostNotizen } from "@/components/hosts/HostNotizen";
import { ContentHeader } from "@/components/layout/ContentHeader";
import {
AblaufBadge,
Badge,
EinkaufBadge,
KeineMeldungBadge,
StatusBadge,
VerkaufBadge,
} from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { hostKundeSetzen } from "@/server/actions/host-detail";
import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const host = await prisma.host.findUnique({ where: { id }, select: { hostname: true } });
return { title: host?.hostname ?? "Host" };
}
function Zeile({ bezeichnung, children }: { bezeichnung: string; children: React.ReactNode }) {
return (
<div className="d-flex justify-content-between gap-3 py-2 border-bottom">
<span className="text-body-secondary">{bezeichnung}</span>
<span className="text-end">{children}</span>
</div>
);
}
export default async function HostDetailSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const host = await hostMitDetails(id);
if (!host) notFound();
const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
const jetzt = new Date();
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
const socketAbweichung =
host.subLizenzierteSockets !== null &&
host.sockets !== null &&
host.subLizenzierteSockets !== host.sockets;
return (
<>
<ContentHeader
titel={host.hostname}
brotkrumen={[{ titel: "Hosts", href: "/hosts" }, { titel: host.hostname }]}
aktion={<StatusBadge status={host.subStatus} />}
/>
<div className="app-content">
<div className="container-fluid">
{host.alerts.length > 0 ? (
<Card titel={`Offene Warnungen (${host.alerts.length})`} rahmen="danger">
<ul className="list-unstyled mb-0">
{host.alerts.map((alert) => (
<li key={alert.id} className="mb-2">
<Badge
farbe={
alert.schwere === "GEFAHR"
? "danger"
: alert.schwere === "WARNUNG"
? "warning"
: "info"
}
>
{alert.titel}
</Badge>{" "}
<span className="small text-body-secondary">{alert.beschreibung}</span>
</li>
))}
</ul>
</Card>
) : null}
<div className="row">
<div className="col-lg-4">
<Card titel="Host" rahmen="primary">
<div className="text-center mb-3">
<i className="bi bi-hdd-rack fs-1 text-body-secondary" aria-hidden="true" />
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null}
</div>
<Zeile bezeichnung="Kunde">
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`}>{host.kunde.name}</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</Zeile>
<Zeile bezeichnung="Standort">{host.standort?.name ?? "–"}</Zeile>
<Zeile bezeichnung="Produkt">{PRODUKT_TEXT[host.produkt]}</Zeile>
<Zeile bezeichnung="Version">{host.produktVersion ?? "–"}</Zeile>
<Zeile bezeichnung="Kernel">{host.kernel ?? "–"}</Zeile>
<Zeile bezeichnung="Server-ID">
<CopyableCode wert={host.serverId} />
</Zeile>
<Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</Zeile>
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</Zeile>
<Zeile bezeichnung="Uptime">
{formatUptime(host.uptimeSekunden === null ? null : Number(host.uptimeSekunden))}
</Zeile>
<Zeile bezeichnung="Paket-Updates">{formatZahl(host.paketUpdates)}</Zeile>
<Zeile bezeichnung="Agent-Version">{host.agentVersion ?? "–"}</Zeile>
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
<form action={hostKundeSetzen} className="mt-3">
<input type="hidden" name="hostId" value={host.id} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</label>
<div className="input-group input-group-sm">
<select
id="kundeId"
name="kundeId"
className="form-select"
defaultValue={host.kundeId ?? ""}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
</form>
</Card>
</div>
<div className="col-lg-8">
<Tabs
tabs={[
{
id: "uebersicht",
titel: "Übersicht",
icon: "bi-info-circle",
inhalt: (
<>
<Zeile bezeichnung="Subscription-Key">
<CopyableCode wert={host.subKey} />
</Zeile>
<Zeile bezeichnung="Status">
<StatusBadge status={host.subStatus} />
</Zeile>
<Zeile bezeichnung="Produktname">{host.subProduktname ?? "–"}</Zeile>
<Zeile bezeichnung="Level">{host.subLevel ?? "–"}</Zeile>
<Zeile bezeichnung="Registriert am">{formatDatum(host.subRegdate)}</Zeile>
<Zeile bezeichnung="Läuft ab am">
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</Zeile>
<Zeile bezeichnung="Letzte Prüfung durch Proxmox">
{formatDatumZeit(host.subChecktime)}
</Zeile>
<div className="mt-3">
{host.tags.length === 0 ? (
<span className="text-body-secondary small">Keine Tags</span>
) : (
host.tags.map((tag) => (
<span key={tag.id} className="badge pl-bg-neutral me-1">
{tag.name}
</span>
))
)}
</div>
</>
),
},
{
id: "historie",
titel: "Subscription-Historie",
icon: "bi-key",
inhalt: (
<>
<h3 className="h6">Keys</h3>
<table className="table table-striped align-middle mb-4">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.keyHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.keyHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch kein Key gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
<h3 className="h6">Server-IDs</h3>
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Server-ID</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.serverIdHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.serverId} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.serverIdHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch keine Server-ID gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
{host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
dann bei Proxmox neu zugewiesen werden.
</p>
) : null}
</>
),
},
{
id: "reports",
titel: `Reports (${host.reports.length})`,
icon: "bi-clock-history",
inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Erhoben</th>
<th scope="col">Empfangen</th>
<th scope="col">Status</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col">Updates</th>
<th scope="col">Agent</th>
</tr>
</thead>
<tbody>
{host.reports.map((report) => (
<tr key={report.id}>
<td>{formatDatumZeit(report.erhobenAm)}</td>
<td className="small text-body-secondary">
{formatDatumZeit(report.empfangenAm)}
</td>
<td>
<StatusBadge status={report.subStatus} />
</td>
<td className="pl-mono">{report.subKey ?? "–"}</td>
<td>{formatDatum(report.subNextduedate)}</td>
<td>{formatZahl(report.paketUpdates)}</td>
<td>{report.agentVersion ?? "–"}</td>
</tr>
))}
{host.reports.length === 0 ? (
<tr>
<td colSpan={7} className="text-body-secondary">
Noch kein Report eingegangen.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
),
},
{
id: "rechnungen",
titel: "Rechnungen",
icon: "bi-receipt",
inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Laufzeit</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{host.laufzeiten.map((laufzeit) => {
const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag)
: null;
const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag)
: null;
return (
<tr key={laufzeit.id}>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
{laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div>
<EinkaufBadge
bezahlt={
laufzeit.proxmoxPosition
? laufzeit.proxmoxPosition.rechnung.bezahlt
: null
}
/>
</div>
</td>
<td>{formatEuro(einkauf)}</td>
<td>
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div>
<VerkaufBadge
erstellt={laufzeit.kundenPosition?.rechnung.erstellt ?? false}
bezahlt={laufzeit.kundenPosition?.rechnung.bezahlt ?? false}
/>
</div>
</td>
<td>{formatEuro(verkauf)}</td>
<td>
{einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf)
: "–"}
</td>
</tr>
);
})}
{host.laufzeiten.length === 0 ? (
<tr>
<td colSpan={6} className="text-body-secondary">
Noch keine Laufzeit erfasst.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
),
},
{
id: "notizen",
titel: "Notizen und Tags",
icon: "bi-journal-text",
inhalt: (
<HostNotizen
hostId={host.id}
notizen={host.notizen}
tags={host.tags.map((tag) => tag.name)}
/>
),
},
{
id: "tokens",
titel: "Tokens",
icon: "bi-key-fill",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Anfang</th>
<th scope="col">Erstellt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{host.tokens.map((token) => (
<tr key={token.id}>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{host.tokens.length === 0 ? (
<tr>
<td colSpan={5} className="text-body-secondary">
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
</td>
</tr>
) : null}
</tbody>
</table>
),
},
]}
/>
</div>
</div>
</div>
</div>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
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. */
export async function GET(request: Request) {
const parameter = new URL(request.url).searchParams;
const filter: HostFilter = {
q: parameter.get("q") ?? undefined,
kunde: parameter.get("kunde") ?? undefined,
status: parameter.get("status") ?? undefined,
produkt: parameter.get("produkt") ?? undefined,
ablauf: (parameter.get("ablauf") as HostFilter["ablauf"]) ?? undefined,
rechnung: (parameter.get("rechnung") as HostFilter["rechnung"]) ?? undefined,
sortierung: parameter.get("sortierung") ?? undefined,
// Der Export enthält alle Treffer, nicht nur die aktuelle Seite.
seite: 1,
};
const { gesamt } = await hostListe({ ...filter }, new Date());
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",
"FQDN",
"Kunde",
"Standort",
"Produkt",
"Version",
"Status",
"Subscription-Key",
"Server-ID",
"Sockets lizenziert",
"Sockets physisch",
"Ablauf",
"Letzte Meldung",
"IP-Adressen",
"Agent-Version",
],
alle.map((host) => [
host.hostname,
host.fqdn,
host.kunde?.name ?? "Nicht zugeordnet",
host.standort?.name ?? "",
PRODUKT_TEXT[host.produkt],
host.produktVersion,
STATUS_TEXT[statusAusDatenbank(host.subStatus)],
host.subKey,
host.serverId,
host.subLizenzierteSockets,
host.sockets,
host.subNextduedate,
host.letzterReportAm,
host.ips.join(", "),
host.agentVersion,
]),
);
return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`);
}
@@ -0,0 +1,71 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { ZuweisungsFormular } from "@/components/hosts/ZuweisungsFormular";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { hostsOhneKunde } from "@/server/queries/hosts";
export const metadata = { title: "Nicht zugeordnete Hosts" };
export default async function NichtZugeordnetSeite() {
const [hosts, kunden] = await Promise.all([
hostsOhneKunde(),
prisma.kunde.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
standorte: { select: { id: true, name: true }, orderBy: { name: "asc" } },
},
}),
]);
return (
<>
<ContentHeader
titel="Nicht zugeordnete Hosts"
brotkrumen={[{ titel: "Hosts", href: "/hosts" }, { titel: "Nicht zugeordnet" }]}
/>
<div className="app-content">
<div className="container-fluid">
{hosts.length === 0 ? (
<Callout titel="Alles zugeordnet" farbe="info">
Jeder gemeldete Host gehört zu einem Kunden. Neu registrierte Hosts erscheinen automatisch hier.
</Callout>
) : (
<Callout titel="Schnellzuweisung" farbe="warning">
Hosts auswählen, Kunde wählen und in einem Schritt zuweisen. Wer Hosts direkt vorzuordnen
möchte, erzeugt unter <Link href="/einstellungen/tokens">Agent-Tokens</Link> einen
Enrollment-Token mit Kundenbindung – damit landen neue Hosts sofort beim richtigen Kunden.
</Callout>
)}
{kunden.length === 0 ? (
<Callout titel="Noch kein Kunde vorhanden" farbe="danger">
Zum Zuweisen wird mindestens ein Kunde benötigt.{" "}
<Link href="/kunden/neu">Jetzt einen Kunden anlegen.</Link>
</Callout>
) : null}
{hosts.length > 0 ? (
<Card titel={`${hosts.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
<ZuweisungsFormular
hosts={hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
produkt: host.produkt,
serverId: host.serverId,
subKey: host.subKey,
erstmalsGesehenAm: host.erstmalsGesehenAm.toISOString(),
}))}
kunden={kunden}
/>
</Card>
) : null}
</div>
</div>
</>
);
}
+229
View File
@@ -0,0 +1,229 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { HostFilterLeiste } from "@/components/hosts/HostFilterLeiste";
import { AblaufBadge, KeineMeldungBadge, StatusBadge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatumZeit } from "@/lib/format";
import { filterOptionen, hostListe, type HostFilter } from "@/server/queries/hostliste";
export const metadata = { title: "Hosts" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
return text && text.length > 0 ? text : undefined;
}
/** Baut eine Adresse mit geänderten Parametern – für Sortierung, Blättern und Export. */
function mitParametern(
basis: Record<string, string | undefined>,
aenderung: Record<string, string | undefined> = {},
pfad = "/hosts",
) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...basis, ...aenderung })) {
if (wert) params.set(schluessel, wert);
}
const text = params.toString();
return text ? `${pfad}?${text}` : pfad;
}
/** Spaltenkopf, der beim Klick die Sortierung umschaltet. */
function SortierKopf({
titel,
feld,
basis,
sortierung,
}: {
titel: string;
feld: string;
basis: Record<string, string | undefined>;
sortierung: string | undefined;
}) {
const aktiv = (sortierung ?? "hostname").startsWith(feld);
const absteigend = sortierung === `${feld}-ab`;
return (
<th scope="col">
<Link
href={mitParametern(basis, { sortierung: absteigend ? feld : `${feld}-ab`, seite: undefined })}
className="text-decoration-none text-body d-inline-flex align-items-center gap-1"
>
{titel}
{aktiv ? (
<i
className={`bi ${absteigend ? "bi-caret-down-fill" : "bi-caret-up-fill"} small`}
aria-hidden="true"
/>
) : null}
</Link>
</th>
);
}
export default async function HostsSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const filter: HostFilter = {
q: einzeln(roh.q),
kunde: einzeln(roh.kunde),
status: einzeln(roh.status),
produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
rechnung: einzeln(roh.rechnung) as HostFilter["rechnung"],
sortierung: einzeln(roh.sortierung),
seite: Number(einzeln(roh.seite) ?? 1),
};
const jetzt = new Date();
const [{ hosts, gesamt, seite, seiten }, optionen] = await Promise.all([
hostListe(filter, jetzt),
filterOptionen(),
]);
const basis: Record<string, string | undefined> = {
q: filter.q,
kunde: filter.kunde,
status: filter.status,
produkt: filter.produkt,
ablauf: filter.ablauf,
rechnung: filter.rechnung,
sortierung: filter.sortierung,
};
return (
<>
<ContentHeader titel="Hosts" brotkrumen={[{ titel: "Hosts" }]} />
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${gesamt} Host${gesamt === 1 ? "" : "s"}${gesamt !== hosts.length ? ` · Seite ${seite} von ${seiten}` : ""}`}
rahmen="primary"
tabelle
werkzeuge={
<>
<Link href="/hosts/nicht-zugeordnet" className="btn btn-sm btn-outline-primary">
Nicht zugeordnete
</Link>
<a
href={mitParametern(basis, {}, "/hosts/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
</>
}
>
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<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>
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Key</th>
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Sockets</th>
<SortierKopf
titel="Letzte Meldung"
feld="meldung"
basis={basis}
sortierung={filter.sortierung}
/>
</tr>
</thead>
<tbody>
{hosts.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
</td>
<td>
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
</Link>
) : (
<Link
href="/hosts/nicht-zugeordnet"
className="badge pl-bg-neutral text-decoration-none"
>
Nicht zugeordnet
</Link>
)}
{host.standort ? (
<div className="small text-body-secondary">{host.standort.name}</div>
) : null}
</td>
<td>{PRODUKT_TEXT[host.produkt]}</td>
<td>
<StatusBadge status={host.subStatus} />
</td>
<td>
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td>
<td>
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td>
</tr>
))}
{hosts.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-body-secondary py-4">
Keine Hosts gefunden. <Link href="/hosts">Filter zurücksetzen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{seiten > 1 ? (
<nav className="p-3 border-top" aria-label="Seiten">
<ul className="pagination pagination-sm mb-0">
<li className={`page-item ${seite <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(seite - 1) })}>
Zurück
</Link>
</li>
{Array.from({ length: seiten }, (_, i) => i + 1).map((nummer) => (
<li key={nummer} className={`page-item ${nummer === seite ? "active" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(nummer) })}>
{nummer}
</Link>
</li>
))}
<li className={`page-item ${seite >= seiten ? "disabled" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(seite + 1) })}>
Weiter
</Link>
</li>
</ul>
</nav>
) : null}
</Card>
</div>
</div>
</>
);
}
+212
View File
@@ -0,0 +1,212 @@
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 { 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 async function JahresbahnSeite() {
const jetzt = new Date();
const { gruppen, naechste90Tage, abgelaufen } = await ablaufDaten(jetzt);
// 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(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)}`;
});
return (
<>
<ContentHeader titel="Jahresbahn" brotkrumen={[{ titel: "Jahresbahn" }]} />
<div className="app-content">
<div className="container-fluid">
<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: "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>
{gruppen.map((gruppe) => (
<div key={gruppe.kundeId ?? "ohne"} className="mb-3">
<div className="fw-semibold small mb-1">
{gruppe.kundeId ? (
<Link href={`/kunden/${gruppe.kundeId}`} className="text-decoration-none">
{gruppe.name}
</Link>
) : (
<span className="badge pl-bg-neutral">{gruppe.name}</span>
)}
</div>
{gruppe.eintraege.map((eintrag) => {
const links = position(eintrag.ablauf);
const farbe =
eintrag.tage < 14
? "var(--pl-danger)"
: eintrag.tage < 30
? "var(--pl-warning)"
: "var(--pl-info)";
return (
<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(eintrag.ablauf)})
</span>
) : (
<Link
href={
eintrag.laufzeitId
? `/rechnungen/${eintrag.laufzeitId}`
: `/hosts/${eintrag.hostId}`
}
className="pl-timeline-bar text-decoration-none"
style={{
left: `${Math.min(92, Math.max(0, links - 5))}%`,
backgroundColor: farbe,
color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff",
}}
title={`${eintrag.hostname} läuft am ${formatDatum(eintrag.ablauf)} ab`}
>
{formatDatum(eintrag.ablauf)}
</Link>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
)}
<p className="small text-body-secondary mb-0">
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,65 @@
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { KundeFormular } from "@/components/kunden/KundeFormular";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { kundeLoeschen } from "@/server/actions/kunden";
export const metadata = { title: "Kunde bearbeiten" };
export default async function KundeBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = await prisma.kunde.findUnique({
where: { id },
include: { _count: { select: { hosts: true } } },
});
if (!kunde) notFound();
return (
<>
<ContentHeader
titel={`${kunde.name} bearbeiten`}
brotkrumen={[
{ titel: "Kunden", href: "/kunden" },
{ titel: kunde.name, href: `/kunden/${kunde.id}` },
{ titel: "Bearbeiten" },
]}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-lg-8">
<Card titel="Stammdaten" rahmen="primary">
<KundeFormular
werte={{
id: kunde.id,
name: kunde.name,
kundennummer: kunde.kundennummer,
ansprechpartner: kunde.ansprechpartner,
email: kunde.email,
telefon: kunde.telefon,
notizen: kunde.notizen,
}}
/>
</Card>
<Card titel="Kunde löschen" rahmen="danger">
<p>
Der Kunde wird mit allen Standorten und Kundenrechnungen entfernt. Seine{" "}
{kunde._count.hosts} Host(s) bleiben erhalten und erscheinen danach unter „Nicht
zugeordnet“.
</p>
<form action={kundeLoeschen}>
<input type="hidden" name="id" value={kunde.id} />
<button type="submit" className="btn btn-danger pl-touch-target">
Endgültig löschen
</button>
</form>
</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`,
);
}
+291
View File
@@ -0,0 +1,291 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { StandortVerwaltung } from "@/components/kunden/StandortVerwaltung";
import {
AblaufBadge,
EinkaufBadge,
KeineMeldungBadge,
StatusBadge,
VerkaufBadge,
} from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox";
import { formatDatum, formatEuro } from "@/lib/format";
import { hostZuordnungAufheben } from "@/server/actions/hosts";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { meldungsGrenze } from "@/server/queries/hosts";
import { PRODUKT_TEXT, statusAusDatenbank } from "@/lib/anzeige";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = await kundeMitDetails(id);
return { title: kunde?.name ?? "Kunde" };
}
export default async function KundenDetailSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = await kundeMitDetails(id);
if (!kunde) notFound();
const laufzeiten = await laufzeitenVonKunde(kunde.id);
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const heute = new Date();
const grenze = meldungsGrenze(heute);
return (
<>
<ContentHeader
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>
}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox text="Hosts" wert={String(kunde.hosts.length)} icon="bi-hdd-stack" farbe="info" />
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Einkauf netto"
wert={formatEuro(gesamt.einkauf)}
icon="bi-cart"
farbe="neutral"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Verkauf netto"
wert={formatEuro(gesamt.verkauf)}
icon="bi-receipt"
farbe="primary"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Marge"
wert={formatEuro(gesamt.marge)}
icon="bi-graph-up-arrow"
farbe="success"
/>
</div>
</div>
<div className="row">
<div className="col-lg-4">
<Card titel="Stammdaten" rahmen="primary">
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Kundennummer</dt>
<dd className="col-7 pl-mono">{kunde.kundennummer}</dd>
<dt className="col-5 fw-normal text-body-secondary">Ansprechpartner</dt>
<dd className="col-7">{kunde.ansprechpartner ?? "–"}</dd>
<dt className="col-5 fw-normal text-body-secondary">E-Mail</dt>
<dd className="col-7">
{kunde.email ? <a href={`mailto:${kunde.email}`}>{kunde.email}</a> : "–"}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Telefon</dt>
<dd className="col-7">{kunde.telefon ?? "–"}</dd>
</dl>
<hr />
<h4 className="h6">Notizen</h4>
<p className="mb-0 small">
{kunde.notizen ?? <span className="text-body-secondary">Keine Notizen hinterlegt.</span>}
</p>
</Card>
<Card titel="Standorte" rahmen="info">
<StandortVerwaltung
kundeId={kunde.id}
standorte={kunde.standorte.map((standort) => ({
id: standort.id,
name: standort.name,
strasse: standort.strasse,
plz: standort.plz,
ort: standort.ort,
hosts: kunde.hosts.filter((host) => host.standortId === standort.id).length,
}))}
/>
</Card>
<Card titel="Offene Warnungen" rahmen={kunde.alerts.length > 0 ? "danger" : "info"}>
{kunde.alerts.length === 0 ? (
<p className="mb-0 text-body-secondary">Keine offenen Warnungen.</p>
) : (
<ul className="list-unstyled mb-0 small">
{kunde.alerts.map((alert) => (
<li key={alert.id} className="mb-2">
<strong>{alert.titel}</strong>
<div className="text-body-secondary">{alert.beschreibung}</div>
</li>
))}
</ul>
)}
</Card>
</div>
<div className="col-lg-8">
<Card titel={`Hosts (${kunde.hosts.length})`} rahmen="primary" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Host</th>
<th scope="col">Produkt</th>
<th scope="col">Status</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col" className="text-end">
Zuordnung
</th>
</tr>
</thead>
<tbody>
{kunde.hosts.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{!host.letzterReportAm || host.letzterReportAm < grenze ? (
<KeineMeldungBadge />
) : null}
</td>
<td>{PRODUKT_TEXT[host.produkt]}</td>
<td>
<StatusBadge status={statusAusDatenbank(host.subStatus)} />
</td>
<td>
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={heute} />
</td>
<td className="text-end">
<form action={hostZuordnungAufheben}>
<input type="hidden" name="hostId" value={host.id} />
<button
type="submit"
className="btn btn-sm btn-outline-secondary"
title="Zuordnung zu diesem Kunden aufheben"
>
Lösen
</button>
</form>
</td>
</tr>
))}
{kunde.hosts.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Diesem Kunden ist noch kein Host zugewiesen.{" "}
<Link href="/hosts/nicht-zugeordnet">Jetzt zuweisen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Laufzeit</th>
<th scope="col">Host</th>
<th scope="col">Proxmox</th>
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
</tr>
</thead>
<tbody>
{laufzeiten.map((laufzeit) => (
<tr key={laufzeit.id}>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
{laufzeit.vorschlag ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>{laufzeit.hostname}</td>
<td>
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<div>
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
</div>
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td>
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<div>
<VerkaufBadge
erstellt={laufzeit.verkauf?.erstellt ?? false}
bezahlt={laufzeit.verkauf?.bezahlt ?? false}
/>
</div>
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
</tr>
))}
{laufzeiten.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Noch keine Laufzeiten erfasst.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
{proJahr.length > 0 ? (
<Card titel="Summen pro Jahr" rahmen="info" tabelle>
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Jahr</th>
<th scope="col">Einkauf netto</th>
<th scope="col">Verkauf netto</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{proJahr.map((jahr) => (
<tr key={jahr.jahr}>
<td>{jahr.jahr}</td>
<td>{formatEuro(jahr.einkauf)}</td>
<td>{formatEuro(jahr.verkauf)}</td>
<td className={jahr.marge >= 0 ? "pl-text-success" : "pl-text-danger"}>
{formatEuro(jahr.marge)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
) : null}
</div>
</div>
</div>
</div>
</>
);
}
+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`);
}
+27
View File
@@ -0,0 +1,27 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import { KundeFormular } from "@/components/kunden/KundeFormular";
import { Card } from "@/components/ui/Card";
export const metadata = { title: "Kunde anlegen" };
export default function KundeNeuSeite() {
return (
<>
<ContentHeader
titel="Kunde anlegen"
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: "Neu" }]}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-lg-8">
<Card titel="Stammdaten" rahmen="primary">
<KundeFormular />
</Card>
</div>
</div>
</div>
</div>
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { AblaufBadge, Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
export const metadata = { title: "Kunden" };
export default async function KundenSeite() {
const [kunden, rechnungspunkte] = await Promise.all([kundenListe(), offeneRechnungspunkteProKunde()]);
const heute = new Date();
return (
<>
<ContentHeader titel="Kunden" brotkrumen={[{ titel: "Kunden" }]} />
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${kunden.length} Kunden`}
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">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col">Kundennummer</th>
<th scope="col">Hosts</th>
<th scope="col">Aktiv</th>
<th scope="col">Nächster Ablauf</th>
<th scope="col">Warnungen</th>
<th scope="col">Rechnungen</th>
</tr>
</thead>
<tbody>
{kunden.map((kunde) => {
const punkte = rechnungspunkte.get(kunde.id);
return (
<tr key={kunde.id}>
<td>
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name}
</Link>
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td>
<td className="pl-mono">{kunde.kundennummer}</td>
<td>{kunde.hosts}</td>
<td>{kunde.aktiv}</td>
<td>
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
</td>
<td>
{kunde.offeneAlerts > 0 ? (
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
) : (
<Badge farbe="success">keine</Badge>
)}
</td>
<td>
{punkte && punkte.offen > 0 ? (
<Badge farbe="danger">{punkte.offen} offen</Badge>
) : (
<Badge farbe="success">erledigt</Badge>
)}
</td>
</tr>
);
})}
{kunden.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-body-secondary py-4">
Noch keine Kunden angelegt.{" "}
<Link href="/kunden/neu">Jetzt den ersten Kunden anlegen.</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
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";
/**
* Das Dashboard zeigt immer den aktuellen Datenbankstand; beim Bauen darf nichts
* vorgerendert werden (sonst öffnet jeder Build-Worker eigene Datenbankverbindungen).
*/
export const dynamic = "force-dynamic";
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 (
<div className="app-wrapper">
<Header
alerts={alerts.map((alert) => ({
id: alert.id,
titel: alert.titel,
beschreibung: alert.beschreibung,
schwere: alert.schwere,
hostId: alert.hostId,
}))}
suchEintraege={suche}
benutzer={sitzung.name}
/>
<Sidebar
hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts}
benutzer={sitzung.name}
/>
<main className="app-main">{children}</main>
<Footer />
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { AblaufBadge, EinkaufBadge, KeineMeldungBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Callout, ProgressBar } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { SmallBox } from "@/components/ui/SmallBox";
import { TodoList, type Aufgabe } from "@/components/ui/TodoList";
import { formatDatum, formatEuro } from "@/lib/format";
import { hostsNachAblauf, offeneAufgaben } from "@/server/queries/dashboard";
import { hostsOhneKunde, kennzahlen } from "@/server/queries/hosts";
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
export const metadata = { title: "Dashboard" };
export default async function DashboardSeite() {
const heute = new Date();
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([
kennzahlen(heute),
hostsOhneKunde(),
hostsNachAblauf(heute),
offeneAufgaben(),
kundenListe(),
offeneRechnungspunkteProKunde(),
]);
const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({
id: aufgabe.id,
titel: aufgabe.titel,
detail: [
aufgabe.detail,
aufgabe.betrag !== null ? `${formatEuro(aufgabe.betrag)} netto` : null,
aufgabe.faelligAm ? `fällig am ${formatDatum(aufgabe.faelligAm)}` : null,
`Laufzeit ${formatDatum(aufgabe.start)} – ${formatDatum(aufgabe.ende)}`,
]
.filter(Boolean)
.join(" · "),
dringlichkeit: aufgabe.dringlichkeit,
badgeText: aufgabe.badgeText,
href: aufgabe.href,
aktion: aufgabe.aktion,
})),
...ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
dringlichkeit: "info" as const,
badgeText: "Zuweisen",
href: "/hosts/nicht-zugeordnet",
})),
];
return (
<>
<ContentHeader titel="Dashboard" brotkrumen={[{ titel: "Dashboard" }]} />
<div className="app-content">
<div className="container-fluid">
{ohneKunde.length > 0 ? (
<Callout
titel={`${ohneKunde.length} Host${ohneKunde.length === 1 ? "" : "s"} ohne Kunde`}
aktion={
<Link href="/hosts/nicht-zugeordnet" className="btn btn-primary pl-touch-target">
Jetzt zuweisen
</Link>
}
>
Neu registrierte Hosts erscheinen hier, bis sie einem Kunden zugewiesen sind:{" "}
{ohneKunde.map((host) => host.hostname).join(", ")}
</Callout>
) : null}
<div className="row">
<div className="col-6 col-lg-3">
<SmallBox
wert={zahlen.hostsGesamt}
text="Hosts gemeldet"
zusatz={`${zahlen.ohneMeldung} ohne Meldung`}
icon="bi-hdd-stack"
farbe="info"
href="/hosts"
/>
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={zahlen.aktivLizenziert}
text="Aktiv lizenziert"
zusatz={`${zahlen.ohneSubscription} ohne gültige Subscription`}
icon="bi-patch-check"
farbe="success"
href="/hosts?status=ACTIVE"
/>
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={zahlen.baldAblaufend}
text="Läuft in 30 Tagen ab"
zusatz="Verlängerung vorbereiten"
icon="bi-hourglass-split"
farbe="warning"
href="/jahresbahn"
/>
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={zahlen.offeneRechnungspunkte}
text="Offene Rechnungspunkte"
zusatz={`${zahlen.offeneEinkaufsrechnungen} Einkauf · ${zahlen.fehlendeKundenrechnungen} Verkauf`}
icon="bi-receipt"
farbe="danger"
href="/rechnungen"
/>
</div>
</div>
<div className="row">
<div className="col-lg-8">
<Card titel="Subscriptions nach Ablaufdatum" rahmen="primary" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
</tr>
</thead>
<tbody>
{ablaufListe.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
</td>
<td>
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
</Link>
) : (
<Link
href="/hosts/nicht-zugeordnet"
className="badge pl-bg-neutral text-decoration-none"
>
Nicht zugeordnet
</Link>
)}
</td>
<td>
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td>
<td>
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td>
<td>
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td>
</tr>
))}
{ablaufListe.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Noch kein Host mit Subscription gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
<div className="col-lg-4">
<Card
titel="Aufgaben"
rahmen="warning"
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>}
>
<TodoList aufgaben={aufgaben} />
</Card>
</div>
</div>
<div className="row">
<div className="col-lg-8">
<Card titel="Rechnungsstatus je Kunde" rahmen="info">
{kunden.length === 0 ? (
<p className="mb-0 text-body-secondary">
Noch keine Kunden angelegt. <Link href="/kunden/neu">Ersten Kunden anlegen</Link>
</p>
) : (
kunden.map((kunde) => {
const punkte = rechnungspunkte.get(kunde.id) ?? { offen: 0, gesamt: 0 };
const erledigt = punkte.gesamt - punkte.offen;
const prozent = punkte.gesamt === 0 ? 100 : Math.round((erledigt / punkte.gesamt) * 100);
return (
<div className="mb-3" key={kunde.id}>
<div className="d-flex justify-content-between small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
<span className="text-body-secondary">
{erledigt} von {punkte.gesamt} Punkten erledigt
</span>
</div>
<ProgressBar prozent={prozent} beschriftung={`Rechnungsstatus ${kunde.name}`} />
</div>
);
})
)}
</Card>
</div>
<div className="col-lg-4">
<Card titel="Nächste Schritte" rahmen="info">
<ul className="list-unstyled mb-0 small">
<li className="mb-2">
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/einstellungen/tokens">Agent auf einem Host ausrollen</Link>
</li>
<li className="mb-2">
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/kunden">Kundenübersicht öffnen</Link>
</li>
<li>
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/rechnungen">Rechnungen pflegen</Link>
</li>
</ul>
</Card>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,197 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { KundenrechnungFormular, ProxmoxRechnungFormular } from "@/components/rechnungen/RechnungsFormulare";
import { Badge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
return { title: laufzeit ? `Laufzeit ${laufzeit.host.hostname}` : "Laufzeit" };
}
export default async function LaufzeitSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
if (!laufzeit) notFound();
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
<>
<ContentHeader
titel={`Laufzeit ${zeitraum}`}
brotkrumen={[
{ titel: "Rechnungen", href: "/rechnungen" },
{ titel: laufzeit.host.hostname, href: `/hosts/${laufzeit.host.id}` },
{ titel: zeitraum },
]}
aktion={laufzeit.status === "VORSCHLAG" ? <Badge farbe="info">Vorschlag</Badge> : null}
/>
<div className="app-content">
<div className="container-fluid">
{laufzeit.status === "VORSCHLAG" ? (
<Callout
titel="Vom Agent vorgeschlagen"
farbe="info"
aktion={
<div className="d-flex gap-2">
<form action={laufzeitBestaetigenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-primary pl-touch-target">
Bestätigen
</button>
</form>
<form action={laufzeitVerwerfenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-outline-danger pl-touch-target">
Verwerfen
</button>
</form>
</div>
}
>
Der Host hat ein neues Ablaufdatum gemeldet. Mit dem Erfassen einer Proxmox-Rechnung gilt die
Laufzeit automatisch als bestätigt.
</Callout>
) : null}
<div className="row">
<div className="col-lg-4">
<Card titel="Laufzeit" rahmen="primary">
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Host</dt>
<dd className="col-7">
<Link href={`/hosts/${laufzeit.host.id}`}>{laufzeit.host.hostname}</Link>
</dd>
<dt className="col-5 fw-normal text-body-secondary">Kunde</dt>
<dd className="col-7">
{laufzeit.host.kunde ? (
<Link href={`/kunden/${laufzeit.host.kunde.id}`}>{laufzeit.host.kunde.name}</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Produkt</dt>
<dd className="col-7">{PRODUKT_TEXT[laufzeit.host.produkt]}</dd>
<dt className="col-5 fw-normal text-body-secondary">Key</dt>
<dd className="col-7">
<CopyableCode wert={laufzeit.key} />
</dd>
<dt className="col-5 fw-normal text-body-secondary">Beginn</dt>
<dd className="col-7">{formatDatum(laufzeit.start)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Ende</dt>
<dd className="col-7">{formatDatum(laufzeit.ende)}</dd>
</dl>
<hr />
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
<dd className={`col-7 ${(marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}>
{marge === null ? "–" : formatEuro(marge)}
</dd>
</dl>
</Card>
{einkauf && einkauf.positionen.length > 1 ? (
<Card titel="Sammelrechnung" rahmen="info">
<p className="small">
Die Proxmox-Rechnung <span className="pl-mono">{einkauf.nummer}</span> deckt mehrere
Laufzeiten ab:
</p>
<ul className="list-unstyled mb-0 small">
{einkauf.positionen.map((position) => (
<li key={position.id} className="mb-1">
{position.laufzeit ? (
<Link href={`/rechnungen/${position.laufzeit.id}`}>
{position.laufzeit.host.hostname}
</Link>
) : (
position.bezeichnung
)}{" "}
· {formatEuro(Number(position.nettoBetrag))}
</li>
))}
</ul>
</Card>
) : null}
</div>
<div className="col-lg-8">
<Card
titel="Proxmox-Rechnung (Einkauf)"
rahmen={einkauf?.bezahlt ? "info" : "danger"}
werkzeuge={<EinkaufBadge bezahlt={einkauf ? einkauf.bezahlt : null} />}
>
<ProxmoxRechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
: Number(einkauf.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
anhangId: einkauf?.anhangId ?? null,
anhangName: einkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
<Card
titel="Kundenrechnung (Verkauf)"
rahmen={verkauf?.erstellt ? "info" : "warning"}
werkzeuge={
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
}
>
<KundenrechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
kundeVorhanden={Boolean(laufzeit.host.kunde)}
werte={{
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
anhangName: verkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
</div>
</div>
</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`);
}
+314
View File
@@ -0,0 +1,314 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { StatusHaken } from "@/components/rechnungen/StatusHaken";
import { Badge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import {
anzahlVorschlaege,
laufzeitJahre,
rechnungsListe,
type RechnungsFilter,
} from "@/server/queries/rechnungen";
export const metadata = { title: "Rechnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
return text && text.length > 0 ? text : undefined;
}
export default async function RechnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const filter: RechnungsFilter = {
status: (einzeln(roh.status) as RechnungsFilter["status"]) ?? "offen",
kunde: einzeln(roh.kunde),
jahr: einzeln(roh.jahr),
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
]);
const heute = new Date();
function adresse(aenderung: Record<string, string | undefined>) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...filter, ...aenderung })) {
if (wert) params.set(schluessel, String(wert));
}
const text = params.toString();
return text ? `/rechnungen?${text}` : "/rechnungen";
}
return (
<>
<ContentHeader titel="Rechnungen" brotkrumen={[{ titel: "Rechnungen" }]} />
<div className="app-content">
<div className="container-fluid">
{vorschlaege > 0 && filter.status !== "vorschlaege" ? (
<Callout
titel={`${vorschlaege} vorgeschlagene Laufzeit${vorschlaege === 1 ? "" : "en"}`}
farbe="info"
aktion={
<Link href={adresse({ status: "vorschlaege" })} className="btn btn-primary pl-touch-target">
Ansehen
</Link>
}
>
Die Agents haben neue Ablaufdaten gemeldet. Bestätige die Laufzeit und trage die Rechnungsdaten
ein.
</Callout>
) : null}
<p className="small text-body-secondary mb-2">
Summen beziehen sich auf die aktuelle Auswahl ({zeilen.length} Laufzeiten).
</p>
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Einkauf netto"
wert={formatEuro(summen.einkauf)}
icon="bi-cart"
farbe="neutral"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Verkauf netto"
wert={formatEuro(summen.verkauf)}
icon="bi-receipt"
farbe="primary"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Marge"
wert={formatEuro(summen.marge)}
icon="bi-graph-up-arrow"
farbe="success"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Laufzeiten" wert={String(zeilen.length)} icon="bi-calendar-range" farbe="info" />
</div>
</div>
<Card
titel="Laufzeiten und Rechnungen"
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">
{(
[
["offen", "Offen"],
["erledigt", "Erledigt"],
["alle", "Alle"],
["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`],
] as const
).map(([wert, text]) => (
<Link
key={wert}
href={adresse({ status: wert })}
className={`btn ${filter.status === wert ? "btn-primary" : "btn-outline-primary"}`}
>
{text}
</Link>
))}
</div>
</>
}
>
<form className="row g-2 p-3 border-bottom" role="search">
<input type="hidden" name="status" value={filter.status} />
<div className="col-md-4">
<input
name="q"
type="search"
defaultValue={filter.q ?? ""}
className="form-control"
placeholder="Rechnungsnummer, Key oder Host …"
aria-label="Suche"
/>
</div>
<div className="col-6 col-md-3">
<select
name="kunde"
defaultValue={filter.kunde ?? ""}
className="form-select"
aria-label="Kunde"
>
<option value="">Alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<select
name="jahr"
defaultValue={filter.jahr ?? ""}
className="form-select"
aria-label="Jahr"
>
<option value="">Alle Jahre</option>
{jahre.map((jahr) => (
<option key={jahr} value={jahr}>
{jahr}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col">Host</th>
<th scope="col">Laufzeit</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="text-center">
Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{zeilen.map((zeile) => {
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return (
<tr key={zeile.id}>
<td>
{zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td>
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td>
<td>
<CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
fällig {formatDatum(zeile.einkaufFaelligAm)}
</div>
) : null}
</td>
<td>{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="einkaufBezahlt"
wert={zeile.einkaufBezahlt === true}
deaktiviert={zeile.einkaufBezahlt === null}
titel="Proxmox-Rechnung bezahlt"
/>
</td>
<td>
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td>{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufErstellt"
wert={zeile.verkaufErstellt}
titel="Kundenrechnung erstellt"
/>
</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufBezahlt"
wert={zeile.verkaufBezahlt}
deaktiviert={!zeile.verkaufErstellt}
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td>
</tr>
);
})}
{zeilen.length === 0 ? (
<tr>
<td colSpan={11} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Häkchen wirken sofort. Für Rechnungsnummer, Beträge und PDF die Laufzeit öffnen.
</p>
</div>
</div>
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { PruefenKnopf } from "@/components/alerts/PruefenKnopf";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import { alertQuittieren, alertSchliessen } from "@/server/actions/alerts";
export const metadata = { title: "Warnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
const SCHWERE_FARBE = { GEFAHR: "danger", WARNUNG: "warning", INFO: "info" } as const;
export default async function WarnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const status = (Array.isArray(roh.status) ? roh.status[0] : roh.status) ?? "offen";
const [alerts, zahlen] = await Promise.all([
prisma.alert.findMany({
where: status === "erledigt" ? { erledigtAm: { not: null } } : { erledigtAm: null },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: 200,
include: {
host: { select: { id: true, hostname: true } },
kunde: { select: { id: true, name: true } },
laufzeit: { select: { id: true } },
},
}),
prisma.alert.groupBy({
by: ["schwere"],
where: { erledigtAm: null },
_count: { _all: true },
}),
]);
const anzahl = (schwere: string) => zahlen.find((z) => z.schwere === schwere)?._count._all ?? 0;
return (
<>
<ContentHeader titel="Warnungen" brotkrumen={[{ titel: "Warnungen" }]} aktion={<PruefenKnopf />} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Gefahr"
wert={String(anzahl("GEFAHR"))}
icon="bi-exclamation-octagon"
farbe="danger"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Warnung"
wert={String(anzahl("WARNUNG"))}
icon="bi-exclamation-triangle"
farbe="warning"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Hinweis" wert={String(anzahl("INFO"))} icon="bi-info-circle" farbe="info" />
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Offen gesamt"
wert={String(anzahl("GEFAHR") + anzahl("WARNUNG") + anzahl("INFO"))}
icon="bi-bell"
farbe="neutral"
/>
</div>
</div>
<Card
titel={status === "erledigt" ? "Erledigte Warnungen" : "Offene Warnungen"}
rahmen={status === "erledigt" ? "info" : "danger"}
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
<Link
href="/warnungen"
className={`btn ${status !== "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Offen
</Link>
<Link
href="/warnungen?status=erledigt"
className={`btn ${status === "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Erledigt
</Link>
</div>
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Schwere</th>
<th scope="col">Warnung</th>
<th scope="col">Betrifft</th>
<th scope="col">Seit</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{alerts.map((alert) => (
<tr key={alert.id}>
<td>
<Badge farbe={SCHWERE_FARBE[alert.schwere]}>
{alert.schwere === "GEFAHR"
? "Gefahr"
: alert.schwere === "WARNUNG"
? "Warnung"
: "Hinweis"}
</Badge>
</td>
<td>
<span className="fw-semibold">{alert.titel}</span>
<div className="small text-body-secondary">{alert.beschreibung}</div>
<div className="small text-body-secondary pl-mono">{alert.regel}</div>
</td>
<td className="small">
{alert.host ? (
<div>
<Link href={`/hosts/${alert.host.id}`}>{alert.host.hostname}</Link>
</div>
) : null}
{alert.kunde ? (
<div>
<Link href={`/kunden/${alert.kunde.id}`}>{alert.kunde.name}</Link>
</div>
) : null}
{alert.laufzeit ? (
<div>
<Link href={`/rechnungen/${alert.laufzeit.id}`}>Laufzeit öffnen</Link>
</div>
) : null}
</td>
<td className="small">
{formatDatumZeit(alert.geoeffnetAm)}
{alert.quittiertAm ? <div className="text-body-secondary">quittiert</div> : null}
{alert.erledigtAm ? (
<div className="text-body-secondary">
erledigt {formatDatumZeit(alert.erledigtAm)}
</div>
) : null}
</td>
<td className="text-end">
{alert.erledigtAm ? null : (
<div className="d-flex gap-2 justify-content-end">
{alert.quittiertAm ? null : (
<form action={alertQuittieren}>
<input type="hidden" name="id" value={alert.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
Quittieren
</button>
</form>
)}
<form action={alertSchliessen}>
<input type="hidden" name="id" value={alert.id} />
<button
type="submit"
className="btn btn-sm btn-outline-danger"
title="Schließt die Warnung; besteht die Ursache weiter, kommt sie beim nächsten Durchlauf zurück"
>
Schließen
</button>
</form>
</div>
)}
</td>
</tr>
))}
{alerts.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
{status === "erledigt" ? "Noch nichts erledigt." : "Keine offenen Warnungen."}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Regeln laufen stündlich im Worker und nach jedem eingehenden Report.{" "}
<Link href="/einstellungen/warnregeln">Schwellwerte ändern</Link>
</p>
</div>
</div>
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { basisUrl, skriptAntwort, skriptLaden } from "@/server/agent/skripte";
export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh");
}
+26
View File
@@ -0,0 +1,26 @@
import { readFile } from "node:fs/promises";
import { prisma } from "@/lib/db";
import { anhangPfad } from "@/lib/uploads";
export const dynamic = "force-dynamic";
/** Liefert ein hinterlegtes Rechnungs-PDF aus. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const anhang = await prisma.anhang.findUnique({ where: { id } });
if (!anhang) return new Response("Nicht gefunden", { status: 404 });
try {
const inhalt = await readFile(anhangPfad(anhang.pfad));
return new Response(new Uint8Array(inhalt), {
headers: {
"Content-Type": anhang.mimeTyp,
"Content-Disposition": `inline; filename="${encodeURIComponent(anhang.dateiname)}"`,
"Content-Length": String(inhalt.length),
"Cache-Control": "private, no-store",
},
});
} catch {
return new Response("Datei fehlt auf der Platte", { status: 410 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import {
AGENT_VERSION,
bearerToken,
enrollRateLimit,
fehlerAntwort,
fehlerBehandeln,
jsonEingabe,
rateLimitPruefen,
} from "@/server/agent/http";
import { enrollSchema } from "@/server/agent/schema";
import { registriereAgent } from "@/server/agent/service";
export const dynamic = "force-dynamic";
/** Registrierung eines Agents mit einem Enrollment-Token. */
export async function POST(request: Request) {
const token = bearerToken(request);
const gebremst = rateLimitPruefen(enrollRateLimit, request.headers, token);
if (gebremst) return gebremst;
if (!token) {
return fehlerAntwort(401, "token_fehlt", "Enrollment-Token fehlt (Authorization: Bearer …).");
}
const eingabe = await jsonEingabe(request, enrollSchema);
if ("antwort" in eingabe) return eingabe.antwort;
try {
const ergebnis = await registriereAgent(eingabe.daten, token);
return Response.json(
{
ok: true,
hostId: ergebnis.hostId,
token: ergebnis.token,
bestehenderHost: ergebnis.bestehenderHost,
serverIdBereitsBekannt: ergebnis.serverIdBereitsBekannt,
agentVersion: AGENT_VERSION,
serverZeit: new Date().toISOString(),
},
{ status: ergebnis.bestehenderHost ? 200 : 201 },
);
} catch (fehler) {
return fehlerBehandeln(fehler);
}
}
+35
View File
@@ -0,0 +1,35 @@
import {
AGENT_VERSION,
agentRateLimit,
bearerToken,
fehlerAntwort,
fehlerBehandeln,
rateLimitPruefen,
} from "@/server/agent/http";
import { authentifiziereHost } from "@/server/agent/service";
export const dynamic = "force-dynamic";
/** Verbindungstest für `proxledger-agent --test`. */
export async function GET(request: Request) {
const token = bearerToken(request);
const gebremst = rateLimitPruefen(agentRateLimit, request.headers, token);
if (gebremst) return gebremst;
if (!token) {
return fehlerAntwort(401, "token_fehlt", "Host-Token fehlt (Authorization: Bearer …).");
}
try {
const host = await authentifiziereHost(token);
return Response.json({
ok: true,
hostId: host.id,
hostname: host.hostname,
agentVersion: AGENT_VERSION,
serverZeit: new Date().toISOString(),
});
} catch (fehler) {
return fehlerBehandeln(fehler);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { clientIp } from "@/lib/ratelimit";
import {
AGENT_VERSION,
agentRateLimit,
bearerToken,
fehlerAntwort,
fehlerBehandeln,
jsonEingabe,
rateLimitPruefen,
} 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";
/** Täglicher Statusbericht eines Hosts. */
export async function POST(request: Request) {
const token = bearerToken(request);
const gebremst = rateLimitPruefen(agentRateLimit, request.headers, token);
if (gebremst) return gebremst;
if (!token) {
return fehlerAntwort(401, "token_fehlt", "Host-Token fehlt (Authorization: Bearer …).");
}
const eingabe = await jsonEingabe(request, reportSchema);
if ("antwort" in eingabe) return eingabe.antwort;
try {
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,
doppelt: ergebnis.doppelt,
laufzeitVorschlag: ergebnis.laufzeitVorschlagAngelegt,
agentVersion: AGENT_VERSION,
serverZeit: new Date().toISOString(),
});
} catch (fehler) {
return fehlerBehandeln(fehler);
}
}
+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);
}
+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);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
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: process.env.NODE_ENV === "production",
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);
}
}
+366 -1
View File
@@ -1 +1,366 @@
/* Wird in #002 durch das ProxLedger-Theme ersetzt. */
/*
* ProxLedger-Theme auf Basis von AdminLTE 4 (nur CSS) und Bootstrap 5.3.
* Farben laut Vorgabe; Info und Erfolg sind gegenüber AdminLTE abgedunkelt,
* damit weiße Schrift den Kontrast 4,5:1 erreicht.
*/
:root {
--pl-navy: #1e2a44;
--pl-primary: #007bff;
--pl-primary-dark: #0062cc;
--pl-info: #138496;
--pl-success: #1e7e34;
--pl-warning: #ffc107;
--pl-warning-text: #1f2d3d;
--pl-danger: #c82333;
--pl-neutral: #6c757d;
--pl-content-bg: #f4f6f9;
--pl-sidebar-bg: #343a40;
--pl-sidebar-text: #c2c7d0;
--pl-sidebar-divider: #4b545c;
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
--bs-primary: var(--pl-primary);
--bs-primary-rgb: 0, 123, 255;
--bs-link-color: var(--pl-primary);
--bs-link-color-rgb: 0, 123, 255;
--bs-link-hover-color: var(--pl-primary-dark);
--bs-link-hover-color-rgb: 0, 98, 204;
--bs-body-font-size: 1rem;
--lte-sidebar-width: 250px;
}
:root[data-bs-theme="dark"] {
--pl-content-bg: #1b1f24;
--pl-sidebar-bg: #22272d;
}
body {
background-color: var(--pl-content-bg);
font-size: 1rem;
}
/* ---------- Buttons und Links in Primärfarbe ---------- */
.btn-primary {
--bs-btn-bg: var(--pl-primary);
--bs-btn-border-color: var(--pl-primary);
--bs-btn-hover-bg: var(--pl-primary-dark);
--bs-btn-hover-border-color: var(--pl-primary-dark);
--bs-btn-active-bg: var(--pl-primary-dark);
--bs-btn-active-border-color: var(--pl-primary-dark);
--bs-btn-disabled-bg: var(--pl-primary);
--bs-btn-disabled-border-color: var(--pl-primary);
}
.btn-outline-primary {
--bs-btn-color: var(--pl-primary);
--bs-btn-border-color: var(--pl-primary);
--bs-btn-hover-bg: var(--pl-primary);
--bs-btn-hover-border-color: var(--pl-primary);
--bs-btn-active-bg: var(--pl-primary);
--bs-btn-active-border-color: var(--pl-primary);
}
/* ---------- Statusfarben (eigene Klassen wegen abgedunkelter Töne) ---------- */
.pl-bg-info,
.pl-bg-success,
.pl-bg-danger,
.pl-bg-neutral,
.pl-bg-primary {
color: #fff;
}
.pl-bg-info {
background-color: var(--pl-info);
}
.pl-bg-success {
background-color: var(--pl-success);
}
.pl-bg-danger {
background-color: var(--pl-danger);
}
.pl-bg-neutral {
background-color: var(--pl-neutral);
}
.pl-bg-primary {
background-color: var(--pl-primary);
}
.pl-bg-warning {
color: var(--pl-warning-text);
background-color: var(--pl-warning);
}
.pl-text-danger {
color: var(--pl-danger) !important;
}
.pl-text-success {
color: var(--pl-success) !important;
}
.pl-text-warning {
color: #9a7400 !important;
}
:root[data-bs-theme="dark"] .pl-text-warning {
color: var(--pl-warning) !important;
}
/* ---------- Sidebar ---------- */
.app-sidebar {
background-color: var(--pl-sidebar-bg);
--lte-sidebar-color: var(--pl-sidebar-text);
--lte-sidebar-hover-color: #fff;
--lte-sidebar-hover-bg: rgba(255, 255, 255, 0.08);
--lte-sidebar-menu-active-color: #fff;
--lte-sidebar-menu-active-bg: var(--pl-primary);
--lte-sidebar-header-color: #8a93a0;
--lte-sidebar-submenu-color: var(--pl-sidebar-text);
--lte-sidebar-submenu-hover-color: #fff;
--lte-sidebar-submenu-hover-bg: rgba(255, 255, 255, 0.08);
--bs-border-color: var(--pl-sidebar-divider);
}
.app-sidebar .sidebar-brand {
background-color: var(--pl-sidebar-bg);
}
.app-sidebar .brand-text {
font-weight: 300;
color: #fff;
letter-spacing: 0.02em;
}
.app-sidebar .nav-link {
border-radius: 0.25rem;
}
.app-sidebar .nav-header {
padding-top: 1rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pl-sidebar-user {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.85rem 0.9rem;
border-bottom: 1px solid var(--pl-sidebar-divider);
color: var(--pl-sidebar-text);
}
.pl-sidebar-user-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
font-weight: 600;
color: #fff;
background-color: var(--pl-info);
border-radius: 50%;
}
.pl-sidebar-search {
padding: 0.75rem 0.9rem;
}
/* Im eingeklappten Zustand nur Icons zeigen */
.sidebar-collapse .pl-sidebar-user span,
.sidebar-collapse .pl-sidebar-search,
.sidebar-collapse .app-sidebar .brand-text {
display: none;
}
/* ---------- Inhaltsbereich ---------- */
.app-content-header {
padding: 1rem 0 0.5rem;
}
.app-main {
background-color: var(--pl-content-bg);
}
.pl-page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 400;
}
/* ---------- Small Box ---------- */
.small-box {
overflow: hidden;
}
.small-box > .inner {
padding: 1rem 1rem 0.25rem;
}
.small-box h3 {
font-size: 2.2rem;
font-weight: 700;
}
.small-box .small-box-icon {
position: absolute;
top: 0.75rem;
right: 1rem;
font-size: 3.25rem;
line-height: 1;
opacity: 0.25;
}
.small-box .small-box-footer {
color: inherit;
text-decoration: none;
}
.small-box .small-box-footer:hover {
text-decoration: underline;
}
/* ---------- Karten ---------- */
.pl-card-outline-primary {
border-top: 3px solid var(--pl-primary);
}
.pl-card-outline-warning {
border-top: 3px solid var(--pl-warning);
}
.pl-card-outline-danger {
border-top: 3px solid var(--pl-danger);
}
.pl-card-outline-info {
border-top: 3px solid var(--pl-info);
}
.card-title {
font-size: 1.05rem;
font-weight: 500;
margin: 0;
}
/* ---------- Aufgabenliste (AdminLTE-Todo) ---------- */
.pl-todo-list {
padding: 0;
margin: 0;
list-style: none;
}
.pl-todo-list > li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.65rem 0.75rem;
margin-bottom: 0.4rem;
border-radius: 0.25rem;
border-left: 3px solid var(--pl-neutral);
background-color: var(--bs-secondary-bg);
}
.pl-todo-list > li.pl-todo-gefahr {
border-left-color: var(--pl-danger);
}
.pl-todo-list > li.pl-todo-warnung {
border-left-color: var(--pl-warning);
}
.pl-todo-list > li.pl-todo-info {
border-left-color: var(--pl-info);
}
.pl-todo-erledigt .pl-todo-text {
text-decoration: line-through;
opacity: 0.6;
}
/* ---------- Monospace-Werte mit Kopierfunktion ---------- */
.pl-mono {
font-family: var(--pl-font-mono);
font-size: 0.9375em;
}
.pl-copy-button {
padding: 0 0.25rem;
font-size: 0.85em;
line-height: 1;
color: var(--bs-secondary-color);
background: none;
border: 0;
}
.pl-copy-button:hover {
color: var(--pl-primary);
}
/* ---------- Tabellen ---------- */
.pl-table-card .table {
margin-bottom: 0;
}
.pl-table-card .table > :not(caption) > * > * {
padding: 0.6rem 0.75rem;
vertical-align: middle;
}
/* ---------- Jahresbahn ---------- */
.pl-timeline-row {
position: relative;
height: 2rem;
background: linear-gradient(to right, var(--bs-secondary-bg) 1px, transparent 1px) repeat-x;
background-size: calc(100% / 12) 100%;
}
.pl-timeline-bar {
position: absolute;
top: 0.4rem;
height: 1.2rem;
border-radius: 0.6rem;
color: #fff;
font-size: 0.75rem;
line-height: 1.2rem;
padding: 0 0.5rem;
white-space: nowrap;
overflow: hidden;
}
.pl-timeline-heute {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--pl-danger);
}
/* ---------- Suchdialog (Strg+K) ---------- */
.pl-search-backdrop {
position: fixed;
inset: 0;
z-index: 1080;
background-color: rgba(0, 0, 0, 0.5);
}
.pl-search-dialog {
position: fixed;
top: 12vh;
left: 50%;
z-index: 1085;
width: min(680px, 92vw);
transform: translateX(-50%);
}
/* ---------- Mobil ---------- */
@media (max-width: 575.98px) {
.pl-touch-target {
min-height: 44px;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { basisUrl, skriptAntwort, skriptLaden } from "@/server/agent/skripte";
export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request));
return skriptAntwort(inhalt, "install.sh");
}
+27 -5
View File
@@ -1,17 +1,39 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Source_Sans_3 } from "next/font/google";
import "admin-lte/dist/css/adminlte.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "./globals.css";
const sourceSans = Source_Sans_3({
subsets: ["latin"],
weight: ["300", "400", "600", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "ProxLedger",
description: "Proxmox Subscription Dashboard",
title: { default: "ProxLedger", template: "%s · ProxLedger" },
description: "Dashboard für den Subscription-Status aller Proxmox-Hosts",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#1E2A44",
};
/**
* Setzt das gespeicherte Farbschema, bevor der Browser zeichnet
* (verhindert Aufblitzen des hellen Designs).
*/
const THEMA_SKRIPT = `(function(){try{var t=localStorage.getItem("pl-theme");if(!t){t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";}document.documentElement.dataset.bsTheme=t;}catch(e){document.documentElement.dataset.bsTheme="light";}})();`;
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>{children}</body>
<html lang="de" data-bs-theme="light" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }} />
</head>
<body className={`${sourceSans.className} layout-fixed sidebar-expand-lg`}>{children}</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Link from "next/link";
import { Logo } from "@/components/Logo";
export default function NichtGefunden() {
return (
<main className="container py-5 text-center">
<Logo groesse={64} titel="ProxLedger" />
<h1 className="h3 mt-3">Seite nicht gefunden</h1>
<p className="text-body-secondary">Die aufgerufene Adresse gibt es nicht.</p>
<Link href="/" className="btn btn-primary">
Zum Dashboard
</Link>
</main>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { APP_VERSION } from "@/lib/version";
export default function Home() {
return (
<main className="container py-5">
<h1>ProxLedger</h1>
<p className="text-body-secondary">Proxmox Subscription Dashboard · Version {APP_VERSION}</p>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen).
*/
export type LogoVariante = "standard" | "sidebar" | "mono";
interface LogoProps {
variante?: LogoVariante;
groesse?: number;
className?: string;
/** Für dekorative Verwendung neben dem Schriftzug. */
titel?: string;
}
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = {
standard: { p: "#1E2A44", linien: "#17A2B8" },
sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
mono: { p: "#1E2A44", linien: "#1E2A44" },
};
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
const farben = FARBEN[variante];
return (
<svg
viewBox="0 0 48 48"
width={groesse}
height={groesse}
className={className}
role={titel ? "img" : "presentation"}
aria-label={titel}
aria-hidden={titel ? undefined : true}
xmlns="http://www.w3.org/2000/svg"
>
{titel ? <title>{titel}</title> : null}
<path
fillRule="evenodd"
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={farben.p}
/>
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
</svg>
);
}
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */
export function LogoMitSchriftzug({
variante = "standard",
groesse = 36,
}: {
variante?: LogoVariante;
groesse?: number;
}) {
return (
<>
<Logo variante={variante} groesse={groesse} className="brand-image opacity-100" titel="ProxLedger" />
<span className="brand-text ms-2">ProxLedger</span>
</>
);
}
+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>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useActionState } from "react";
import { anmelden } from "@/server/actions/auth";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
export function AnmeldeFormular({ ziel }: { ziel: string }) {
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
return (
<form action={aktion}>
<input type="hidden" name="ziel" value={ziel} />
{ergebnis.fehler ? (
<div className="alert alert-danger py-2" role="alert">
{ergebnis.fehler}
</div>
) : null}
<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" disabled={laeuft}>
{laeuft ? "Meldet an …" : "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>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
/**
* Filterleiste der Host-Tabelle. Alle Filter stehen in der Adresse, damit sich eine
* gefilterte Liste teilen lässt und der Zurück-Knopf funktioniert.
*/
export function HostFilterLeiste({
kunden,
werte,
}: {
kunden: { id: string; name: string }[];
werte: Record<string, string | undefined>;
}) {
const router = useRouter();
const pathname = usePathname();
const parameter = useSearchParams();
function setzen(schluessel: string, wert: string) {
const neu = new URLSearchParams(parameter.toString());
if (wert) neu.set(schluessel, wert);
else neu.delete(schluessel);
neu.delete("seite");
router.push(`${pathname}?${neu.toString()}`);
}
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter(
(schluessel) => werte[schluessel],
).length;
return (
<form
className="row g-2 p-3 border-bottom"
role="search"
onSubmit={(e) => {
e.preventDefault();
const eingabe = new FormData(e.currentTarget).get("q")?.toString() ?? "";
setzen("q", eingabe);
}}
>
<div className="col-md-4">
<label className="visually-hidden" htmlFor="q">
Suche
</label>
<div className="input-group">
<input
id="q"
name="q"
type="search"
defaultValue={werte.q ?? ""}
className="form-control"
placeholder="Hostname, Key, Server-ID, IP oder Rechnungsnummer …"
/>
<button type="submit" className="btn btn-primary" aria-label="Suchen">
<i className="bi bi-search" aria-hidden="true" />
</button>
</div>
</div>
<div className="col-6 col-md-2">
<select
className="form-select"
aria-label="Kunde"
value={werte.kunde ?? ""}
onChange={(e) => setzen("kunde", e.target.value)}
>
<option value="">Alle Kunden</option>
<option value="ohne">Nicht zugeordnet</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<select
className="form-select"
aria-label="Status"
value={werte.status ?? ""}
onChange={(e) => setzen("status", e.target.value)}
>
<option value="">Alle Status</option>
<option value="ACTIVE">Aktiv</option>
<option value="EXPIRED">Abgelaufen</option>
<option value="NOTFOUND">Keine Subscription</option>
<option value="INVALID">Ungültig</option>
<option value="SUSPENDED">Ausgesetzt</option>
<option value="UNKNOWN">Unbekannt</option>
<option value="keine-meldung">Meldet sich nicht</option>
</select>
</div>
<div className="col-6 col-md-2">
<select
className="form-select"
aria-label="Produkt"
value={werte.produkt ?? ""}
onChange={(e) => setzen("produkt", e.target.value)}
>
<option value="">Alle Produkte</option>
<option value="PVE">Proxmox VE</option>
<option value="PBS">Backup Server</option>
<option value="PMG">Mail Gateway</option>
</select>
</div>
<div className="col-6 col-md-2">
<select
className="form-select"
aria-label="Ablauf"
value={werte.ablauf ?? ""}
onChange={(e) => setzen("ablauf", e.target.value)}
>
<option value="">Jeder Ablauf</option>
<option value="14">Läuft in 14 Tagen ab</option>
<option value="30">Läuft in 30 Tagen ab</option>
<option value="abgelaufen">Abgelaufen</option>
</select>
</div>
<div className="col-12 d-flex align-items-center gap-3">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="rechnung"
checked={werte.rechnung === "offen"}
onChange={(e) => setzen("rechnung", e.target.checked ? "offen" : "")}
/>
<label className="form-check-label small" htmlFor="rechnung">
nur mit offenen Rechnungspunkten
</label>
</div>
{aktiveFilter > 0 ? (
<Link href="/hosts" className="small">
Filter zurücksetzen ({aktiveFilter})
</Link>
) : null}
</div>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useActionState } from "react";
import { hostNotizenSpeichern } from "@/server/actions/host-detail";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
export function HostNotizen({
hostId,
notizen,
tags,
}: {
hostId: string;
notizen: string | null;
tags: string[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(hostNotizenSpeichern, LEER);
return (
<form action={aktion}>
<input type="hidden" name="hostId" value={hostId} />
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
<div className="mb-3">
<label className="form-label" htmlFor="notizen">
Notizen
</label>
<textarea
id="notizen"
name="notizen"
rows={5}
className="form-control"
defaultValue={notizen ?? ""}
placeholder="z. B. Verlängerung mit Kundin besprochen"
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="tags">
Tags
</label>
<input
id="tags"
name="tags"
className="form-control"
defaultValue={tags.join(", ")}
placeholder="produktiv, cluster, backup"
/>
<div className="form-text">Mehrere Tags mit Komma trennen.</div>
</div>
<button type="submit" className="btn btn-primary pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Speichern"}
</button>
</form>
);
}

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