Compare commits

...
10 Commits
Author SHA1 Message Date
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
88 changed files with 10451 additions and 118 deletions
+16
View File
@@ -0,0 +1,16 @@
# 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"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000"
# Demo-Daten beim Seed anlegen (1 = ja)
SEED_DEMO="0"
+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
+40 -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,43 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Entfernt
### Sicherheit
## [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)
+30 -7
View File
@@ -2,38 +2,61 @@
## Zeitübersicht
**Gesamt:** 0 h 00 min (laufende Sitzung nicht eingerechnet)
**Gesamt:** 1 h 38 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 |
| 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**
---
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001 Projekt-Setup
**Version:** 0.1.0-dev
**Issue:** #001–#005 (Meilensteine v0.1.0 und v0.2.0)
**Version:** 0.2.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
### Entscheidungen
- **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.
### Probleme / offene Fragen
- Docker ist nicht installiert und `sudo` erfordert ein Passwort → Installation muss manuell erfolgen (benötigt ab #003).
- 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)
- #006 Kunden, Standorte und Hostzuweisung (erste Oberfläche mit echten Daten)
+29 -1
View File
@@ -11,12 +11,40 @@ 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
```
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.
## 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: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 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.
+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/`.
+8 -7
View File
@@ -4,12 +4,12 @@ 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 | – |
| [#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 | in Arbeit | 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 | – |
@@ -19,4 +19,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – |
| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
| | **Summe** | | | **101 h** | **–** |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| | **Summe** | | | **106 h** | **1 h 38 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;
+5 -1
View File
@@ -1,7 +1,11 @@
import type { NextConfig } from "next";
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"],
},
};
export default nextConfig;
+2461 -14
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.1.0-dev",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -10,9 +10,19 @@
"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"
},
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
@@ -25,10 +35,13 @@
"@types/node": "^24.13.5",
"@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"],
},
});
@@ -0,0 +1,24 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
export const metadata = { title: "Benachrichtigungen" };
export default function BenachrichtigungenSeite() {
return (
<>
<ContentHeader
titel="Benachrichtigungen"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Card titel="E-Mail und Webhook" rahmen="primary">
<p className="text-body-secondary mb-0">
SMTP (Microsoft 365), Webhooks und die tägliche Zusammenfassung werden in #012 umgesetzt.
</p>
</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,37 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
export const metadata = { title: "Agent-Tokens" };
export default function TokensSeite() {
return (
<>
<ContentHeader
titel="Agent-Tokens"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Agent-Tokens" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Card titel="Agent installieren" rahmen="primary">
<p>
Auf dem Proxmox-Host als root ausführen. Der Enrollment-Token gilt bis zum angegebenen Datum und
kann mehrfach verwendet werden.
</p>
<pre className="bg-body-secondary p-3 rounded">
<code className="pl-mono">
curl -fsSL https://proxledger.example.de/install.sh | bash -s -- --token plx_e_BEISPIEL
</code>
</pre>
<CopyableCode wert="plx_e_BEISPIEL" />
</Card>
<Card titel="Vorhandene Tokens" rahmen="info">
<p className="text-body-secondary mb-0">
Ausgabe, Ablauf und Widerruf von Enrollment- und Host-Tokens folgen in #006.
</p>
</Card>
</div>
</div>
</>
);
}
+315
View File
@@ -0,0 +1,315 @@
import Link from "next/link";
import { notFound } from "next/navigation";
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 { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { HEUTE, hosts } from "@/mock/data";
import {
PRODUKT_NAME,
alertsVonHost,
getHost,
getKunde,
hostMeldetNicht,
laufzeitenVonHost,
reportsVonHost,
} from "@/mock/queries";
export function generateStaticParams() {
return hosts.map((host) => ({ id: host.id }));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return { title: getHost(id)?.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 = getHost(id);
if (!host) notFound();
const kunde = getKunde(host.kundeId);
const standort = kunde?.standorte.find((s) => s.id === host.standortId);
const laufzeiten = laufzeitenVonHost(host.id);
const reports = reportsVonHost(host.id);
const alerts = alertsVonHost(host.id);
const keys = [...new Set(laufzeiten.map((l) => l.key))];
return (
<>
<ContentHeader
titel={host.hostname}
brotkrumen={[{ titel: "Hosts", href: "/hosts" }, { titel: host.hostname }]}
aktion={<StatusBadge status={host.subscription.status} />}
/>
<div className="app-content">
<div className="container-fluid">
{alerts.length > 0 ? (
<div className="row">
<div className="col-12">
<Card titel="Offene Warnungen" rahmen="danger">
<ul className="list-unstyled mb-0">
{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>
</div>
</div>
) : 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>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
</div>
<Zeile bezeichnung="Kunde">
{kunde ? (
<Link href={`/kunden/${kunde.id}`}>{kunde.name}</Link>
) : (
<Link href="/hosts/nicht-zugeordnet" className="badge pl-bg-neutral text-decoration-none">
Nicht zugeordnet
</Link>
)}
</Zeile>
<Zeile bezeichnung="Standort">{standort?.name ?? "–"}</Zeile>
<Zeile bezeichnung="Produkt">{PRODUKT_NAME[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.subscription.lizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
</Zeile>
<Zeile bezeichnung="Uptime">{formatUptime(host.uptimeSekunden)}</Zeile>
<Zeile bezeichnung="Paket-Updates">{formatZahl(host.paketUpdates)}</Zeile>
<Zeile bezeichnung="Agent-Version">{host.agentVersion}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReport)}</Zeile>
<div className="d-grid gap-2 mt-3">
<span className="btn btn-outline-secondary disabled">Kunde ändern (ab #006)</span>
</div>
</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.subscription.key} />
</Zeile>
<Zeile bezeichnung="Status">
<StatusBadge status={host.subscription.status} />
</Zeile>
<Zeile bezeichnung="Produktname">{host.subscription.produktname ?? "–"}</Zeile>
<Zeile bezeichnung="Level">{host.subscription.level ?? "–"}</Zeile>
<Zeile bezeichnung="Registriert am">{formatDatum(host.subscription.regdate)}</Zeile>
<Zeile bezeichnung="Läuft ab am">
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</Zeile>
<Zeile bezeichnung="Letzte Prüfung durch Proxmox">
{formatDatumZeit(host.subscription.checktime)}
</Zeile>
<div className="mt-3">
{host.tags.map((tag) => (
<span key={tag} className="badge pl-bg-neutral me-1">
{tag}
</span>
))}
</div>
</>
),
},
{
id: "keys",
titel: "Subscription-Historie",
icon: "bi-key",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Erste Laufzeit</th>
<th scope="col">Letzte Laufzeit</th>
</tr>
</thead>
<tbody>
{keys.map((key) => {
const zugehoerig = laufzeiten.filter((l) => l.key === key);
return (
<tr key={key}>
<td>
<CopyableCode wert={key} />
</td>
<td>{formatDatum(zugehoerig[zugehoerig.length - 1]?.start)}</td>
<td>{formatDatum(zugehoerig[0]?.ende)}</td>
</tr>
);
})}
</tbody>
</table>
),
},
{
id: "reports",
titel: "Reports",
icon: "bi-clock-history",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Zeitpunkt</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>
{reports.map((report) => (
<tr key={report.id}>
<td>{formatDatumZeit(report.zeitpunkt)}</td>
<td>
<StatusBadge status={report.status} />
</td>
<td>
<span className="pl-mono">{report.key ?? "–"}</span>
</td>
<td>{formatDatum(report.nextduedate)}</td>
<td>{formatZahl(report.paketUpdates)}</td>
<td>{report.agentVersion}</td>
</tr>
))}
</tbody>
</table>
),
},
{
id: "rechnungen",
titel: "Rechnungen",
icon: "bi-receipt",
inhalt: (
<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>
{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>
<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>
<td>
{laufzeit.einkauf && laufzeit.verkauf?.nettoBetrag
? formatEuro(
(laufzeit.verkauf.nettoBetrag ?? 0) -
(laufzeit.einkauf.nettoBetrag ?? 0),
)
: "–"}
</td>
</tr>
))}
</tbody>
</table>
),
},
{
id: "notizen",
titel: "Notizen",
icon: "bi-journal-text",
inhalt: (
<p className="mb-0">
{host.notizen ?? (
<span className="text-body-secondary">Keine Notizen hinterlegt.</span>
)}
</p>
),
},
]}
/>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,104 @@
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatumZeit } from "@/lib/format";
import { kunden } from "@/mock/data";
import { PRODUKT_NAME, nichtZugeordneteHosts } from "@/mock/queries";
export const metadata = { title: "Nicht zugeordnete Hosts" };
export default function NichtZugeordnetSeite() {
const liste = nichtZugeordneteHosts();
return (
<>
<ContentHeader
titel="Nicht zugeordnete Hosts"
brotkrumen={[{ titel: "Hosts", href: "/hosts" }, { titel: "Nicht zugeordnet" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Callout titel="Schnellzuweisung" farbe="info">
Mehrere Hosts auswählen und gemeinsam einem Kunden zuweisen. Im Prototyp ist das Formular noch
ohne Funktion – die Zuweisung folgt in #006.
</Callout>
<Card titel={`${liste.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">
<input type="checkbox" className="form-check-input" aria-label="Alle auswählen" />
</th>
<th scope="col">Host</th>
<th scope="col">Produkt</th>
<th scope="col">Server-ID</th>
<th scope="col">Key</th>
<th scope="col">Erste Meldung</th>
</tr>
</thead>
<tbody>
{liste.map((host) => (
<tr key={host.id}>
<td>
<input
type="checkbox"
className="form-check-input"
aria-label={`${host.hostname} auswählen`}
defaultChecked
/>
</td>
<td className="fw-semibold">{host.hostname}</td>
<td>{PRODUKT_NAME[host.produkt]}</td>
<td>
<CopyableCode wert={host.serverId} />
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>{formatDatumZeit(host.letzterReport)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card titel="Ausgewählte Hosts zuweisen">
<div className="row g-2 align-items-end">
<div className="col-md-5">
<label className="form-label" htmlFor="kunde">
Kunde
</label>
<select id="kunde" className="form-select">
{kunden.map((kunde) => (
<option key={kunde.id}>{kunde.name}</option>
))}
</select>
</div>
<div className="col-md-5">
<label className="form-label" htmlFor="standort">
Standort (optional)
</label>
<select id="standort" className="form-select">
<option>– kein Standort –</option>
{kunden[0].standorte.map((standort) => (
<option key={standort.id}>{standort.name}</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="button" className="btn btn-primary w-100" disabled>
Zuweisen
</button>
</div>
</div>
</Card>
</div>
</div>
</>
);
}
+142
View File
@@ -0,0 +1,142 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { AblaufBadge, KeineMeldungBadge, StatusBadge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { HEUTE, hosts } from "@/mock/data";
import { PRODUKT_NAME, getKunde, hostMeldetNicht, suche } from "@/mock/queries";
export const metadata = { title: "Hosts" };
export default async function HostsSeite({
searchParams,
}: {
searchParams: Promise<{ q?: string; status?: string; kunde?: string }>;
}) {
const { q, status, kunde } = await searchParams;
let liste = q ? suche(q).hosts : hosts;
if (status) liste = liste.filter((h) => h.subscription.status === status);
if (kunde) liste = liste.filter((h) => h.kundeId === kunde);
return (
<>
<ContentHeader titel="Hosts" brotkrumen={[{ titel: "Hosts" }]} />
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${liste.length} von ${hosts.length} Hosts`}
rahmen="primary"
tabelle
werkzeuge={
<>
<Link href="/hosts/nicht-zugeordnet" className="btn btn-sm btn-outline-primary">
Nicht zugeordnete
</Link>
<span className="btn btn-sm btn-outline-secondary disabled">CSV-Export (ab #007)</span>
</>
}
>
<form className="p-3 border-bottom row g-2" role="search">
<div className="col-md-5">
<input
type="search"
name="q"
defaultValue={q ?? ""}
className="form-control"
placeholder="Hostname, Key, Server-ID oder IP …"
aria-label="Hosts durchsuchen"
/>
</div>
<div className="col-md-3">
<select name="status" defaultValue={status ?? ""} className="form-select" aria-label="Status">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="expired">Abgelaufen</option>
<option value="notfound">Keine Subscription</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">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Produkt</th>
<th scope="col">Status</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col">Sockets</th>
</tr>
</thead>
<tbody>
{liste.map((host) => {
const kundeDesHosts = getKunde(host.kundeId);
const socketAbweichung =
host.subscription.lizenzierteSockets !== null &&
host.sockets !== null &&
host.subscription.lizenzierteSockets !== host.sockets;
return (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
<div className="small text-body-secondary">{host.ips.join(", ")}</div>
</td>
<td>
{kundeDesHosts ? (
<Link href={`/kunden/${kundeDesHosts.id}`} className="text-decoration-none">
{kundeDesHosts.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>{PRODUKT_NAME[host.produkt]}</td>
<td>
<StatusBadge status={host.subscription.status} />
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</td>
<td>
{host.subscription.lizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</td>
</tr>
);
})}
{liste.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-body-secondary py-4">
Keine Hosts gefunden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { formatDatum, tageBis } from "@/lib/format";
import { HEUTE, hosts, kunden } from "@/mock/data";
export const metadata = { title: "Jahresbahn" };
const MONATE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
export default function JahresbahnSeite() {
const start = new Date(Date.UTC(HEUTE.getUTCFullYear(), HEUTE.getUTCMonth(), 1));
const gesamtTage = 365;
function position(datum: string): number | null {
const tage = Math.round((new Date(datum).getTime() - start.getTime()) / 86_400_000);
if (tage < 0 || tage > gesamtTage) return null;
return (tage / gesamtTage) * 100;
}
const heutePosition = position(HEUTE.toISOString().slice(0, 10)) ?? 0;
const monatsBeschriftung = 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 12 Monate" rahmen="primary">
<div className="table-responsive">
<div style={{ minWidth: "760px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "12rem" }}>
{monatsBeschriftung.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
</div>
))}
</div>
{kunden.map((kunde) => {
const eigene = hosts.filter(
(h) => h.kundeId === kunde.id && h.subscription.nextduedate !== null,
);
if (eigene.length === 0) return null;
return (
<div key={kunde.id} className="mb-3">
<div className="fw-semibold small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
</div>
{eigene.map((host) => {
const datum = host.subscription.nextduedate!;
const links = position(datum.slice(0, 10));
const tage = tageBis(datum, HEUTE);
const farbe =
tage === null || tage < 14
? "var(--pl-danger)"
: tage < 30
? "var(--pl-warning)"
: "var(--pl-info)";
return (
<div key={host.id} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "12rem" }}>
<Link href={`/hosts/${host.id}`} className="text-decoration-none">
{host.hostname}
</Link>
</div>
<div className="pl-timeline-row flex-grow-1 position-relative">
<div className="pl-timeline-heute" style={{ left: `${heutePosition}%` }} />
{links === null ? (
<span className="small text-body-secondary ms-2">
außerhalb des Zeitraums ({formatDatum(datum)})
</span>
) : (
<div
className="pl-timeline-bar"
style={{
left: `${Math.max(0, links - 6)}%`,
backgroundColor: farbe,
color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff",
}}
title={`${host.hostname} läuft am ${formatDatum(datum)} ab`}
>
{formatDatum(datum)}
</div>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
<p className="small text-body-secondary mb-0">
Die rote Linie markiert heute ({formatDatum(HEUTE)}). Farbe der Balken: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau.
</p>
</Card>
</div>
</div>
</>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
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 { HEUTE, kunden } from "@/mock/data";
import {
PRODUKT_NAME,
alertsVonKunde,
getKunde,
hostMeldetNicht,
hostsVonKunde,
laufzeitenVonKunde,
summenProJahr,
summenVonKunde,
} from "@/mock/queries";
export function generateStaticParams() {
return kunden.map((kunde) => ({ id: kunde.id }));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return { title: getKunde(id)?.name ?? "Kunde" };
}
export default async function KundenDetailSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = getKunde(id);
if (!kunde) notFound();
const eigeneHosts = hostsVonKunde(kunde.id);
const laufzeiten = laufzeitenVonKunde(kunde.id);
const summen = summenVonKunde(kunde.id);
const proJahr = summenProJahr(laufzeiten);
const alerts = alertsVonKunde(kunde.id);
return (
<>
<ContentHeader
titel={kunde.name}
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox text="Hosts" wert={String(eigeneHosts.length)} icon="bi-hdd-stack" farbe="info" />
</div>
<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>
<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>
<dt className="col-5 fw-normal text-body-secondary">Standorte</dt>
<dd className="col-7">
{kunde.standorte.map((s) => (
<div key={s.id}>
{s.name}
{s.ort ? <span className="text-body-secondary"> · {s.ort}</span> : null}
</div>
))}
</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="Offene Warnungen" rahmen={alerts.length > 0 ? "danger" : "info"}>
{alerts.length === 0 ? (
<p className="mb-0 text-body-secondary">Keine offenen Warnungen.</p>
) : (
<ul className="list-unstyled mb-0 small">
{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" 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>
</tr>
</thead>
<tbody>
{eigeneHosts.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
</td>
<td>{PRODUKT_NAME[host.produkt]}</td>
<td>
<StatusBadge status={host.subscription.status} />
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</td>
</tr>
))}
</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) => {
const host = eigeneHosts.find((h) => h.id === laufzeit.hostId);
return (
<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>{host?.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>
);
})}
</tbody>
</table>
</div>
</Card>
<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>
</div>
</div>
</div>
</div>
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
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 { formatEuro } from "@/lib/format";
import { HEUTE } from "@/mock/data";
import { kundenUebersicht, summenVonKunde } from "@/mock/queries";
export const metadata = { title: "Kunden" };
export default function KundenSeite() {
const uebersicht = kundenUebersicht();
return (
<>
<ContentHeader titel="Kunden" brotkrumen={[{ titel: "Kunden" }]} />
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${uebersicht.length} Kunden`}
rahmen="primary"
tabelle
werkzeuge={<span className="btn btn-sm btn-primary disabled">Kunde anlegen (ab #006)</span>}
>
<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>
<th scope="col">Marge (netto)</th>
</tr>
</thead>
<tbody>
{uebersicht.map((eintrag) => {
const summen = summenVonKunde(eintrag.kunde.id);
return (
<tr key={eintrag.kunde.id}>
<td>
<Link
href={`/kunden/${eintrag.kunde.id}`}
className="fw-semibold text-decoration-none"
>
{eintrag.kunde.name}
</Link>
<div className="small text-body-secondary">
{eintrag.kunde.ansprechpartner ?? "–"}
</div>
</td>
<td className="pl-mono">{eintrag.kunde.kundennummer}</td>
<td>{eintrag.hosts}</td>
<td>{eintrag.aktiv}</td>
<td>
<AblaufBadge datum={eintrag.naechsterAblauf} heute={HEUTE} />
</td>
<td>
{eintrag.offeneAlerts > 0 ? (
<Badge farbe="warning">{eintrag.offeneAlerts} offen</Badge>
) : (
<Badge farbe="success">keine</Badge>
)}
</td>
<td>
{eintrag.offeneRechnungspunkte > 0 ? (
<Badge farbe="danger">{eintrag.offeneRechnungspunkte} offen</Badge>
) : (
<Badge farbe="success">erledigt</Badge>
)}
</td>
<td>{formatEuro(summen.marge)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar";
import { hosts } from "@/mock/data";
import { dashboardKennzahlen, offeneAlerts } from "@/mock/queries";
import { suchIndex } from "@/mock/suchindex";
const BENUTZER = "Florian";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const kennzahlen = dashboardKennzahlen();
return (
<div className="app-wrapper">
<Header alerts={offeneAlerts()} suchEintraege={suchIndex()} benutzer={BENUTZER} />
<Sidebar
hostsAnzahl={hosts.length}
offeneRechnungspunkte={kennzahlen.offeneRechnungspunkte}
benutzer={BENUTZER}
/>
<main className="app-main">{children}</main>
<Footer />
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
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 { HEUTE } from "@/mock/data";
import {
laufzeitZuAblauf,
dashboardKennzahlen,
getKunde,
hostMeldetNicht,
hostsNachAblauf,
nichtZugeordneteHosts,
offeneRechnungsLaufzeiten,
rechnungsFortschrittProKunde,
} from "@/mock/queries";
export const metadata = { title: "Dashboard" };
function aufgabenliste(): Aufgabe[] {
return offeneRechnungsLaufzeiten().flatMap((laufzeit) => {
const host = hostsNachAblauf().find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
const basis = `${host?.hostname ?? "?"} · ${kunde?.name ?? "Nicht zugeordnet"}`;
const aufgaben: Aufgabe[] = [];
if (laufzeit.einkauf && !laufzeit.einkauf.bezahlt) {
aufgaben.push({
id: `${laufzeit.id}-einkauf`,
titel: `Proxmox-Rechnung ${laufzeit.einkauf.nummer} bezahlen`,
detail: `${basis} · ${formatEuro(laufzeit.einkauf.nettoBetrag)} netto · fällig am ${formatDatum(
laufzeit.einkauf.faelligAm,
)}`,
dringlichkeit: "gefahr",
badgeText: "Einkauf",
href: `/hosts/${laufzeit.hostId}`,
});
}
if (laufzeit.einkauf && (!laufzeit.verkauf || !laufzeit.verkauf.erstellt)) {
aufgaben.push({
id: `${laufzeit.id}-verkauf`,
titel: "Kundenrechnung erstellen",
detail: `${basis} · Laufzeit ${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`,
dringlichkeit: "warnung",
badgeText: "Verkauf",
href: `/rechnungen`,
});
}
return aufgaben;
});
}
export default function DashboardSeite() {
const kennzahlen = dashboardKennzahlen();
const ohneKunde = nichtZugeordneteHosts();
const fortschritt = rechnungsFortschrittProKunde();
const aufgaben = aufgabenliste();
const nichtZugeordnetAufgaben: Aufgabe[] = ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId} · seit ${formatDatum(host.letzterReport)} gemeldet`,
dringlichkeit: "info",
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((h) => h.hostname).join(", ")}
</Callout>
) : null}
<div className="row">
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.hostsGesamt}
text="Hosts gemeldet"
zusatz={`${kennzahlen.ohneMeldung} ohne Meldung`}
icon="bi-hdd-stack"
farbe="info"
href="/hosts"
/>
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.aktivLizenziert}
text="Aktiv lizenziert"
zusatz={`${kennzahlen.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={kennzahlen.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={kennzahlen.offeneRechnungspunkte}
text="Offene Rechnungspunkte"
zusatz={`${kennzahlen.offeneEinkaufsrechnungen} Einkauf · ${kennzahlen.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>
{hostsNachAblauf().map((host) => {
const kunde = getKunde(host.kundeId);
const laufzeit = laufzeitZuAblauf(host);
return (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
</td>
<td>
{kunde ? (
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</td>
<td>
<EinkaufBadge bezahlt={laufzeit?.einkauf ? laufzeit.einkauf.bezahlt : null} />
</td>
<td>
<VerkaufBadge
erstellt={laufzeit?.verkauf?.erstellt ?? false}
bezahlt={laufzeit?.verkauf?.bezahlt ?? false}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
<div className="col-lg-4">
<Card
titel="Aufgaben"
rahmen="warning"
werkzeuge={
<span className="badge pl-bg-warning">
{aufgaben.length + nichtZugeordnetAufgaben.length}
</span>
}
>
<TodoList aufgaben={[...aufgaben, ...nichtZugeordnetAufgaben]} />
</Card>
</div>
</div>
<div className="row">
<div className="col-lg-8">
<Card titel="Rechnungsstatus je Kunde" rahmen="info">
{fortschritt.map((eintrag) => (
<div className="mb-3" key={eintrag.kunde.id}>
<div className="d-flex justify-content-between small mb-1">
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
</Link>
<span className="text-body-secondary">
{eintrag.erledigt} von {eintrag.gesamt} Punkten erledigt
</span>
</div>
<ProgressBar
prozent={eintrag.prozent}
beschriftung={`Rechnungsstatus ${eintrag.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="/kunden">Kundenübersicht öffnen</Link>
</li>
<li className="mb-2">
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/rechnungen">Rechnungen pflegen</Link>
</li>
<li>
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/jahresbahn">Abläufe der nächsten 12 Monate ansehen</Link>
</li>
</ul>
</Card>
</div>
</div>
</div>
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatum, formatEuro } from "@/lib/format";
import { hosts, laufzeiten } from "@/mock/data";
import { getKunde, istAbgeschlossen } from "@/mock/queries";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite({
searchParams,
}: {
searchParams: Promise<{ filter?: string }>;
}) {
const { filter = "offen" } = await searchParams;
const alle = laufzeiten.filter((l) => !l.vorschlag);
const liste =
filter === "erledigt"
? alle.filter(istAbgeschlossen)
: filter === "alle"
? alle
: alle.filter((l) => !istAbgeschlossen(l));
return (
<>
<ContentHeader titel="Rechnungen" brotkrumen={[{ titel: "Rechnungen" }]} />
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${liste.length} Laufzeiten`}
rahmen="primary"
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
{[
["offen", "Offen"],
["erledigt", "Erledigt"],
["alle", "Alle"],
].map(([wert, text]) => (
<Link
key={wert}
href={`/rechnungen?filter=${wert}`}
className={`btn ${filter === wert ? "btn-primary" : "btn-outline-primary"}`}
>
{text}
</Link>
))}
</div>
}
>
<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">
Proxmox bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="text-center">
Kundenrechnung erstellt
</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{liste.map((laufzeit) => {
const host = hosts.find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
const marge = (laufzeit.verkauf?.nettoBetrag ?? 0) - (laufzeit.einkauf?.nettoBetrag ?? 0);
return (
<tr key={laufzeit.id}>
<td>
{kunde ? (
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
{host?.hostname}
</Link>
</td>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</td>
<td>
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td className="text-center">
<input
type="checkbox"
className="form-check-input pl-touch-target"
defaultChecked={laufzeit.einkauf?.bezahlt ?? false}
aria-label="Proxmox-Rechnung bezahlt"
/>
</td>
<td>
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td className="text-center">
<input
type="checkbox"
className="form-check-input pl-touch-target"
defaultChecked={laufzeit.verkauf?.erstellt ?? false}
aria-label="Kundenrechnung erstellt"
/>
</td>
<td className={marge >= 0 ? "pl-text-success" : "pl-text-danger"}>
{laufzeit.verkauf?.nettoBetrag ? formatEuro(marge) : "–"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Im Prototyp sind die Häkchen noch ohne Wirkung. Ab #008 setzen sie den Status direkt.
</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");
}
+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);
}
}
+44
View File
@@ -0,0 +1,44 @@
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";
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));
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);
}
}
+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>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import type { ReactNode } from "react";
export interface Brotkrume {
titel: string;
href?: string;
}
/** Inhaltskopf: Seitentitel links, Breadcrumb rechts (AdminLTE-Standard). */
export function ContentHeader({
titel,
brotkrumen = [],
aktion,
}: {
titel: string;
brotkrumen?: Brotkrume[];
aktion?: ReactNode;
}) {
return (
<div className="app-content-header">
<div className="container-fluid">
<div className="row align-items-center">
<div className="col-sm-6 d-flex align-items-center gap-3">
<h1 className="pl-page-title">{titel}</h1>
{aktion}
</div>
<div className="col-sm-6">
<nav aria-label="Brotkrumen">
<ol className="breadcrumb float-sm-end mb-0">
<li className="breadcrumb-item">
<Link href="/">Start</Link>
</li>
{brotkrumen.map((k, i) => (
<li
key={`${k.titel}-${i}`}
className={`breadcrumb-item ${i === brotkrumen.length - 1 ? "active" : ""}`}
aria-current={i === brotkrumen.length - 1 ? "page" : undefined}
>
{k.href ? <Link href={k.href}>{k.titel}</Link> : k.titel}
</li>
))}
</ol>
</nav>
</div>
</div>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { APP_VERSION } from "@/lib/version";
export function Footer() {
return (
<footer className="app-footer bg-body">
<div className="float-end d-none d-sm-inline">Prototyp mit Beispieldaten (#002)</div>
<strong>ProxLedger</strong> · Version {APP_VERSION}
</footer>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import type { SuchEintrag } from "@/mock/suchindex";
import type { Alert } from "@/mock/types";
import { SearchDialog } from "./SearchDialog";
const SCHWERE_ICON: Record<Alert["schwere"], string> = {
gefahr: "bi-exclamation-octagon-fill pl-text-danger",
warnung: "bi-exclamation-triangle-fill pl-text-warning",
info: "bi-info-circle-fill text-body-secondary",
};
/** Farbschema aus dem <html>-Attribut lesen, damit Server und Client übereinstimmen. */
function themaIstDunkel() {
return document.documentElement.dataset.bsTheme === "dark";
}
function themaAbonnieren(aenderung: () => void) {
const beobachter = new MutationObserver(aenderung);
beobachter.observe(document.documentElement, { attributes: true, attributeFilter: ["data-bs-theme"] });
return () => beobachter.disconnect();
}
export function Header({
alerts,
suchEintraege,
benutzer,
}: {
alerts: Alert[];
suchEintraege: SuchEintrag[];
benutzer: string;
}) {
const [sucheOffen, setSucheOffen] = useState(false);
const [glockeOffen, setGlockeOffen] = useState(false);
const [benutzerOffen, setBenutzerOffen] = useState(false);
const dunkel = useSyncExternalStore(themaAbonnieren, themaIstDunkel, () => false);
useEffect(() => {
function tasten(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSucheOffen(true);
}
}
window.addEventListener("keydown", tasten);
return () => window.removeEventListener("keydown", tasten);
}, []);
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
const sidebarUmschalten = useCallback(() => {
const body = document.body;
if (window.innerWidth < 992) {
body.classList.toggle("sidebar-open");
body.classList.remove("sidebar-collapse");
} else {
body.classList.toggle("sidebar-collapse");
body.classList.remove("sidebar-open");
}
}, []);
function themaUmschalten() {
const neu = dunkel ? "light" : "dark";
document.documentElement.dataset.bsTheme = neu;
try {
localStorage.setItem("pl-theme", neu);
} catch {
// Speicherzugriff kann blockiert sein; das Thema gilt dann nur für diese Sitzung.
}
}
function vollbild() {
if (document.fullscreenElement) {
void document.exitFullscreen();
} else {
void document.documentElement.requestFullscreen();
}
}
return (
<nav className="app-header navbar navbar-expand bg-body shadow-sm">
<div className="container-fluid">
<ul className="navbar-nav">
<li className="nav-item">
<button
type="button"
className="nav-link btn btn-link pl-touch-target"
onClick={sidebarUmschalten}
aria-label="Menü ein- oder ausklappen"
>
<i className="bi bi-list fs-5" aria-hidden="true" />
</button>
</li>
<li className="nav-item d-none d-md-block">
<Link href="/" className="nav-link">
Start
</Link>
</li>
<li className="nav-item d-none d-md-block">
<Link href="/einstellungen/tokens" className="nav-link">
Agent installieren
</Link>
</li>
</ul>
<ul className="navbar-nav ms-auto align-items-center">
<li className="nav-item">
<button
type="button"
className="nav-link btn btn-link pl-touch-target"
onClick={() => setSucheOffen(true)}
aria-label="Globale Suche öffnen (Strg+K)"
title="Suche (Strg+K)"
>
<i className="bi bi-search" aria-hidden="true" />
</button>
</li>
<li className="nav-item dropdown">
<button
type="button"
className="nav-link btn btn-link position-relative pl-touch-target"
onClick={() => {
setGlockeOffen((o) => !o);
setBenutzerOffen(false);
}}
aria-label={`${alerts.length} offene Warnungen anzeigen`}
aria-expanded={glockeOffen}
>
<i className="bi bi-bell-fill" aria-hidden="true" />
{alerts.length > 0 ? (
<span className="badge pl-bg-danger position-absolute top-0 start-100 translate-middle rounded-pill">
{alerts.length}
</span>
) : null}
</button>
<div
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
>
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
<div className="dropdown-divider" />
{alerts.slice(0, 6).map((alert) => (
<Link
key={alert.id}
href={alert.hostId ? `/hosts/${alert.hostId}` : "/hosts"}
className="dropdown-item d-flex gap-2 py-2"
onClick={() => setGlockeOffen(false)}
>
<i className={`bi ${SCHWERE_ICON[alert.schwere]} mt-1`} aria-hidden="true" />
<span className="text-wrap">
<span className="d-block fw-semibold">{alert.titel}</span>
<span className="d-block small text-body-secondary">{alert.beschreibung}</span>
</span>
</Link>
))}
<div className="dropdown-divider" />
<Link href="/hosts" className="dropdown-item text-center" onClick={() => setGlockeOffen(false)}>
Alle Warnungen ansehen
</Link>
</div>
</li>
<li className="nav-item">
<button
type="button"
className="nav-link btn btn-link pl-touch-target"
onClick={themaUmschalten}
aria-label={dunkel ? "Helles Design verwenden" : "Dunkles Design verwenden"}
title={dunkel ? "Helles Design" : "Dunkles Design"}
>
<i className={`bi ${dunkel ? "bi-sun" : "bi-moon-stars"}`} aria-hidden="true" />
</button>
</li>
<li className="nav-item d-none d-md-block">
<button
type="button"
className="nav-link btn btn-link pl-touch-target"
onClick={vollbild}
aria-label="Vollbild umschalten"
>
<i className="bi bi-arrows-fullscreen" aria-hidden="true" />
</button>
</li>
<li className="nav-item dropdown">
<button
type="button"
className="nav-link btn btn-link d-flex align-items-center gap-2 pl-touch-target"
onClick={() => {
setBenutzerOffen((o) => !o);
setGlockeOffen(false);
}}
aria-expanded={benutzerOffen}
>
<i className="bi bi-person-circle" aria-hidden="true" />
<span className="d-none d-sm-inline">{benutzer}</span>
</button>
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
<span className="dropdown-header">Angemeldet als {benutzer}</span>
<div className="dropdown-divider" />
<Link href="/einstellungen/system" className="dropdown-item">
Einstellungen
</Link>
<span className="dropdown-item text-body-secondary">Abmelden (ab #013)</span>
</div>
</li>
</ul>
</div>
{sucheOffen ? (
<SearchDialog eintraege={suchEintraege} onSchliessen={() => setSucheOffen(false)} />
) : null}
</nav>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import type { SuchEintrag } from "@/mock/suchindex";
const TYP_FARBE: Record<SuchEintrag["typ"], string> = {
Host: "pl-bg-info",
Kunde: "pl-bg-primary",
Rechnung: "pl-bg-neutral",
};
/**
* Globale Suche als Dialog, per Strg+K oder über die Kopfleiste.
* Wird nur gerendert, solange er offen ist – dadurch startet er jedes Mal leer.
*/
export function SearchDialog({
eintraege,
onSchliessen,
}: {
eintraege: SuchEintrag[];
onSchliessen: () => void;
}) {
const [begriff, setBegriff] = useState("");
const [markiert, setMarkiert] = useState(0);
const router = useRouter();
const treffer = useMemo(() => {
const q = begriff.trim().toLowerCase();
if (q.length < 2) return [];
return eintraege.filter((e) => e.begriffe.some((b) => b.includes(q))).slice(0, 12);
}, [begriff, eintraege]);
function oeffnen(eintrag: SuchEintrag) {
onSchliessen();
router.push(eintrag.href);
}
function tasten(event: React.KeyboardEvent) {
if (event.key === "Escape") onSchliessen();
if (event.key === "ArrowDown") {
event.preventDefault();
setMarkiert((i) => Math.min(i + 1, treffer.length - 1));
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMarkiert((i) => Math.max(i - 1, 0));
}
if (event.key === "Enter" && treffer[markiert]) {
event.preventDefault();
oeffnen(treffer[markiert]);
}
}
return (
<>
<div className="pl-search-backdrop" onClick={onSchliessen} />
<div className="pl-search-dialog" role="dialog" aria-modal="true" aria-label="Globale Suche">
<div className="card shadow-lg mb-0">
<div className="card-body p-2">
<div className="input-group">
<span className="input-group-text bg-body">
<i className="bi bi-search" aria-hidden="true" />
</span>
<input
autoFocus
type="search"
className="form-control form-control-lg"
placeholder="Key, Host, Kunde oder Rechnungsnummer …"
value={begriff}
onChange={(e) => {
setBegriff(e.target.value);
setMarkiert(0);
}}
onKeyDown={tasten}
aria-label="Suchbegriff"
/>
</div>
</div>
{treffer.length > 0 ? (
<ul className="list-group list-group-flush">
{treffer.map((eintrag, index) => (
<li key={`${eintrag.typ}-${eintrag.titel}-${index}`} className="list-group-item p-0">
<button
type="button"
className={`btn btn-link w-100 text-start text-decoration-none d-flex align-items-center gap-2 py-2 ${
index === markiert ? "bg-body-secondary" : ""
}`}
onMouseEnter={() => setMarkiert(index)}
onClick={() => oeffnen(eintrag)}
>
<span className={`badge ${TYP_FARBE[eintrag.typ]}`}>{eintrag.typ}</span>
<span className="flex-grow-1">
<span className="d-block text-body fw-semibold">{eintrag.titel}</span>
<span className="d-block small text-body-secondary">{eintrag.untertitel}</span>
</span>
</button>
</li>
))}
</ul>
) : (
<div className="card-body pt-0 small text-body-secondary">
Mindestens zwei Zeichen eingeben. Beispiel: <span className="pl-mono">pve1c-a6bd21fcb6</span>
</div>
)}
</div>
</div>
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo";
interface MenuEintrag {
href: string;
titel: string;
icon: string;
badge?: { text: string; klasse: string };
}
export function Sidebar({
hostsAnzahl,
offeneRechnungspunkte,
benutzer,
}: {
hostsAnzahl: number;
offeneRechnungspunkte: number;
benutzer: string;
}) {
const pathname = usePathname();
const router = useRouter();
const [suche, setSuche] = useState("");
const haupt: MenuEintrag[] = [
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
{
href: "/hosts",
titel: "Hosts",
icon: "bi-hdd-stack",
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
},
{ href: "/kunden", titel: "Kunden", icon: "bi-people" },
{
href: "/rechnungen",
titel: "Rechnungen",
icon: "bi-receipt",
badge:
offeneRechnungspunkte > 0
? { text: String(offeneRechnungspunkte), klasse: "pl-bg-danger" }
: undefined,
},
{ href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" },
];
const einstellungen: MenuEintrag[] = [
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
];
function istAktiv(href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
function sucheAbsenden(event: React.FormEvent) {
event.preventDefault();
if (suche.trim().length > 0) {
router.push(`/hosts?q=${encodeURIComponent(suche.trim())}`);
}
}
function eintrag(item: MenuEintrag) {
return (
<li className="nav-item" key={item.href}>
<Link href={item.href} className={`nav-link pl-touch-target ${istAktiv(item.href) ? "active" : ""}`}>
<i className={`nav-icon bi ${item.icon}`} aria-hidden="true" />
<p className="ms-2 mb-0 flex-grow-1">{item.titel}</p>
{item.badge ? (
<span className={`nav-badge badge ${item.badge.klasse}`}>{item.badge.text}</span>
) : null}
</Link>
</li>
);
}
return (
<aside className="app-sidebar shadow" data-bs-theme="dark">
<div className="sidebar-brand">
<Link href="/" className="brand-link">
<LogoMitSchriftzug variante="sidebar" groesse={34} />
</Link>
</div>
<div className="pl-sidebar-user">
<span className="pl-sidebar-user-avatar" aria-hidden="true">
{benutzer.slice(0, 1)}
</span>
<span>
<span className="d-block small text-white">{benutzer}</span>
<span className="d-block small opacity-75">Administrator</span>
</span>
</div>
<form className="pl-sidebar-search" role="search" onSubmit={sucheAbsenden}>
<div className="input-group input-group-sm">
<input
type="search"
className="form-control"
placeholder="Suchen …"
aria-label="Hosts, Kunden oder Rechnungen suchen"
value={suche}
onChange={(e) => setSuche(e.target.value)}
/>
<button className="btn btn-primary" type="submit" aria-label="Suchen">
<i className="bi bi-search" aria-hidden="true" />
</button>
</div>
</form>
<div className="sidebar-wrapper">
<nav className="mt-2">
<ul className="nav sidebar-menu flex-column" role="menu">
{haupt.map(eintrag)}
<li className="nav-header">Einstellungen</li>
{einstellungen.map(eintrag)}
</ul>
</nav>
</div>
</aside>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { formatDatum, formatTageBis, tageBis } from "@/lib/format";
import { STATUS_TEXT } from "@/mock/queries";
import type { SubscriptionStatus } from "@/mock/types";
import { FARB_KLASSE, type Farbton } from "./SmallBox";
export function Badge({ farbe, children }: { farbe: Farbton; children: React.ReactNode }) {
return <span className={`badge ${FARB_KLASSE[farbe]}`}>{children}</span>;
}
const STATUS_FARBE: Record<SubscriptionStatus, Farbton> = {
active: "success",
notfound: "neutral",
invalid: "danger",
expired: "danger",
suspended: "warning",
};
export function StatusBadge({ status }: { status: SubscriptionStatus }) {
return <Badge farbe={STATUS_FARBE[status]}>{STATUS_TEXT[status]}</Badge>;
}
/** Ablaufdatum mit Ampel: rot unter 14 Tagen, gelb unter 30 Tagen, sonst Info. */
export function AblaufBadge({ datum, heute }: { datum: string | null; heute: Date }) {
if (!datum) return <Badge farbe="neutral">–</Badge>;
const tage = tageBis(datum, heute);
const farbe: Farbton = tage === null || tage < 14 ? "danger" : tage < 30 ? "warning" : "info";
return (
<span className="d-inline-flex align-items-center gap-2">
<Badge farbe={farbe}>{formatDatum(datum)}</Badge>
<small className="text-body-secondary">{formatTageBis(tage)}</small>
</span>
);
}
export function EinkaufBadge({ bezahlt }: { bezahlt: boolean | null }) {
if (bezahlt === null) return <Badge farbe="neutral">Keine Rechnung</Badge>;
return bezahlt ? <Badge farbe="success">Bezahlt</Badge> : <Badge farbe="danger">Offen</Badge>;
}
export function VerkaufBadge({ erstellt, bezahlt }: { erstellt: boolean; bezahlt: boolean }) {
if (!erstellt) return <Badge farbe="danger">Fehlt</Badge>;
return bezahlt ? <Badge farbe="success">Bezahlt</Badge> : <Badge farbe="warning">Erstellt</Badge>;
}
export function KeineMeldungBadge() {
return (
<span className="badge pl-bg-warning ms-2" title="Der Host hat sich länger als 48 Stunden nicht gemeldet">
<i className="bi bi-wifi-off me-1" aria-hidden="true" />
Keine Meldung
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
type CalloutFarbe = "warning" | "danger" | "info";
const STIL: Record<CalloutFarbe, { rand: string; icon: string }> = {
warning: { rand: "var(--pl-warning)", icon: "bi-exclamation-triangle-fill" },
danger: { rand: "var(--pl-danger)", icon: "bi-x-octagon-fill" },
info: { rand: "var(--pl-info)", icon: "bi-info-circle-fill" },
};
export function Callout({
farbe = "warning",
titel,
children,
aktion,
}: {
farbe?: CalloutFarbe;
titel: string;
children: ReactNode;
aktion?: ReactNode;
}) {
const stil = STIL[farbe];
return (
<div
className="card bg-body shadow-sm mb-4 d-flex flex-row align-items-center gap-3 p-3"
style={{ borderLeft: `5px solid ${stil.rand}` }}
role="note"
>
<i className={`bi ${stil.icon} fs-4`} style={{ color: stil.rand }} aria-hidden="true" />
<div className="flex-grow-1">
<strong>{titel}</strong>
<div className="small text-body-secondary">{children}</div>
</div>
{aktion}
</div>
);
}
/** Fortschrittsbalken, z. B. für den Rechnungsstatus je Kunde. */
export function ProgressBar({ prozent, beschriftung }: { prozent: number; beschriftung?: string }) {
const farbe =
prozent === 100 ? "var(--pl-success)" : prozent >= 50 ? "var(--pl-warning)" : "var(--pl-danger)";
return (
<div
className="progress"
role="progressbar"
aria-valuenow={prozent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={beschriftung}
style={{ height: "0.75rem" }}
>
<div className="progress-bar" style={{ width: `${prozent}%`, backgroundColor: farbe }} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
type Rahmen = "primary" | "info" | "warning" | "danger" | "keiner";
const RAHMEN_KLASSE: Record<Rahmen, string> = {
primary: "pl-card-outline-primary",
info: "pl-card-outline-info",
warning: "pl-card-outline-warning",
danger: "pl-card-outline-danger",
keiner: "",
};
interface CardProps {
titel?: ReactNode;
werkzeuge?: ReactNode;
rahmen?: Rahmen;
/** Ohne Innenabstand, z. B. für randlose Tabellen. */
tabelle?: boolean;
fuss?: ReactNode;
className?: string;
children: ReactNode;
}
export function Card({
titel,
werkzeuge,
rahmen = "keiner",
tabelle = false,
fuss,
className = "",
children,
}: CardProps) {
return (
<div className={`card mb-4 shadow-sm ${RAHMEN_KLASSE[rahmen]} ${className}`}>
{titel ? (
<div className="card-header d-flex align-items-center justify-content-between">
<h3 className="card-title">{titel}</h3>
{werkzeuge ? <div className="card-tools d-flex gap-2">{werkzeuge}</div> : null}
</div>
) : null}
<div className={tabelle ? "card-body p-0 pl-table-card" : "card-body"}>{children}</div>
{fuss ? <div className="card-footer">{fuss}</div> : null}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
/** Monospace-Wert (Key, Server-ID, Rechnungsnummer) mit Kopier-Button. */
export function CopyableCode({ wert, className = "" }: { wert: string | null; className?: string }) {
const [kopiert, setKopiert] = useState(false);
if (!wert) return <span className="text-body-secondary">–</span>;
async function kopieren() {
try {
await navigator.clipboard.writeText(wert as string);
setKopiert(true);
setTimeout(() => setKopiert(false), 1500);
} catch {
// Zwischenablage nicht verfügbar (z. B. ohne HTTPS) – Wert bleibt markierbar.
}
}
return (
<span className={`d-inline-flex align-items-center gap-1 ${className}`}>
<span className="pl-mono">{wert}</span>
<button
type="button"
className="pl-copy-button"
onClick={kopieren}
title={`${wert} kopieren`}
aria-label={`${wert} kopieren`}
>
<i className={`bi ${kopiert ? "bi-check-lg" : "bi-clipboard"}`} aria-hidden="true" />
</button>
</span>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
export type Farbton = "info" | "success" | "warning" | "danger" | "neutral" | "primary";
export const FARB_KLASSE: Record<Farbton, string> = {
info: "pl-bg-info",
success: "pl-bg-success",
warning: "pl-bg-warning",
danger: "pl-bg-danger",
neutral: "pl-bg-neutral",
primary: "pl-bg-primary",
};
interface SmallBoxProps {
wert: number | string;
text: string;
zusatz?: string;
icon: string;
farbe: Farbton;
href: string;
linkText?: string;
}
export function SmallBox({ wert, text, zusatz, icon, farbe, href, linkText = "Mehr Infos" }: SmallBoxProps) {
return (
<div className={`small-box ${FARB_KLASSE[farbe]}`}>
<div className="inner">
<h3>{wert}</h3>
<p className="mb-2">
{text}
{zusatz ? <small className="d-block">{zusatz}</small> : null}
</p>
</div>
<i className={`bi ${icon} small-box-icon`} aria-hidden="true" />
<Link href={href} className="small-box-footer pl-touch-target">
{linkText} <i className="bi bi-arrow-right-circle" aria-hidden="true" />
</Link>
</div>
);
}
interface InfoBoxProps {
text: string;
wert: string;
icon: string;
farbe: Farbton;
}
export function InfoBox({ text, wert, icon, farbe }: InfoBoxProps) {
return (
<div className="info-box shadow-sm mb-3 bg-body">
<span className={`info-box-icon ${FARB_KLASSE[farbe]}`}>
<i className={`bi ${icon}`} aria-hidden="true" />
</span>
<div className="info-box-content">
<span className="info-box-text">{text}</span>
<span className="info-box-number">{wert}</span>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState, type ReactNode } from "react";
export interface Tab {
id: string;
titel: string;
icon?: string;
inhalt: ReactNode;
}
/** Karten-Tabs im AdminLTE-Stil, ohne Bootstrap-JavaScript. */
export function Tabs({ tabs }: { tabs: Tab[] }) {
const [aktiv, setAktiv] = useState(tabs[0]?.id);
return (
<div className="card shadow-sm mb-4">
<div className="card-header p-0 border-bottom-0">
<ul className="nav nav-tabs" role="tablist">
{tabs.map((tab) => (
<li className="nav-item" key={tab.id} role="presentation">
<button
type="button"
role="tab"
aria-selected={aktiv === tab.id}
className={`nav-link pl-touch-target ${aktiv === tab.id ? "active" : ""}`}
onClick={() => setAktiv(tab.id)}
>
{tab.icon ? <i className={`bi ${tab.icon} me-1`} aria-hidden="true" /> : null}
{tab.titel}
</button>
</li>
))}
</ul>
</div>
<div className="card-body">
{tabs.map((tab) => (
<div key={tab.id} role="tabpanel" hidden={aktiv !== tab.id}>
{aktiv === tab.id ? tab.inhalt : null}
</div>
))}
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "./Badges";
import type { Farbton } from "./SmallBox";
export interface Aufgabe {
id: string;
titel: string;
detail: string;
dringlichkeit: "gefahr" | "warnung" | "info";
badgeText: string;
href: string;
}
const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
gefahr: "danger",
warnung: "warning",
info: "info",
};
/**
* Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal;
* ab #008 setzt es den Rechnungsstatus in der Datenbank.
*/
export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
const [erledigt, setErledigt] = useState<string[]>([]);
function umschalten(id: string) {
setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
}
if (aufgaben.length === 0) {
return <p className="text-body-secondary mb-0">Nichts zu tun – alle Punkte erledigt.</p>;
}
return (
<ul className="pl-todo-list">
{aufgaben.map((aufgabe) => {
const istErledigt = erledigt.includes(aufgabe.id);
return (
<li
key={aufgabe.id}
className={`pl-todo-${aufgabe.dringlichkeit} ${istErledigt ? "pl-todo-erledigt" : ""}`}
>
<input
type="checkbox"
className="form-check-input mt-1 pl-touch-target"
checked={istErledigt}
onChange={() => umschalten(aufgabe.id)}
aria-label={`${aufgabe.titel} erledigt`}
/>
<div className="flex-grow-1 pl-todo-text">
<Link href={aufgabe.href} className="fw-semibold text-decoration-none">
{aufgabe.titel}
</Link>
<div className="small text-body-secondary">{aufgabe.detail}</div>
</div>
<Badge farbe={BADGE_FARBE[aufgabe.dringlichkeit]}>{aufgabe.badgeText}</Badge>
</li>
);
})}
</ul>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Prisma-Client als Singleton, der erst beim ersten Zugriff verbindet.
* Das ist wichtig, weil Next.js beim Bauen Module lädt, ohne dass `DATABASE_URL` gesetzt sein muss.
* Im Entwicklungsmodus verhindert der globale Zwischenspeicher, dass jeder Hot-Reload
* einen neuen Verbindungspool öffnet.
*/
declare global {
var proxledgerPrisma: PrismaClient | undefined;
}
function erzeugeClient(): PrismaClient {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example.");
}
const client = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
if (process.env.NODE_ENV !== "production") globalThis.proxledgerPrisma = client;
return client;
}
export function getPrisma(): PrismaClient {
return globalThis.proxledgerPrisma ?? erzeugeClient();
}
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
get(_ziel, eigenschaft, empfaenger) {
return Reflect.get(getPrisma(), eigenschaft, empfaenger);
},
});
+77
View File
@@ -0,0 +1,77 @@
/** Deutsche Formatierung für Datum, Währung und Zahlen. Anzeige-Zeitzone: Europe/Berlin. */
const ZEITZONE = "Europe/Berlin";
const datumFormat = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const datumZeitFormat = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const waehrungFormat = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
export function formatDatum(wert: string | Date | null | undefined): string {
if (!wert) return "–";
return datumFormat.format(new Date(wert));
}
export function formatDatumZeit(wert: string | Date | null | undefined): string {
if (!wert) return "–";
return `${datumZeitFormat.format(new Date(wert))} Uhr`;
}
export function formatEuro(betrag: number | null | undefined): string {
if (betrag === null || betrag === undefined) return "–";
return waehrungFormat.format(betrag);
}
export function formatZahl(wert: number | null | undefined): string {
if (wert === null || wert === undefined) return "–";
return new Intl.NumberFormat("de-DE").format(wert);
}
/** Ganze Tage zwischen heute und einem Datum; negativ, wenn das Datum in der Vergangenheit liegt. */
export function tageBis(datum: string | Date | null | undefined, heute: Date): number | null {
if (!datum) return null;
const tagMs = 24 * 60 * 60 * 1000;
const ziel = Date.UTC(
new Date(datum).getUTCFullYear(),
new Date(datum).getUTCMonth(),
new Date(datum).getUTCDate(),
);
const start = Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), heute.getUTCDate());
return Math.round((ziel - start) / tagMs);
}
export function formatTageBis(tage: number | null): string {
if (tage === null) return "–";
if (tage === 0) return "heute";
if (tage === 1) return "in 1 Tag";
if (tage > 1) return `in ${tage} Tagen`;
if (tage === -1) return "seit 1 Tag";
return `seit ${Math.abs(tage)} Tagen`;
}
export function formatUptime(sekunden: number | null | undefined): string {
if (sekunden === null || sekunden === undefined) return "–";
const tage = Math.floor(sekunden / 86400);
const stunden = Math.floor((sekunden % 86400) / 3600);
if (tage > 0) return `${formatZahl(tage)} Tage, ${stunden} h`;
return `${stunden} h`;
}
/** Stunden seit einem Zeitpunkt, für die Regel „keine Meldung seit 48 h“. */
export function stundenSeit(zeitpunkt: string | Date | null | undefined, jetzt: Date): number | null {
if (!zeitpunkt) return null;
return (jetzt.getTime() - new Date(zeitpunkt).getTime()) / 3_600_000;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Einfaches Token-Bucket-Rate-Limit im Prozessspeicher.
* Für eine einzelne Instanz ausreichend; bei mehreren Instanzen müsste der Zustand
* in die Datenbank oder einen gemeinsamen Cache wandern (siehe #014).
*/
interface Eimer {
tokens: number;
zuletzt: number;
}
export interface RateLimitErgebnis {
erlaubt: boolean;
/** Sekunden bis zum nächsten erlaubten Versuch (nur wenn abgelehnt). */
wiederholenNach: number;
verbleibend: number;
}
export class RateLimit {
private eimer = new Map<string, Eimer>();
constructor(
private readonly kapazitaet: number,
private readonly nachfuellProSekunde: number,
) {}
pruefe(schluessel: string, jetzt: number = Date.now()): RateLimitErgebnis {
const vorhanden = this.eimer.get(schluessel) ?? { tokens: this.kapazitaet, zuletzt: jetzt };
const vergangen = Math.max(0, (jetzt - vorhanden.zuletzt) / 1000);
const tokens = Math.min(this.kapazitaet, vorhanden.tokens + vergangen * this.nachfuellProSekunde);
if (tokens < 1) {
this.eimer.set(schluessel, { tokens, zuletzt: jetzt });
return {
erlaubt: false,
wiederholenNach: Math.ceil((1 - tokens) / this.nachfuellProSekunde),
verbleibend: 0,
};
}
this.eimer.set(schluessel, { tokens: tokens - 1, zuletzt: jetzt });
return { erlaubt: true, wiederholenNach: 0, verbleibend: Math.floor(tokens - 1) };
}
/** Entfernt Einträge, die wieder voll aufgefüllt sind (Aufräumen bei vielen Hosts). */
aufraeumen(jetzt: number = Date.now()) {
const vollNach = (this.kapazitaet / this.nachfuellProSekunde) * 1000;
for (const [schluessel, eimer] of this.eimer) {
if (jetzt - eimer.zuletzt > vollNach) this.eimer.delete(schluessel);
}
}
zuruecksetzen() {
this.eimer.clear();
}
}
/** 10 Anfragen pro Minute je Token bzw. IP – ein Agent meldet sich einmal täglich. */
export const agentRateLimit = new RateLimit(10, 10 / 60);
/** Registrierungen sind seltener und werden strenger begrenzt. */
export const enrollRateLimit = new RateLimit(5, 5 / 60);
/** Ermittelt die Client-IP hinter einem Reverse Proxy. */
export function clientIp(headers: Headers): string {
const forwarded = headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return headers.get("x-real-ip") ?? "unbekannt";
}
+48
View File
@@ -0,0 +1,48 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
/**
* API-Tokens für Agents. In der Datenbank liegt ausschließlich der SHA-256-Hash:
* Die Tokens haben volle Entropie (32 Byte Zufall), deshalb ist kein langsames
* Passwort-Hashing nötig – SHA-256 erlaubt zudem das direkte Nachschlagen per Index.
*/
export const PRAEFIX_ENROLLMENT = "plx_e_";
export const PRAEFIX_HOST = "plx_h_";
export interface ErzeugterToken {
/** Vollständiger Token im Klartext – wird nur einmal ausgegeben. */
klartext: string;
hash: string;
/** Erkennbarer Anfang für Anzeige und Protokoll, z. B. `plx_h_9f3a2b`. */
praefix: string;
}
export function erzeugeToken(typ: "enrollment" | "host"): ErzeugterToken {
const kennung = typ === "enrollment" ? PRAEFIX_ENROLLMENT : PRAEFIX_HOST;
const zufall = randomBytes(32).toString("base64url");
const klartext = `${kennung}${zufall}`;
return {
klartext,
hash: hashToken(klartext),
praefix: klartext.slice(0, kennung.length + 6),
};
}
export function hashToken(klartext: string): string {
return createHash("sha256").update(klartext.trim()).digest("hex");
}
/** Vergleicht zwei Hashes ohne zeitliche Seitenkanäle. */
export function hashGleich(a: string, b: string): boolean {
const links = Buffer.from(a, "hex");
const rechts = Buffer.from(b, "hex");
if (links.length !== rechts.length || links.length === 0) return false;
return timingSafeEqual(links, rechts);
}
/** Liest den Bearer-Token aus dem Authorization-Header. */
export function tokenAusHeader(header: string | null): string | null {
if (!header) return null;
const treffer = /^Bearer\s+(.+)$/i.exec(header.trim());
return treffer ? treffer[1].trim() : null;
}
+727
View File
@@ -0,0 +1,727 @@
import type { Alert, Host, Kunde, Laufzeit, Report } from "./types";
/**
* Beispieldaten für den Prototyp (#002). Bezugsdatum ist HEUTE, damit Ablauffristen
* und Badges realistisch aussehen. In #003 ersetzt die Datenbank diese Datei.
*/
export const HEUTE = new Date("2026-09-17T08:00:00Z");
export const kunden: Kunde[] = [
{
id: "k1",
name: "Brandl Elektrotechnik GmbH",
kundennummer: "K-1001",
ansprechpartner: "Sabine Brandl",
email: "it@brandl-elektro.de",
telefon: "+49 871 998120",
notizen: "Wartungsvertrag bis 12/2027. Rechnungen bitte gesammelt zum Quartalsende.",
standorte: [
{ id: "s1", kundeId: "k1", name: "Zentrale Landshut", ort: "Landshut" },
{ id: "s2", kundeId: "k1", name: "Werk Ergolding", ort: "Ergolding" },
],
},
{
id: "k2",
name: "Huber Logistik AG",
kundennummer: "K-1002",
ansprechpartner: "Markus Huber",
email: "edv@huber-logistik.de",
telefon: "+49 89 4412300",
notizen: null,
standorte: [{ id: "s3", kundeId: "k2", name: "Hauptlager München", ort: "München" }],
},
{
id: "k3",
name: "Lehner Steuerberatung",
kundennummer: "K-1003",
ansprechpartner: "Dr. Eva Lehner",
email: "kanzlei@lehner-stb.de",
telefon: "+49 941 776540",
notizen: "Nur Fernwartung nach Voranmeldung.",
standorte: [{ id: "s4", kundeId: "k3", name: "Kanzlei Regensburg", ort: "Regensburg" }],
},
{
id: "k4",
name: "Systemhaus Nord GmbH",
kundennummer: "K-1004",
ansprechpartner: "Jens Petersen",
email: "service@systemhaus-nord.de",
telefon: "+49 431 220190",
notizen: null,
standorte: [{ id: "s5", kundeId: "k4", name: "Rechenzentrum Kiel", ort: "Kiel" }],
},
];
export const hosts: Host[] = [
{
id: "h1",
hostname: "pve-brandl-01",
fqdn: "pve-brandl-01.brandl.local",
kundeId: "k1",
standortId: "s1",
produkt: "pve",
produktVersion: "pve-manager/8.3.2/dc2b2f4e",
kernel: "6.8.12-4-pve",
serverId: "A1B2C3D4E5F60718",
clusterName: "brandl-cluster",
nodeName: "node01",
ips: ["192.168.10.11", "10.20.0.11"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2388G",
uptimeSekunden: 4_233_600,
paketUpdates: 3,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T03:41:00Z",
subscription: {
key: "pve1c-a6bd21fcb6",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-09-24T00:00:00Z",
nextduedate: "2026-09-24T00:00:00Z",
checktime: "2026-09-17T03:40:00Z",
},
tags: ["produktiv", "cluster"],
notizen: "Hauptvirtualisierung Zentrale.",
},
{
id: "h2",
hostname: "pve-brandl-02",
fqdn: "pve-brandl-02.brandl.local",
kundeId: "k1",
standortId: "s2",
produkt: "pve",
produktVersion: "pve-manager/8.3.2/dc2b2f4e",
kernel: "6.8.12-4-pve",
serverId: "B2C3D4E5F6071829",
clusterName: "brandl-cluster",
nodeName: "node02",
ips: ["192.168.10.12"],
sockets: 2,
cpuModell: "Intel(R) Xeon(R) Silver 4310",
uptimeSekunden: 1_987_200,
paketUpdates: 0,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T03:52:00Z",
subscription: {
key: "pve2c-3f91ad77e2",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 2 CPUs/year",
lizenzierteSockets: 2,
regdate: "2025-10-08T00:00:00Z",
nextduedate: "2026-10-08T00:00:00Z",
checktime: "2026-09-17T03:51:00Z",
},
tags: ["produktiv", "cluster"],
notizen: null,
},
{
id: "h3",
hostname: "pbs-brandl-bak",
fqdn: "pbs-brandl-bak.brandl.local",
kundeId: "k1",
standortId: "s2",
produkt: "pbs",
produktVersion: "proxmox-backup-server/3.2.7",
kernel: "6.8.12-4-pve",
serverId: "C3D4E5F607182A3B",
clusterName: null,
nodeName: null,
ips: ["192.168.10.20"],
sockets: 1,
cpuModell: "AMD Ryzen 7 5700G",
uptimeSekunden: 8_553_600,
paketUpdates: 7,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T04:10:00Z",
subscription: {
key: "pbsc-9d41be5507",
status: "active",
level: "c",
produktname: "Proxmox Backup Server Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2026-03-02T00:00:00Z",
nextduedate: "2027-03-02T00:00:00Z",
checktime: "2026-09-17T04:09:00Z",
},
tags: ["backup"],
notizen: null,
},
{
id: "h4",
hostname: "pve-huber-01",
fqdn: "pve-huber-01.huber.intern",
kundeId: "k2",
standortId: "s3",
produkt: "pve",
produktVersion: "pve-manager/8.2.4/faa83925",
kernel: "6.8.4-2-pve",
serverId: "D4E5F607182A3B4C",
clusterName: null,
nodeName: null,
ips: ["10.8.0.5", "192.168.30.5"],
sockets: 2,
cpuModell: "Intel(R) Xeon(R) Gold 5318Y",
uptimeSekunden: 15_120_000,
paketUpdates: 24,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T02:15:00Z",
subscription: {
key: "pve1c-77c0ea1b93",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-12-01T00:00:00Z",
nextduedate: "2026-12-01T00:00:00Z",
checktime: "2026-09-17T02:14:00Z",
},
tags: ["produktiv"],
notizen: "Zweiter Sockel im Sommer nachgerüstet.",
},
{
id: "h5",
hostname: "pve-huber-02",
fqdn: "pve-huber-02.huber.intern",
kundeId: "k2",
standortId: "s3",
produkt: "pve",
produktVersion: "pve-manager/8.2.4/faa83925",
kernel: "6.8.4-2-pve",
serverId: "E5F607182A3B4C5D",
clusterName: null,
nodeName: null,
ips: ["192.168.30.6"],
sockets: 1,
cpuModell: "Intel(R) Core(TM) i7-13700",
uptimeSekunden: 604_800,
paketUpdates: 41,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T02:22:00Z",
subscription: {
key: null,
status: "notfound",
level: null,
produktname: null,
lizenzierteSockets: null,
regdate: null,
nextduedate: null,
checktime: "2026-09-17T02:21:00Z",
},
tags: ["test"],
notizen: "Testumgebung, Lizenzierung noch offen.",
},
{
id: "h6",
hostname: "pmg-lehner-mail",
fqdn: "mail.lehner-stb.de",
kundeId: "k3",
standortId: "s4",
produkt: "pmg",
produktVersion: "pmg/8.1.2",
kernel: "6.8.12-2-pve",
serverId: "F607182A3B4C5D6E",
clusterName: null,
nodeName: null,
ips: ["81.21.44.18", "192.168.5.10"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2124",
uptimeSekunden: 20_736_000,
paketUpdates: 2,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T01:30:00Z",
subscription: {
key: "pmgc-2be0f41a88",
status: "active",
level: "c",
produktname: "Proxmox Mail Gateway Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-11-15T00:00:00Z",
nextduedate: "2026-11-15T00:00:00Z",
checktime: "2026-09-17T01:29:00Z",
},
tags: ["mail"],
notizen: null,
},
{
id: "h7",
hostname: "pve-lehner-01",
fqdn: "pve-lehner-01.lehner.local",
kundeId: "k3",
standortId: "s4",
produkt: "pve",
produktVersion: "pve-manager/8.1.10/4b06efb5",
kernel: "6.5.13-5-pve",
serverId: "07182A3B4C5D6E7F",
clusterName: null,
nodeName: null,
ips: ["192.168.5.20"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2226G",
uptimeSekunden: 3_110_400,
paketUpdates: 63,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T01:45:00Z",
subscription: {
key: "pve1c-11ffa3c209",
status: "expired",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2024-08-30T00:00:00Z",
nextduedate: "2026-08-30T00:00:00Z",
checktime: "2026-09-17T01:44:00Z",
},
tags: ["produktiv"],
notizen: "Verlängerung mit Kundin besprochen, Bestellung offen.",
},
{
id: "h8",
hostname: "pve-nord-01",
fqdn: "pve-nord-01.nord.intern",
kundeId: "k4",
standortId: "s5",
produkt: "pve",
produktVersion: "pve-manager/9.0.3/ab12cd34",
kernel: "6.14.5-1-pve",
serverId: "182A3B4C5D6E7F80",
clusterName: "nord-cluster",
nodeName: "kiel01",
ips: ["172.16.4.11"],
sockets: 2,
cpuModell: "AMD EPYC 7313P",
uptimeSekunden: 11_059_200,
paketUpdates: null,
agentVersion: "0.1.0",
letzterReport: "2026-09-14T03:12:00Z",
subscription: {
key: "pve2c-85d37ae041",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 2 CPUs/year",
lizenzierteSockets: 2,
regdate: "2026-01-20T00:00:00Z",
nextduedate: "2027-01-20T00:00:00Z",
checktime: "2026-09-14T03:11:00Z",
},
tags: ["produktiv", "cluster"],
notizen: null,
},
{
id: "h9",
hostname: "pve-demo-neu",
fqdn: null,
kundeId: null,
standortId: null,
produkt: "pve",
produktVersion: "pve-manager/9.0.3/ab12cd34",
kernel: "6.14.5-1-pve",
serverId: "2A3B4C5D6E7F8091",
clusterName: null,
nodeName: null,
ips: ["203.0.113.44"],
sockets: 1,
cpuModell: "Intel(R) Core(TM) i5-14500",
uptimeSekunden: 86_400,
paketUpdates: 12,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T06:05:00Z",
subscription: {
key: "pve1c-5c72db90aa",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2026-06-05T00:00:00Z",
nextduedate: "2027-06-05T00:00:00Z",
checktime: "2026-09-17T06:04:00Z",
},
tags: [],
notizen: null,
},
];
export const laufzeiten: Laufzeit[] = [
{
id: "l1",
hostId: "h1",
key: "pve1c-a6bd21fcb6",
start: "2025-09-24",
ende: "2026-09-23",
vorschlag: false,
einkauf: {
nummer: "PX-2025-88213",
datum: "2025-09-22",
faelligAm: "2025-10-06",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-09-30",
pdfDateiname: "proxmox-PX-2025-88213.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0412",
datum: "2025-10-01",
nettoBetrag: 165,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-10-14",
pdfDateiname: "RE-2025-0412.pdf",
},
},
{
id: "l2",
hostId: "h1",
key: "pve1c-a6bd21fcb6",
start: "2026-09-24",
ende: "2027-09-23",
vorschlag: false,
einkauf: {
nummer: "PX-2026-14790",
datum: "2026-09-10",
faelligAm: "2026-09-24",
nettoBetrag: 115,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: false,
bezahltAm: null,
pdfDateiname: "proxmox-PX-2026-14790.pdf",
},
verkauf: null,
},
{
id: "l3",
hostId: "h2",
key: "pve2c-3f91ad77e2",
start: "2025-10-08",
ende: "2026-10-07",
vorschlag: false,
einkauf: {
nummer: "PX-2025-88213",
datum: "2025-09-22",
faelligAm: "2025-10-06",
nettoBetrag: 220,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-09-30",
pdfDateiname: "proxmox-PX-2025-88213.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0412",
datum: "2025-10-01",
nettoBetrag: 320,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-10-14",
pdfDateiname: "RE-2025-0412.pdf",
},
},
{
id: "l4",
hostId: "h2",
key: "pve2c-3f91ad77e2",
start: "2026-10-08",
ende: "2027-10-07",
vorschlag: true,
einkauf: null,
verkauf: null,
},
{
id: "l5",
hostId: "h3",
key: "pbsc-9d41be5507",
start: "2026-03-02",
ende: "2027-03-01",
vorschlag: false,
einkauf: {
nummer: "PX-2026-09114",
datum: "2026-02-26",
faelligAm: "2026-03-12",
nettoBetrag: 95,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Kreditkarte",
bezahlt: true,
bezahltAm: "2026-02-26",
pdfDateiname: "proxmox-PX-2026-09114.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2026-0188",
datum: "2026-03-03",
nettoBetrag: 140,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2026-03-20",
pdfDateiname: "RE-2026-0188.pdf",
},
},
{
id: "l6",
hostId: "h4",
key: "pve1c-77c0ea1b93",
start: "2025-12-01",
ende: "2026-11-30",
vorschlag: false,
einkauf: {
nummer: "PX-2025-91002",
datum: "2025-11-27",
faelligAm: "2025-12-11",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-12-02",
pdfDateiname: null,
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0501",
datum: "2025-12-03",
nettoBetrag: 170,
ustSatz: 19,
bezahlt: false,
bezahltAm: null,
pdfDateiname: "RE-2025-0501.pdf",
},
},
{
id: "l7",
hostId: "h6",
key: "pmgc-2be0f41a88",
start: "2025-11-15",
ende: "2026-11-14",
vorschlag: false,
einkauf: {
nummer: "PX-2025-90455",
datum: "2025-11-11",
faelligAm: "2025-11-25",
nettoBetrag: 90,
ustSatz: 19,
rabatt: 5,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-11-18",
pdfDateiname: "proxmox-PX-2025-90455.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0489",
datum: "2025-11-20",
nettoBetrag: 135,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-12-01",
pdfDateiname: "RE-2025-0489.pdf",
},
},
{
id: "l8",
hostId: "h7",
key: "pve1c-11ffa3c209",
start: "2025-08-30",
ende: "2026-08-29",
vorschlag: false,
einkauf: {
nummer: "PX-2025-85110",
datum: "2025-08-26",
faelligAm: "2025-09-09",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-08-28",
pdfDateiname: "proxmox-PX-2025-85110.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0377",
datum: "2025-09-01",
nettoBetrag: 165,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-09-15",
pdfDateiname: "RE-2025-0377.pdf",
},
},
{
id: "l9",
hostId: "h8",
key: "pve2c-85d37ae041",
start: "2026-01-20",
ende: "2027-01-19",
vorschlag: false,
einkauf: {
nummer: "PX-2026-03998",
datum: "2026-01-16",
faelligAm: "2026-01-30",
nettoBetrag: 220,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2026-01-19",
pdfDateiname: "proxmox-PX-2026-03998.pdf",
},
verkauf: {
erstellt: false,
nummer: null,
datum: null,
nettoBetrag: null,
ustSatz: 19,
bezahlt: false,
bezahltAm: null,
pdfDateiname: null,
},
},
{
id: "l10",
hostId: "h9",
key: "pve1c-5c72db90aa",
start: "2026-06-05",
ende: "2027-06-04",
vorschlag: false,
einkauf: {
nummer: "PX-2026-11233",
datum: "2026-06-02",
faelligAm: "2026-06-16",
nettoBetrag: 115,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2026-06-04",
pdfDateiname: null,
},
verkauf: null,
},
];
export const alerts: Alert[] = [
{
id: "a1",
typ: "ablauf-7",
titel: "Subscription läuft in 7 Tagen ab",
beschreibung: "pve-brandl-01 · Brandl Elektrotechnik GmbH · 24.09.2026",
schwere: "gefahr",
hostId: "h1",
kundeId: "k1",
laufzeitId: "l2",
seit: "2026-09-17T06:00:00Z",
erledigt: false,
},
{
id: "a2",
typ: "status",
titel: "Subscription abgelaufen",
beschreibung: "pve-lehner-01 · Lehner Steuerberatung · Status expired seit 30.08.2026",
schwere: "gefahr",
hostId: "h7",
kundeId: "k3",
laufzeitId: null,
seit: "2026-08-30T04:00:00Z",
erledigt: false,
},
{
id: "a3",
typ: "sockets",
titel: "Lizenzierte Sockets weichen ab",
beschreibung: "pve-huber-01 · 1 lizenziert, 2 physisch vorhanden",
schwere: "warnung",
hostId: "h4",
kundeId: "k2",
laufzeitId: null,
seit: "2026-09-02T03:00:00Z",
erledigt: false,
},
{
id: "a4",
typ: "keine-meldung",
titel: "Host meldet sich nicht",
beschreibung: "pve-nord-01 · letzte Meldung am 14.09.2026",
schwere: "warnung",
hostId: "h8",
kundeId: "k4",
laufzeitId: null,
seit: "2026-09-16T03:12:00Z",
erledigt: false,
},
{
id: "a5",
typ: "ohne-kunde",
titel: "Host ist keinem Kunden zugewiesen",
beschreibung: "pve-demo-neu · seit heute registriert",
schwere: "info",
hostId: "h9",
kundeId: null,
laufzeitId: null,
seit: "2026-09-17T06:05:00Z",
erledigt: false,
},
{
id: "a6",
typ: "rechnung-offen",
titel: "Proxmox-Rechnung fällig",
beschreibung: "PX-2026-14790 · 115,00 € · fällig am 24.09.2026",
schwere: "warnung",
hostId: "h1",
kundeId: "k1",
laufzeitId: "l2",
seit: "2026-09-17T06:00:00Z",
erledigt: false,
},
{
id: "a7",
typ: "kundenrechnung-fehlt",
titel: "Kundenrechnung noch nicht erstellt",
beschreibung: "pve-nord-01 · Systemhaus Nord GmbH · Einkauf bezahlt am 19.01.2026",
schwere: "warnung",
hostId: "h8",
kundeId: "k4",
laufzeitId: "l9",
seit: "2026-01-19T08:00:00Z",
erledigt: false,
},
{
id: "a8",
typ: "kundenrechnung-unbezahlt",
titel: "Kundenrechnung seit über 30 Tagen offen",
beschreibung: "RE-2025-0501 · Huber Logistik AG · 170,00 € netto",
schwere: "warnung",
hostId: "h4",
kundeId: "k2",
laufzeitId: "l6",
seit: "2026-01-05T08:00:00Z",
erledigt: false,
},
];
/** Report-Historie (gekürzt) für die Host-Detailansicht. */
export const reports: Report[] = hosts.flatMap((host) =>
Array.from({ length: 6 }, (_, i) => {
const zeitpunkt = new Date(
new Date(host.letzterReport ?? HEUTE).getTime() - i * 24 * 60 * 60 * 1000,
).toISOString();
return {
id: `${host.id}-r${i}`,
hostId: host.id,
zeitpunkt,
status: host.subscription.status,
key: host.subscription.key,
nextduedate: host.subscription.nextduedate,
agentVersion: host.agentVersion,
paketUpdates: host.paketUpdates === null ? null : Math.max(0, host.paketUpdates - i),
};
}),
);
+218
View File
@@ -0,0 +1,218 @@
import { stundenSeit, tageBis } from "@/lib/format";
import { HEUTE, alerts, hosts, kunden, laufzeiten, reports } from "./data";
import type { Host, Kunde, Laufzeit } from "./types";
export const MELDUNG_FRIST_STUNDEN = 48;
export const ABLAUF_FENSTER_TAGE = 30;
export function getKunde(id: string | null): Kunde | undefined {
if (!id) return undefined;
return kunden.find((k) => k.id === id);
}
export function getHost(id: string): Host | undefined {
return hosts.find((h) => h.id === id);
}
export function hostsVonKunde(kundeId: string): Host[] {
return hosts.filter((h) => h.kundeId === kundeId);
}
export function laufzeitenVonHost(hostId: string): Laufzeit[] {
return laufzeiten.filter((l) => l.hostId === hostId).sort((a, b) => b.start.localeCompare(a.start));
}
export function laufzeitenVonKunde(kundeId: string): Laufzeit[] {
const hostIds = new Set(hostsVonKunde(kundeId).map((h) => h.id));
return laufzeiten.filter((l) => hostIds.has(l.hostId)).sort((a, b) => b.start.localeCompare(a.start));
}
/**
* Laufzeit, die zum gemeldeten Ablaufdatum gehört: bevorzugt die Verlängerung, die am
* `nextduedate` beginnt, sonst die laufende Laufzeit. Für das Dashboard ist die Verlängerung
* der interessante Fall, weil deren Rechnungen noch offen sind.
*/
export function laufzeitZuAblauf(host: Host, jetzt: Date = HEUTE): Laufzeit | undefined {
const eigene = laufzeitenVonHost(host.id).filter((l) => !l.vorschlag);
const ablauf = host.subscription.nextduedate?.slice(0, 10);
return eigene.find((l) => l.start === ablauf) ?? aktuelleLaufzeit(host.id, jetzt);
}
/** Laufende bzw. zuletzt gekaufte Laufzeit eines Hosts. */
export function aktuelleLaufzeit(hostId: string, jetzt: Date = HEUTE): Laufzeit | undefined {
const eigene = laufzeitenVonHost(hostId).filter((l) => !l.vorschlag);
const heuteIso = jetzt.toISOString().slice(0, 10);
return eigene.find((l) => l.start <= heuteIso && l.ende >= heuteIso) ?? eigene[0];
}
export function reportsVonHost(hostId: string) {
return reports.filter((r) => r.hostId === hostId).sort((a, b) => b.zeitpunkt.localeCompare(a.zeitpunkt));
}
export function alertsVonHost(hostId: string) {
return alerts.filter((a) => a.hostId === hostId && !a.erledigt);
}
export function alertsVonKunde(kundeId: string) {
return alerts.filter((a) => a.kundeId === kundeId && !a.erledigt);
}
export function offeneAlerts() {
return alerts.filter((a) => !a.erledigt);
}
export function hostMeldetNicht(host: Host, jetzt: Date = HEUTE): boolean {
const stunden = stundenSeit(host.letzterReport, jetzt);
return stunden === null || stunden > MELDUNG_FRIST_STUNDEN;
}
export function nichtZugeordneteHosts(): Host[] {
return hosts.filter((h) => h.kundeId === null);
}
/** Offene Punkte einer Laufzeit: Proxmox-Rechnung unbezahlt bzw. Kundenrechnung fehlt. */
export function offenePunkte(laufzeit: Laufzeit): { einkaufOffen: boolean; verkaufOffen: boolean } {
const einkaufOffen = laufzeit.einkauf !== null && !laufzeit.einkauf.bezahlt;
const verkaufOffen = laufzeit.verkauf === null || !laufzeit.verkauf.erstellt;
return { einkaufOffen, verkaufOffen };
}
export function istAbgeschlossen(laufzeit: Laufzeit): boolean {
const { einkaufOffen, verkaufOffen } = offenePunkte(laufzeit);
return !einkaufOffen && !verkaufOffen;
}
/** Laufzeiten, für die noch etwas zu tun ist (Vorschläge zählen ebenfalls). */
export function offeneRechnungsLaufzeiten(): Laufzeit[] {
return laufzeiten.filter((l) => !l.vorschlag && !istAbgeschlossen(l));
}
export function dashboardKennzahlen(jetzt: Date = HEUTE) {
const aktiv = hosts.filter((h) => h.subscription.status === "active");
const bald = hosts.filter((h) => {
const tage = tageBis(h.subscription.nextduedate, jetzt);
return tage !== null && tage >= 0 && tage <= ABLAUF_FENSTER_TAGE;
});
const offeneEinkaufsrechnungen = laufzeiten.filter((l) => offenePunkte(l).einkaufOffen);
const fehlendeKundenrechnungen = laufzeiten.filter(
(l) => !l.vorschlag && l.einkauf !== null && offenePunkte(l).verkaufOffen,
);
return {
hostsGesamt: hosts.length,
aktivLizenziert: aktiv.length,
ohneSubscription: hosts.filter((h) => h.subscription.status !== "active").length,
baldAblaufend: bald.length,
ohneMeldung: hosts.filter((h) => hostMeldetNicht(h, jetzt)).length,
ohneKunde: nichtZugeordneteHosts().length,
offeneEinkaufsrechnungen: offeneEinkaufsrechnungen.length,
fehlendeKundenrechnungen: fehlendeKundenrechnungen.length,
offeneRechnungspunkte: offeneEinkaufsrechnungen.length + fehlendeKundenrechnungen.length,
offeneAlerts: offeneAlerts().length,
};
}
/** Hosts nach Ablaufdatum der Subscription, ohne Hosts ganz ohne Subscription. */
export function hostsNachAblauf(): Host[] {
return [...hosts]
.filter((h) => h.subscription.nextduedate !== null)
.sort((a, b) => (a.subscription.nextduedate ?? "").localeCompare(b.subscription.nextduedate ?? ""));
}
export function summenVonKunde(kundeId: string) {
const eigene = laufzeitenVonKunde(kundeId);
const einkauf = eigene.reduce((s, l) => s + (l.einkauf?.nettoBetrag ?? 0), 0);
const verkauf = eigene.reduce((s, l) => s + (l.verkauf?.nettoBetrag ?? 0), 0);
return { einkauf, verkauf, marge: verkauf - einkauf };
}
export function summenProJahr(laufzeitenListe: Laufzeit[]) {
const proJahr = new Map<string, { einkauf: number; verkauf: number }>();
for (const l of laufzeitenListe) {
const jahr = l.start.slice(0, 4);
const eintrag = proJahr.get(jahr) ?? { einkauf: 0, verkauf: 0 };
eintrag.einkauf += l.einkauf?.nettoBetrag ?? 0;
eintrag.verkauf += l.verkauf?.nettoBetrag ?? 0;
proJahr.set(jahr, eintrag);
}
return [...proJahr.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([jahr, werte]) => ({ jahr, ...werte, marge: werte.verkauf - werte.einkauf }));
}
/** Fortschritt „erledigte Rechnungspunkte“ je Kunde für die Dashboard-Card. */
export function rechnungsFortschrittProKunde() {
return kunden.map((kunde) => {
const eigene = laufzeitenVonKunde(kunde.id).filter((l) => !l.vorschlag);
const gesamt = eigene.length * 2;
const erledigt = eigene.reduce((summe, l) => {
const { einkaufOffen, verkaufOffen } = offenePunkte(l);
return summe + (einkaufOffen ? 0 : 1) + (verkaufOffen ? 0 : 1);
}, 0);
return {
kunde,
gesamt,
erledigt,
prozent: gesamt === 0 ? 100 : Math.round((erledigt / gesamt) * 100),
};
});
}
export function kundenUebersicht(jetzt: Date = HEUTE) {
return kunden.map((kunde) => {
const eigeneHosts = hostsVonKunde(kunde.id);
const naechsterAblauf = eigeneHosts
.map((h) => h.subscription.nextduedate)
.filter((d): d is string => d !== null)
.sort()[0];
const eigeneLaufzeiten = laufzeitenVonKunde(kunde.id).filter((l) => !l.vorschlag);
const offen = eigeneLaufzeiten.filter((l) => !istAbgeschlossen(l)).length;
return {
kunde,
hosts: eigeneHosts.length,
aktiv: eigeneHosts.filter((h) => h.subscription.status === "active").length,
naechsterAblauf: naechsterAblauf ?? null,
tageBisAblauf: tageBis(naechsterAblauf ?? null, jetzt),
offeneAlerts: alertsVonKunde(kunde.id).length,
offeneRechnungspunkte: offen,
};
});
}
/** Einfache Volltextsuche über Hosts, Kunden und Rechnungsnummern (Strg+K). */
export function suche(begriff: string) {
const q = begriff.trim().toLowerCase();
if (q.length < 2) return { hosts: [], kunden: [], rechnungen: [] };
const trefferHosts = hosts.filter((h) =>
[h.hostname, h.fqdn, h.serverId, h.subscription.key, ...h.ips]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
const trefferKunden = kunden.filter((k) =>
[k.name, k.kundennummer, k.ansprechpartner, k.email]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
const trefferRechnungen = laufzeiten.filter((l) =>
[l.einkauf?.nummer, l.verkauf?.nummer]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
return { hosts: trefferHosts, kunden: trefferKunden, rechnungen: trefferRechnungen };
}
export const PRODUKT_NAME: Record<Host["produkt"], string> = {
pve: "Proxmox VE",
pbs: "Proxmox Backup Server",
pmg: "Proxmox Mail Gateway",
};
export const STATUS_TEXT: Record<Host["subscription"]["status"], string> = {
active: "Aktiv",
notfound: "Keine Subscription",
invalid: "Ungültig",
expired: "Abgelaufen",
suspended: "Ausgesetzt",
};
+63
View File
@@ -0,0 +1,63 @@
import { hosts, kunden, laufzeiten } from "./data";
import { PRODUKT_NAME, getKunde } from "./queries";
export interface SuchEintrag {
typ: "Host" | "Kunde" | "Rechnung";
titel: string;
untertitel: string;
href: string;
/** Alle durchsuchbaren Werte in Kleinschreibung. */
begriffe: string[];
}
/** Flacher Suchindex für die globale Suche (Strg+K) im Prototyp. */
export function suchIndex(): SuchEintrag[] {
const eintraege: SuchEintrag[] = [];
for (const host of hosts) {
const kunde = getKunde(host.kundeId);
eintraege.push({
typ: "Host",
titel: host.hostname,
untertitel: `${PRODUKT_NAME[host.produkt]} · ${kunde?.name ?? "Nicht zugeordnet"}${
host.subscription.key ? ` · ${host.subscription.key}` : ""
}`,
href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subscription.key, ...host.ips]
.filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()),
});
}
for (const kunde of kunden) {
eintraege.push({
typ: "Kunde",
titel: kunde.name,
untertitel: `${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
href: `/kunden/${kunde.id}`,
begriffe: [kunde.name, kunde.kundennummer, kunde.ansprechpartner, kunde.email]
.filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()),
});
}
for (const laufzeit of laufzeiten) {
const host = hosts.find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
for (const [art, rechnung] of [
["Proxmox-Rechnung", laufzeit.einkauf],
["Kundenrechnung", laufzeit.verkauf],
] as const) {
if (!rechnung?.nummer) continue;
eintraege.push({
typ: "Rechnung",
titel: rechnung.nummer,
untertitel: `${art} · ${host?.hostname ?? "?"} · ${kunde?.name ?? "Nicht zugeordnet"}`,
href: `/hosts/${laufzeit.hostId}`,
begriffe: [rechnung.nummer.toLowerCase()],
});
}
}
return eintraege;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Typen für die Beispieldaten des Prototyps (#002).
* Sie bilden das geplante Datenmodell (docs/architektur.md) vereinfacht ab und werden
* in #003 durch die von Prisma erzeugten Typen ersetzt.
*/
export type Produkt = "pve" | "pbs" | "pmg";
export type SubscriptionStatus = "active" | "notfound" | "invalid" | "expired" | "suspended";
export type AlertSchwere = "info" | "warnung" | "gefahr";
export interface Kunde {
id: string;
name: string;
kundennummer: string;
ansprechpartner: string | null;
email: string | null;
telefon: string | null;
notizen: string | null;
standorte: Standort[];
}
export interface Standort {
id: string;
kundeId: string;
name: string;
ort: string | null;
}
export interface Host {
id: string;
hostname: string;
fqdn: string | null;
kundeId: string | null;
standortId: string | null;
produkt: Produkt;
produktVersion: string | null;
kernel: string | null;
serverId: string;
clusterName: string | null;
nodeName: string | null;
ips: string[];
sockets: number | null;
cpuModell: string | null;
uptimeSekunden: number | null;
paketUpdates: number | null;
agentVersion: string;
letzterReport: string | null;
subscription: {
key: string | null;
status: SubscriptionStatus;
level: string | null;
produktname: string | null;
lizenzierteSockets: number | null;
regdate: string | null;
nextduedate: string | null;
checktime: string | null;
};
tags: string[];
notizen: string | null;
}
export interface Rechnung {
nummer: string | null;
datum: string | null;
faelligAm?: string | null;
nettoBetrag: number | null;
ustSatz: number;
rabatt?: number | null;
zahlungsart?: string | null;
bezahlt: boolean;
bezahltAm: string | null;
pdfDateiname: string | null;
}
export interface Laufzeit {
id: string;
hostId: string;
key: string;
start: string;
ende: string;
/** Vom Agent vorgeschlagen und noch nicht bestätigt. */
vorschlag: boolean;
/** Proxmox-Rechnung (Einkauf). */
einkauf: Rechnung | null;
/** Kundenrechnung (Verkauf); `erstellt = false`, solange sie noch fehlt. */
verkauf: (Rechnung & { erstellt: boolean }) | null;
}
export interface Alert {
id: string;
typ: string;
titel: string;
beschreibung: string;
schwere: AlertSchwere;
hostId: string | null;
kundeId: string | null;
laufzeitId: string | null;
seit: string;
erledigt: boolean;
}
export interface Report {
id: string;
hostId: string;
zeitpunkt: string;
status: SubscriptionStatus;
key: string | null;
nextduedate: string | null;
agentVersion: string;
paketUpdates: number | null;
}
+69
View File
@@ -0,0 +1,69 @@
import type { ZodType } from "zod";
import { agentRateLimit, clientIp, enrollRateLimit, type RateLimit } from "@/lib/ratelimit";
import { tokenAusHeader } from "@/lib/tokens";
import { AgentApiFehler } from "./service";
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
export const AGENT_VERSION = "0.2.0";
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
}
/** Wandelt geworfene Fehler in eine JSON-Antwort, ohne interne Details preiszugeben. */
export function fehlerBehandeln(fehler: unknown) {
if (fehler instanceof AgentApiFehler) {
return fehlerAntwort(fehler.status, fehler.code, fehler.message);
}
console.error("Agent-API: unerwarteter Fehler", fehler);
return fehlerAntwort(500, "interner_fehler", "Interner Fehler.");
}
/** Prüft das Rate-Limit für IP und (falls vorhanden) Token. */
export function rateLimitPruefen(
limit: RateLimit,
headers: Headers,
token: string | null,
): Response | null {
const schluessel = token ? `token:${token.slice(0, 16)}` : `ip:${clientIp(headers)}`;
const ergebnis = limit.pruefe(schluessel);
if (ergebnis.erlaubt) return null;
return fehlerAntwort(429, "zu_viele_anfragen", "Zu viele Anfragen.", {
"Retry-After": String(ergebnis.wiederholenNach),
});
}
export { agentRateLimit, enrollRateLimit };
export function bearerToken(request: Request): string | null {
return tokenAusHeader(request.headers.get("authorization"));
}
/** Liest und validiert den JSON-Rumpf; liefert bei Fehlern direkt eine Antwort. */
export async function jsonEingabe<T>(
request: Request,
schema: ZodType<T>,
): Promise<{ daten: T } | { antwort: Response }> {
let rohdaten: unknown;
try {
rohdaten = await request.json();
} catch {
return { antwort: fehlerAntwort(400, "kein_json", "Der Anfragekörper ist kein gültiges JSON.") };
}
const ergebnis = schema.safeParse(rohdaten);
if (!ergebnis.success) {
const felder = ergebnis.error.issues.map((problem) => ({
feld: problem.path.join("."),
meldung: problem.message,
}));
return {
antwort: Response.json(
{ ok: false, code: "validierung", fehler: "Eingabe ist ungültig.", felder },
{ status: 422 },
),
};
}
return { daten: ergebnis.data };
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
import type { Produkt, SubscriptionStatus } from "@/generated/prisma/enums";
/**
* Schemas für die Agent-API. Grundregel aus dem Auftrag: Ein Report darf nie an fehlenden
* Feldern scheitern – alles außer den Metadaten ist `nullable` und darf auch ganz fehlen.
*/
const produkt = z.enum(["pve", "pbs", "pmg"]);
/** Proxmox liefert Datumsangaben als `YYYY-MM-DD`, Zeitstempel teils als Unix-Sekunden. */
const datumWert = z.union([z.string(), z.number()]).nullish();
const nullbarerText = z.string().max(500).nullish();
export const enrollSchema = z.object({
/** Stabile Kennung des Agents, abgeleitet aus /etc/machine-id. */
agentId: z.string().min(8).max(128),
hostname: z.string().min(1).max(255),
fqdn: nullbarerText,
produkt: produkt,
serverId: nullbarerText,
agentVersion: nullbarerText,
});
export const reportSchema = z.object({
/** UUID des Agents; macht nachgesendete Reports idempotent. */
reportId: z.string().min(8).max(64),
agentVersion: nullbarerText,
erhobenAm: z.string().datetime({ offset: true }),
host: z
.object({
hostname: nullbarerText,
fqdn: nullbarerText,
ips: z.array(z.string().max(64)).max(32).nullish(),
clusterName: nullbarerText,
nodeName: nullbarerText,
kernel: nullbarerText,
uptimeSekunden: z.number().int().nonnegative().nullish(),
cpuModell: nullbarerText,
sockets: z.number().int().positive().max(64).nullish(),
paketUpdates: z.number().int().nonnegative().nullish(),
})
.partial()
.default({}),
produkt: z
.object({
typ: produkt.nullish(),
version: nullbarerText,
})
.partial()
.default({}),
subscription: z
.object({
key: nullbarerText,
status: nullbarerText,
level: nullbarerText,
productname: nullbarerText,
sockets: z.number().int().nonnegative().max(64).nullish(),
serverid: nullbarerText,
regdate: datumWert,
nextduedate: datumWert,
checktime: datumWert,
})
.partial()
.default({}),
});
export type EnrollEingabe = z.infer<typeof enrollSchema>;
export type ReportEingabe = z.infer<typeof reportSchema>;
const STATUS_ABBILDUNG: Record<string, SubscriptionStatus> = {
active: "ACTIVE",
new: "ACTIVE",
notfound: "NOTFOUND",
invalid: "INVALID",
expired: "EXPIRED",
suspended: "SUSPENDED",
};
/** Unbekannte Statuswerte werden zu UNKNOWN, damit ein Report nie abgelehnt wird. */
export function statusAusProxmox(wert: string | null | undefined): SubscriptionStatus {
if (!wert) return "UNKNOWN";
return STATUS_ABBILDUNG[wert.trim().toLowerCase()] ?? "UNKNOWN";
}
export function produktAusEingabe(wert: "pve" | "pbs" | "pmg" | null | undefined): Produkt | null {
if (!wert) return null;
return wert === "pve" ? "PVE" : wert === "pbs" ? "PBS" : "PMG";
}
/**
* Wandelt Datumsangaben von Proxmox um: `YYYY-MM-DD`, ISO-Zeitstempel oder Unix-Sekunden.
* Ungültige Werte ergeben `null` statt eines Fehlers.
*/
export function datumAusProxmox(wert: string | number | null | undefined): Date | null {
if (wert === null || wert === undefined || wert === "") return null;
if (typeof wert === "number") {
const ausSekunden = new Date(wert * 1000);
return Number.isNaN(ausSekunden.getTime()) ? null : ausSekunden;
}
const text = wert.trim();
if (/^\d+$/.test(text)) return datumAusProxmox(Number(text));
const datum = /^\d{4}-\d{2}-\d{2}$/.test(text) ? new Date(`${text}T00:00:00Z`) : new Date(text);
return Number.isNaN(datum.getTime()) ? null : datum;
}
+266
View File
@@ -0,0 +1,266 @@
import type { HostModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { erzeugeToken, hashToken } from "@/lib/tokens";
import {
datumAusProxmox,
produktAusEingabe,
statusAusProxmox,
type EnrollEingabe,
type ReportEingabe,
} from "./schema";
export class AgentApiFehler extends Error {
constructor(
readonly status: number,
readonly code: string,
nachricht: string,
) {
super(nachricht);
}
}
export interface EnrollErgebnis {
hostId: string;
token: string;
/** Wahr, wenn die Server-ID bereits zu einem anderen Host gehörte (Neuinstallation oder Klon). */
serverIdBereitsBekannt: boolean;
/** Wahr, wenn sich ein bereits registrierter Agent erneut meldet (z. B. nach Neuinstallation des Agents). */
bestehenderHost: boolean;
}
/** Registriert einen Agent und gibt ihm einen host-spezifischen Token. */
export async function registriereAgent(
eingabe: EnrollEingabe,
enrollmentTokenKlartext: string,
): Promise<EnrollErgebnis> {
const hash = hashToken(enrollmentTokenKlartext);
const enrollToken = await prisma.enrollmentToken.findUnique({ where: { tokenHash: hash } });
if (!enrollToken || enrollToken.widerrufenAm) {
throw new AgentApiFehler(401, "token_ungueltig", "Enrollment-Token ist ungültig oder widerrufen.");
}
if (enrollToken.gueltigBis && enrollToken.gueltigBis.getTime() < Date.now()) {
throw new AgentApiFehler(401, "token_abgelaufen", "Enrollment-Token ist abgelaufen.");
}
if (enrollToken.maxNutzungen !== null && enrollToken.nutzungen >= enrollToken.maxNutzungen) {
throw new AgentApiFehler(401, "token_aufgebraucht", "Enrollment-Token wurde bereits zu oft verwendet.");
}
const produkt = produktAusEingabe(eingabe.produkt);
if (!produkt) {
throw new AgentApiFehler(422, "produkt_unbekannt", "Unbekanntes Produkt.");
}
const vorhanden = await prisma.host.findUnique({ where: { agentId: eingabe.agentId } });
const fremderHostMitServerId = eingabe.serverId
? await prisma.host.findFirst({
where: { serverId: eingabe.serverId, NOT: { agentId: eingabe.agentId } },
})
: null;
const neuerToken = erzeugeToken("host");
const hostId = await prisma.$transaction(async (tx) => {
const host = vorhanden
? await tx.host.update({
where: { id: vorhanden.id },
data: {
hostname: eingabe.hostname,
fqdn: eingabe.fqdn ?? vorhanden.fqdn,
produkt,
agentVersion: eingabe.agentVersion ?? vorhanden.agentVersion,
// Eine Kundenzuordnung aus dem Enrollment-Token nur ergänzen, nie überschreiben.
kundeId: vorhanden.kundeId ?? enrollToken.kundeId,
standortId: vorhanden.standortId ?? enrollToken.standortId,
},
})
: await tx.host.create({
data: {
agentId: eingabe.agentId,
hostname: eingabe.hostname,
fqdn: eingabe.fqdn ?? null,
produkt,
serverId: eingabe.serverId ?? null,
agentVersion: eingabe.agentVersion ?? null,
kundeId: enrollToken.kundeId,
standortId: enrollToken.standortId,
},
});
await tx.agentToken.create({
data: {
hostId: host.id,
tokenHash: neuerToken.hash,
praefix: neuerToken.praefix,
bezeichnung: `Registrierung ${eingabe.hostname}`,
},
});
await tx.enrollmentToken.update({
where: { id: enrollToken.id },
data: { nutzungen: { increment: 1 } },
});
return host.id;
});
return {
hostId,
token: neuerToken.klartext,
serverIdBereitsBekannt: fremderHostMitServerId !== null,
bestehenderHost: vorhanden !== null,
};
}
/** Prüft den Host-Token und liefert den zugehörigen Host. */
export async function authentifiziereHost(tokenKlartext: string): Promise<HostModel> {
const eintrag = await prisma.agentToken.findUnique({
where: { tokenHash: hashToken(tokenKlartext) },
include: { host: true },
});
if (!eintrag || eintrag.widerrufenAm) {
throw new AgentApiFehler(401, "token_ungueltig", "Host-Token ist ungültig oder widerrufen.");
}
await prisma.agentToken.update({
where: { id: eintrag.id },
data: { letzteNutzungAm: new Date() },
});
return eintrag.host;
}
export interface ReportErgebnis {
reportId: string;
/** Wahr, wenn derselbe Report bereits verarbeitet wurde (nachgesendeter Report). */
doppelt: boolean;
laufzeitVorschlagAngelegt: boolean;
}
/** Nimmt einen Report entgegen, aktualisiert den Host und schlägt bei Bedarf eine neue Laufzeit vor. */
export async function verarbeiteReport(
host: HostModel,
eingabe: ReportEingabe,
quellIp: string | null,
): Promise<ReportErgebnis> {
const bereitsVorhanden = await prisma.report.findUnique({ where: { reportId: eingabe.reportId } });
if (bereitsVorhanden) {
return { reportId: bereitsVorhanden.id, doppelt: true, laufzeitVorschlagAngelegt: false };
}
const erhobenAm = new Date(eingabe.erhobenAm);
const status = statusAusProxmox(eingabe.subscription.status);
const nextduedate = datumAusProxmox(eingabe.subscription.nextduedate);
const regdate = datumAusProxmox(eingabe.subscription.regdate);
const checktime = datumAusProxmox(eingabe.subscription.checktime);
const key = eingabe.subscription.key ?? null;
const serverId = eingabe.subscription.serverid ?? null;
const produkt = produktAusEingabe(eingabe.produkt.typ) ?? host.produkt;
// Nachgesendete ältere Reports werden gespeichert, dürfen den aktuellen Stand aber nicht überschreiben.
const istNeuesterStand = !host.letzterReportAm || erhobenAm.getTime() >= host.letzterReportAm.getTime();
return prisma.$transaction(async (tx) => {
const report = await tx.report.create({
data: {
reportId: eingabe.reportId,
hostId: host.id,
erhobenAm,
subStatus: status,
subKey: key,
subNextduedate: nextduedate,
serverId,
hostname: eingabe.host.hostname ?? null,
produktVersion: eingabe.produkt.version ?? null,
kernel: eingabe.host.kernel ?? null,
sockets: eingabe.host.sockets ?? null,
paketUpdates: eingabe.host.paketUpdates ?? null,
agentVersion: eingabe.agentVersion ?? null,
ips: eingabe.host.ips ?? [],
quellIp,
rohdaten: eingabe,
},
});
if (istNeuesterStand) {
await tx.host.update({
where: { id: host.id },
data: {
hostname: eingabe.host.hostname ?? host.hostname,
fqdn: eingabe.host.fqdn ?? host.fqdn,
produkt,
produktVersion: eingabe.produkt.version ?? host.produktVersion,
kernel: eingabe.host.kernel ?? host.kernel,
serverId: serverId ?? host.serverId,
clusterName: eingabe.host.clusterName ?? host.clusterName,
nodeName: eingabe.host.nodeName ?? host.nodeName,
ips: eingabe.host.ips ?? host.ips,
sockets: eingabe.host.sockets ?? host.sockets,
cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
uptimeSekunden:
eingabe.host.uptimeSekunden === null || eingabe.host.uptimeSekunden === undefined
? host.uptimeSekunden
: BigInt(eingabe.host.uptimeSekunden),
paketUpdates: eingabe.host.paketUpdates ?? host.paketUpdates,
agentVersion: eingabe.agentVersion ?? host.agentVersion,
subKey: key,
subStatus: status,
subLevel: eingabe.subscription.level ?? null,
subProduktname: eingabe.subscription.productname ?? null,
subLizenzierteSockets: eingabe.subscription.sockets ?? null,
subRegdate: regdate,
subNextduedate: nextduedate,
subChecktime: checktime,
letzterReportAm: erhobenAm,
},
});
}
if (key) {
await tx.subscriptionKeyHistorie.upsert({
where: { hostId_key: { hostId: host.id, key } },
update: { letzteMeldung: erhobenAm },
create: { hostId: host.id, key, ersteMeldung: erhobenAm, letzteMeldung: erhobenAm },
});
}
if (serverId) {
await tx.serverIdHistorie.upsert({
where: { hostId_serverId: { hostId: host.id, serverId } },
update: { letzteMeldung: erhobenAm },
create: { hostId: host.id, serverId, ersteMeldung: erhobenAm, letzteMeldung: erhobenAm },
});
}
let laufzeitVorschlagAngelegt = false;
if (key && nextduedate && istNeuesterStand) {
const vorhanden = await tx.laufzeit.findUnique({
where: { hostId_start: { hostId: host.id, start: nextduedate } },
});
if (!vorhanden) {
await tx.laufzeit.create({
data: {
hostId: host.id,
key,
start: nextduedate,
ende: laufzeitEnde(nextduedate),
status: "VORSCHLAG",
},
});
laufzeitVorschlagAngelegt = true;
}
}
return { reportId: report.id, doppelt: false, laufzeitVorschlagAngelegt };
});
}
/** Eine Subscription-Periode läuft ein Jahr, das Enddatum ist der Tag davor. */
export function laufzeitEnde(start: Date): Date {
const ende = new Date(start);
ende.setUTCFullYear(ende.getUTCFullYear() + 1);
ende.setUTCDate(ende.getUTCDate() - 1);
return ende;
}
+31
View File
@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { AGENT_VERSION } from "./http";
/**
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
* Dashboard-Adresse und Agent-Version ein.
*/
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
const pfad = path.join(process.cwd(), "agent", datei);
const inhalt = await readFile(pfad, "utf8");
return inhalt
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION);
}
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */
export function basisUrl(request: Request): string {
if (process.env.APP_URL) return process.env.APP_URL;
return new URL(request.url).origin;
}
export function skriptAntwort(inhalt: string, dateiname: string) {
return new Response(inhalt, {
headers: {
"Content-Type": "text/x-shellscript; charset=utf-8",
"Content-Disposition": `inline; filename="${dateiname}"`,
"Cache-Control": "no-store",
},
});
}
+371
View File
@@ -0,0 +1,371 @@
import { beforeEach, afterAll, describe, expect, it } from "vitest";
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
import { erzeugeToken, hashToken } from "@/lib/tokens";
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
import { GET as pingGet } from "@/app/api/agent/v1/ping/route";
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Legt einen Enrollment-Token an und gibt den Klartext zurück. */
async function enrollmentTokenAnlegen(
optionen: { gueltigBis?: Date; maxNutzungen?: number; kundeId?: string; widerrufen?: boolean } = {},
) {
const token = erzeugeToken("enrollment");
await testPrisma.enrollmentToken.create({
data: {
tokenHash: token.hash,
praefix: token.praefix,
bezeichnung: "Test",
gueltigBis: optionen.gueltigBis ?? null,
maxNutzungen: optionen.maxNutzungen ?? null,
kundeId: optionen.kundeId ?? null,
widerrufenAm: optionen.widerrufen ? new Date() : null,
},
});
return token.klartext;
}
function anfrage(pfad: string, koerper: unknown, token?: string, methode = "POST") {
return new Request(`http://localhost:3000${pfad}`, {
method: methode,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
"x-forwarded-for": `10.0.0.${Math.ceil(Math.random() * 250)}`,
},
body: methode === "GET" ? undefined : JSON.stringify(koerper),
});
}
const ENROLL_EINGABE = {
agentId: "machine-id-abcdef0123456789",
hostname: "pve-test-01",
fqdn: "pve-test-01.example.local",
produkt: "pve" as const,
serverId: "AABBCCDDEEFF0011",
agentVersion: "0.2.0",
};
/** Vollständiger Report, wie ihn der Agent auf einem PVE-Host erzeugt. */
function reportEingabe(überschreibungen: Record<string, unknown> = {}) {
return {
reportId: `report-${Math.random().toString(36).slice(2)}-0001`,
agentVersion: "0.2.0",
erhobenAm: "2026-09-17T03:41:00Z",
host: {
hostname: "pve-test-01",
fqdn: "pve-test-01.example.local",
ips: ["192.168.10.11", "10.20.0.11"],
clusterName: "test-cluster",
nodeName: "node01",
kernel: "6.8.12-4-pve",
uptimeSekunden: 4233600,
cpuModell: "Intel(R) Xeon(R) E-2388G",
sockets: 1,
paketUpdates: 3,
},
produkt: { typ: "pve" as const, version: "pve-manager/8.3.2" },
subscription: {
key: "pve1c-a6bd21fcb6",
status: "active",
level: "c",
productname: "Proxmox VE Community Subscription 1 CPU/year",
sockets: 1,
serverid: "AABBCCDDEEFF0011",
regdate: "2025-09-24",
nextduedate: "2026-09-24",
checktime: 1789610400,
},
...überschreibungen,
};
}
async function registriereTestHost() {
const enrollToken = await enrollmentTokenAnlegen();
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { hostId: string; token: string };
return daten;
}
describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
beforeEach(async () => {
await datenbankLeeren();
agentRateLimit.zuruecksetzen();
enrollRateLimit.zuruecksetzen();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
describe("Registrierung", () => {
it("legt einen Host an und gibt genau einmal einen Token aus", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 3 });
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { ok: boolean; token: string; hostId: string };
expect(antwort.status).toBe(201);
expect(daten.token.startsWith("plx_h_")).toBe(true);
const host = await testPrisma.host.findUnique({
where: { id: daten.hostId },
include: { tokens: true },
});
expect(host?.hostname).toBe("pve-test-01");
expect(host?.produkt).toBe("PVE");
// In der Datenbank darf nur der Hash liegen.
expect(host?.tokens[0].tokenHash).toBe(hashToken(daten.token));
expect(host?.tokens[0].tokenHash).not.toContain(daten.token);
const verbraucht = await testPrisma.enrollmentToken.findFirst();
expect(verbraucht?.nutzungen).toBe(1);
});
it("übernimmt die Kundenzuordnung aus dem Enrollment-Token", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Zuordnung GmbH", kundennummer: "K-8001" },
});
const enrollToken = await enrollmentTokenAnlegen({ kundeId: kunde.id });
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { hostId: string };
const host = await testPrisma.host.findUnique({ where: { id: daten.hostId } });
expect(host?.kundeId).toBe(kunde.id);
});
it("erkennt einen bereits bekannten Agent und legt keinen zweiten Host an", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 });
await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const zweite = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await zweite.json()) as { bestehenderHost: boolean };
expect(zweite.status).toBe(200);
expect(daten.bestehenderHost).toBe(true);
expect(await testPrisma.host.count()).toBe(1);
});
it("meldet eine bereits bekannte Server-ID (Neuinstallation oder Klon)", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 });
await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const antwort = await enrollPost(
anfrage(
"/api/agent/v1/enroll",
{ ...ENROLL_EINGABE, agentId: "machine-id-9999999999999999", hostname: "pve-neu-01" },
enrollToken,
),
);
const daten = (await antwort.json()) as { serverIdBereitsBekannt: boolean };
expect(daten.serverIdBereitsBekannt).toBe(true);
});
it.each([
["fehlendem Token", async () => undefined, 401],
["unbekanntem Token", async () => "plx_e_unbekannt", 401],
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
[
"abgelaufenem Token",
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
401,
],
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen();
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, token));
expect(antwort.status).toBe(status);
expect(await testPrisma.host.count()).toBe(0);
});
it("lehnt unvollständige Eingaben mit 422 ab", async () => {
const enrollToken = await enrollmentTokenAnlegen();
const antwort = await enrollPost(
anfrage("/api/agent/v1/enroll", { hostname: "ohne-agent-id" }, enrollToken),
);
expect(antwort.status).toBe(422);
const daten = (await antwort.json()) as { code: string; felder: { feld: string }[] };
expect(daten.code).toBe("validierung");
expect(daten.felder.some((f) => f.feld === "agentId")).toBe(true);
});
});
describe("Report", () => {
it("übernimmt einen vollständigen Report und schlägt eine Laufzeit vor", async () => {
const { hostId, token } = await registriereTestHost();
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
const daten = (await antwort.json()) as { ok: boolean; laufzeitVorschlag: boolean };
expect(antwort.status).toBe(200);
expect(daten.laufzeitVorschlag).toBe(true);
const host = await testPrisma.host.findUniqueOrThrow({
where: { id: hostId },
include: { keyHistorie: true, serverIdHistorie: true, laufzeiten: true, reports: true },
});
expect(host.subStatus).toBe("ACTIVE");
expect(host.subKey).toBe("pve1c-a6bd21fcb6");
expect(host.subNextduedate?.toISOString().slice(0, 10)).toBe("2026-09-24");
expect(host.ips).toEqual(["192.168.10.11", "10.20.0.11"]);
expect(host.uptimeSekunden).toBe(BigInt(4233600));
expect(host.keyHistorie).toHaveLength(1);
expect(host.serverIdHistorie).toHaveLength(1);
expect(host.reports).toHaveLength(1);
const laufzeit = host.laufzeiten[0];
expect(laufzeit.status).toBe("VORSCHLAG");
expect(laufzeit.start.toISOString().slice(0, 10)).toBe("2026-09-24");
expect(laufzeit.ende.toISOString().slice(0, 10)).toBe("2027-09-23");
});
it("nimmt einen Report an, in dem alle optionalen Felder fehlen", async () => {
const { hostId, token } = await registriereTestHost();
const antwort = await reportPost(
anfrage(
"/api/agent/v1/report",
{ reportId: "nur-pflichtfelder-0001", erhobenAm: "2026-09-17T03:41:00Z" },
token,
),
);
expect(antwort.status).toBe(200);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("UNKNOWN");
expect(host.subKey).toBeNull();
expect(host.hostname).toBe("pve-test-01");
});
it("nimmt Reports mit null-Werten an und behandelt unbekannte Status als UNKNOWN", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
subscription: {
key: null,
status: "irgendwas-neues",
level: null,
productname: null,
sockets: null,
serverid: null,
regdate: null,
nextduedate: null,
checktime: null,
},
}),
token,
),
);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("UNKNOWN");
expect(host.subNextduedate).toBeNull();
});
it("verarbeitet denselben Report nur einmal", async () => {
const { hostId, token } = await registriereTestHost();
const eingabe = reportEingabe();
await reportPost(anfrage("/api/agent/v1/report", eingabe, token));
const zweite = await reportPost(anfrage("/api/agent/v1/report", eingabe, token));
const daten = (await zweite.json()) as { doppelt: boolean };
expect(daten.doppelt).toBe(true);
expect(await testPrisma.report.count({ where: { hostId } })).toBe(1);
expect(await testPrisma.laufzeit.count({ where: { hostId } })).toBe(1);
});
it("lässt einen nachgesendeten älteren Report den aktuellen Stand nicht überschreiben", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
reportId: "alter-report-0001",
erhobenAm: "2026-09-10T03:41:00Z",
subscription: { ...reportEingabe().subscription, status: "expired" },
}),
token,
),
);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("ACTIVE");
expect(await testPrisma.report.count({ where: { hostId } })).toBe(2);
});
it("merkt einen Key-Wechsel in der Historie vor", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
reportId: "neuer-key-0001",
erhobenAm: "2026-09-18T03:41:00Z",
subscription: { ...reportEingabe().subscription, key: "pve1c-neuerkey99" },
}),
token,
),
);
const historie = await testPrisma.subscriptionKeyHistorie.findMany({ where: { hostId } });
expect(historie.map((e) => e.key).sort()).toEqual(["pve1c-a6bd21fcb6", "pve1c-neuerkey99"]);
});
it("weist einen widerrufenen Host-Token ab", async () => {
const { token } = await registriereTestHost();
await testPrisma.agentToken.updateMany({ data: { widerrufenAm: new Date() } });
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
expect(antwort.status).toBe(401);
expect(await testPrisma.report.count()).toBe(0);
});
it("weist einen Report ohne Token ab", async () => {
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe()));
expect(antwort.status).toBe(401);
});
it("lehnt einen Report ohne reportId mit 422 ab", async () => {
const { token } = await registriereTestHost();
const antwort = await reportPost(
anfrage("/api/agent/v1/report", { erhobenAm: "2026-09-17T03:41:00Z" }, token),
);
expect(antwort.status).toBe(422);
});
});
describe("Ping und Rate-Limit", () => {
it("bestätigt einen gültigen Host-Token", async () => {
const { token, hostId } = await registriereTestHost();
const antwort = await pingGet(anfrage("/api/agent/v1/ping", null, token, "GET"));
const daten = (await antwort.json()) as { ok: boolean; hostId: string; hostname: string };
expect(daten.ok).toBe(true);
expect(daten.hostId).toBe(hostId);
expect(daten.hostname).toBe("pve-test-01");
});
it("bremst zu viele Reports mit 429 und Retry-After", async () => {
const { token } = await registriereTestHost();
let letzte: Response | undefined;
for (let i = 0; i < 12; i++) {
letzte = await reportPost(
anfrage("/api/agent/v1/report", reportEingabe({ reportId: `flut-${i}-0001` }), token),
);
}
expect(letzte?.status).toBe(429);
expect(Number(letzte?.headers.get("Retry-After"))).toBeGreaterThan(0);
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { RateLimit } from "@/lib/ratelimit";
import { erzeugeToken, hashGleich, hashToken, tokenAusHeader } from "@/lib/tokens";
import { datumAusProxmox, statusAusProxmox } from "@/server/agent/schema";
describe("Tokens", () => {
it("erzeugt eindeutige Tokens mit erkennbarem Präfix", () => {
const a = erzeugeToken("host");
const b = erzeugeToken("host");
expect(a.klartext.startsWith("plx_h_")).toBe(true);
expect(erzeugeToken("enrollment").klartext.startsWith("plx_e_")).toBe(true);
expect(a.klartext).not.toBe(b.klartext);
expect(a.praefix.length).toBe("plx_h_".length + 6);
});
it("hasht deterministisch und vergleicht ohne Zeitunterschiede", () => {
const token = erzeugeToken("host");
expect(hashToken(token.klartext)).toBe(token.hash);
expect(hashToken(` ${token.klartext} `)).toBe(token.hash);
expect(hashGleich(token.hash, token.hash)).toBe(true);
expect(hashGleich(token.hash, hashToken("etwas anderes"))).toBe(false);
});
it("liest den Bearer-Token aus dem Header", () => {
expect(tokenAusHeader("Bearer plx_h_abc")).toBe("plx_h_abc");
expect(tokenAusHeader("bearer plx_h_abc ")).toBe("plx_h_abc");
expect(tokenAusHeader("Basic abc")).toBeNull();
expect(tokenAusHeader(null)).toBeNull();
});
});
describe("Datums- und Statusumwandlung", () => {
it("versteht die Formate von Proxmox", () => {
expect(datumAusProxmox("2026-09-24")?.toISOString()).toBe("2026-09-24T00:00:00.000Z");
expect(datumAusProxmox(1789610400)?.toISOString()).toBe("2026-09-17T02:00:00.000Z");
expect(datumAusProxmox("1789610400")?.toISOString()).toBe("2026-09-17T02:00:00.000Z");
expect(datumAusProxmox("2026-09-17T03:41:00Z")?.toISOString()).toBe("2026-09-17T03:41:00.000Z");
});
it("liefert null statt eines Fehlers bei unbrauchbaren Werten", () => {
expect(datumAusProxmox(null)).toBeNull();
expect(datumAusProxmox("")).toBeNull();
expect(datumAusProxmox("kein datum")).toBeNull();
expect(datumAusProxmox(undefined)).toBeNull();
});
it("bildet Proxmox-Status ab und fällt sonst auf UNKNOWN zurück", () => {
expect(statusAusProxmox("active")).toBe("ACTIVE");
expect(statusAusProxmox("NotFound")).toBe("NOTFOUND");
expect(statusAusProxmox("expired")).toBe("EXPIRED");
expect(statusAusProxmox("etwas neues")).toBe("UNKNOWN");
expect(statusAusProxmox(null)).toBe("UNKNOWN");
});
});
describe("Rate-Limit", () => {
it("lässt die Kapazität zu und bremst danach", () => {
const limit = new RateLimit(3, 1);
const jetzt = Date.now();
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
const abgelehnt = limit.pruefe("a", jetzt);
expect(abgelehnt.erlaubt).toBe(false);
expect(abgelehnt.wiederholenNach).toBeGreaterThan(0);
// Anderer Schlüssel hat einen eigenen Eimer.
expect(limit.pruefe("b", jetzt).erlaubt).toBe(true);
// Nach einer Sekunde ist wieder ein Token verfügbar.
expect(limit.pruefe("a", jetzt + 1000).erlaubt).toBe(true);
});
});
+195
View File
@@ -0,0 +1,195 @@
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
import { erzeugeToken } from "@/lib/tokens";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const AGENT = path.join(process.cwd(), "agent", "proxledger-agent.sh");
let stubVerzeichnis: string;
/** Legt ein ausführbares Skript an, das einen Proxmox-Befehl nachahmt. */
function befehlErsetzen(name: string, ausgabe: string) {
const datei = path.join(stubVerzeichnis, name);
writeFileSync(datei, `#!/usr/bin/env bash\ncat <<'ENDE'\n${ausgabe}\nENDE\n`);
chmodSync(datei, 0o755);
}
/** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */
function reportErzeugen(): Record<string, never> | Record<string, unknown> {
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
env: {
...process.env,
PATH: `${stubVerzeichnis}:/usr/bin:/bin`,
PROXLEDGER_CONFIG: path.join(stubVerzeichnis, "gibt-es-nicht.conf"),
},
encoding: "utf8",
});
return JSON.parse(ausgabe) as Record<string, unknown>;
}
const PVE_SUBSCRIPTION = `key: pve1c-a6bd21fcb6
status: active
serverid: A1B2C3D4E5F60718
checktime: 1789610400
productname: Proxmox VE Community Subscription 1 CPU/year
regdate: 2025-09-24 00:00:00
nextduedate: 2026-09-24
sockets: 1
level: c`;
describe("Agent-Skript", () => {
beforeEach(() => {
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
mkdirSync(stubVerzeichnis, { recursive: true });
});
afterEach(() => {
rmSync(stubVerzeichnis, { recursive: true, force: true });
});
it("erzeugt aus einer vollständigen PVE-Ausgabe einen gültigen Report", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G");
befehlErsetzen("ip", "1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0");
befehlErsetzen("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]");
const report = reportErzeugen() as {
reportId: string;
agentVersion: string;
erhobenAm: string;
produkt: { typ: string; version: string };
subscription: Record<string, unknown>;
host: Record<string, unknown>;
};
expect(report.agentVersion).toBe("0.2.0");
expect(report.reportId).toMatch(/.{8,}/);
expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" });
expect(report.subscription).toMatchObject({
key: "pve1c-a6bd21fcb6",
status: "active",
level: "c",
sockets: 1,
serverid: "A1B2C3D4E5F60718",
productname: "Proxmox VE Community Subscription 1 CPU/year",
nextduedate: "2026-09-24",
// Die Uhrzeit aus regdate wird abgeschnitten.
regdate: "2025-09-24",
checktime: "1789610400",
});
expect(report.host.sockets).toBe(2);
expect(report.host.cpuModell).toBe("Intel(R) Xeon(R) E-2388G");
expect(report.host.paketUpdates).toBe(2);
expect(report.host.kernel).toBeTypeOf("string");
});
it("liefert auch ohne Proxmox-Befehle gültiges JSON mit null-Werten", () => {
const report = reportErzeugen() as {
produkt: { typ: string | null };
subscription: Record<string, unknown>;
};
expect(report.produkt.typ).toBeNull();
expect(report.subscription.key).toBeNull();
expect(report.subscription.status).toBeNull();
expect(report.subscription.sockets).toBeNull();
});
it("maskiert Sonderzeichen aus Proxmox-Ausgaben korrekt", () => {
befehlErsetzen(
"pvesubscription",
`key: pve1c-test
status: active
productname: Proxmox "VE" \\ Subscription mit Tab`,
);
const report = reportErzeugen() as { subscription: { productname: string } };
expect(report.subscription.productname).toBe('Proxmox "VE" \\ Subscription\tmit Tab');
});
it("verarbeitet eine PBS-Ausgabe im JSON-Format", () => {
befehlErsetzen(
"proxmox-backup-manager",
`{"key":"pbsc-9d41be5507","status":"active","serverid":"C3D4E5F607182A3B","sockets":1,"nextduedate":"2027-03-02"}`,
);
const report = reportErzeugen() as {
produkt: { typ: string };
subscription: Record<string, unknown>;
};
expect(report.produkt.typ).toBe("pbs");
expect(report.subscription).toMatchObject({
key: "pbsc-9d41be5507",
status: "active",
serverid: "C3D4E5F607182A3B",
sockets: 1,
nextduedate: "2027-03-02",
});
});
it("gibt die Version aus", () => {
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
expect(ausgabe).toBe("0.2.0");
});
describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => {
afterAll(async () => {
await testPrisma.$disconnect();
});
it("wird vom Report-Endpunkt unverändert angenommen", async () => {
await datenbankLeeren();
agentRateLimit.zuruecksetzen();
enrollRateLimit.zuruecksetzen();
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
befehlErsetzen("lscpu", "Socket(s): 1\nModel name: Intel(R) Xeon(R) E-2388G");
const enrollToken = erzeugeToken("enrollment");
await testPrisma.enrollmentToken.create({
data: { tokenHash: enrollToken.hash, praefix: enrollToken.praefix, bezeichnung: "Agent-Test" },
});
const enrollAntwort = await enrollPost(
new Request("http://localhost:3000/api/agent/v1/enroll", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${enrollToken.klartext}` },
body: JSON.stringify({
agentId: "machine-id-fuer-agent-test",
hostname: "pve-agent-test",
produkt: "pve",
}),
}),
);
const { token, hostId } = (await enrollAntwort.json()) as { token: string; hostId: string };
const report = reportErzeugen();
const antwort = await reportPost(
new Request("http://localhost:3000/api/agent/v1/report", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(report),
}),
);
expect(antwort.status).toBe(200);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subKey).toBe("pve1c-a6bd21fcb6");
expect(host.subStatus).toBe("ACTIVE");
expect(host.subNextduedate?.toISOString().slice(0, 10)).toBe("2026-09-24");
expect(host.subChecktime?.toISOString()).toBe("2026-09-17T02:00:00.000Z");
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("verteilt eine Proxmox-Sammelrechnung auf mehrere Laufzeiten", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Testkunde GmbH", kundennummer: "K-9001" },
});
const hostDaten = [
{ agentId: "test-1", hostname: "pve-test-01", key: "pve1c-aaaa111111" },
{ agentId: "test-2", hostname: "pve-test-02", key: "pve1c-bbbb222222" },
];
const rechnung = await testPrisma.proxmoxRechnung.create({
data: {
nummer: "PX-TEST-1",
datum: new Date("2026-09-01"),
faelligAm: new Date("2026-09-15"),
nettoBetrag: 230,
bezahlt: false,
positionen: {
create: hostDaten.map((h) => ({ bezeichnung: h.key, nettoBetrag: 115 })),
},
},
include: { positionen: true },
});
for (const [index, daten] of hostDaten.entries()) {
const host = await testPrisma.host.create({
data: {
agentId: daten.agentId,
hostname: daten.hostname,
produkt: "PVE",
kundeId: kunde.id,
subKey: daten.key,
subStatus: "ACTIVE",
subNextduedate: new Date("2027-09-01"),
},
});
await testPrisma.laufzeit.create({
data: {
hostId: host.id,
key: daten.key,
start: new Date("2026-09-01"),
ende: new Date("2027-08-31"),
status: "BESTAETIGT",
proxmoxPositionId: rechnung.positionen[index].id,
},
});
}
const laufzeiten = await testPrisma.laufzeit.findMany({
include: { proxmoxPosition: { include: { rechnung: true } } },
});
expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect(
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
});
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Wegfallend GmbH", kundennummer: "K-9002" },
});
const host = await testPrisma.host.create({
data: { agentId: "test-3", hostname: "pve-test-03", produkt: "PVE", kundeId: kunde.id },
});
await testPrisma.kunde.delete({ where: { id: kunde.id } });
const danach = await testPrisma.host.findUnique({ where: { id: host.id } });
expect(danach?.kundeId).toBeNull();
});
it("verhindert zwei Laufzeiten mit gleichem Start je Host", async () => {
const host = await testPrisma.host.create({
data: { agentId: "test-4", hostname: "pve-test-04", produkt: "PBS" },
});
const daten = {
hostId: host.id,
key: "pbsc-1111111111",
start: new Date("2026-01-01"),
ende: new Date("2026-12-31"),
};
await testPrisma.laufzeit.create({ data: daten });
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
});
})
+34
View File
@@ -0,0 +1,34 @@
import { execFileSync } from "node:child_process";
import { Client } from "pg";
import "dotenv/config";
/**
* Legt die Testdatenbank an (falls nötig) und spielt alle Migrationen ein.
* Ohne `TEST_DATABASE_URL` werden die datenbankgestützten Tests übersprungen.
*/
export default async function setup() {
const url = process.env.TEST_DATABASE_URL;
if (!url) {
console.warn("TEST_DATABASE_URL ist nicht gesetzt – Datenbanktests werden übersprungen.");
return;
}
const ziel = new URL(url);
const datenbank = ziel.pathname.replace(/^\//, "");
const verwaltung = new URL(url);
verwaltung.pathname = "/postgres";
const client = new Client({ connectionString: verwaltung.toString() });
await client.connect();
const vorhanden = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [datenbank]);
if (vorhanden.rowCount === 0) {
// Datenbanknamen lassen sich nicht parametrisieren; er stammt aus der eigenen Konfiguration.
await client.query(`CREATE DATABASE "${datenbank.replace(/"/g, "")}"`);
}
await client.end();
execFileSync("npx", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: url },
stdio: "inherit",
});
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import "dotenv/config";
const url = process.env.TEST_DATABASE_URL;
/** Datenbankgestützte Tests laufen nur, wenn eine Testdatenbank konfiguriert ist. */
export const datenbankVerfuegbar = Boolean(url);
export const testPrisma: PrismaClient = new PrismaClient({
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5432/proxledger_test" }),
});
/** Leert alle Tabellen; Reihenfolge spielt dank CASCADE keine Rolle. */
export async function datenbankLeeren() {
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
`;
if (tabellen.length === 0) return;
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
await testPrisma.$executeRawUnsafe(`TRUNCATE TABLE ${liste} RESTART IDENTITY CASCADE`);
}
+6
View File
@@ -0,0 +1,6 @@
import "dotenv/config";
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
if (process.env.TEST_DATABASE_URL) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
+4
View File
@@ -8,5 +8,9 @@ export default defineConfig({
test: {
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
environment: "node",
globalSetup: ["tests/global-setup.ts"],
setupFiles: ["tests/setup-env.ts"],
// Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen.
fileParallelism: false,
},
});