Compare commits

...
5 Commits
Author SHA1 Message Date
florian.wilde f838a44991 Merge branch 'phase/015-feinschliff' 2026-09-17 23:27:23 +02:00
florian.wildeandClaude Opus 5 4c65c9ddab perf(alerts): Warnungen sammelweise anlegen, Dark-Mode und Mobilansicht verbessern (#015)
Lasttest mit 200 Hosts und 365.000 Reports: Auswertung der Warnregeln von 3813
auf 293 ms. Logo im dunklen Design sichtbar gemacht, Tabellen blenden auf
schmalen Bildschirmen Spalten aus, Aufgaben-Karte begrenzt.

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

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

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+21 -11
View File
@@ -1,26 +1,36 @@
# #014 Deployment und Dokumentation # #014 Deployment und Dokumentation
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.10.0 **Meilenstein:** v0.10.0
**Labels:** deployment, docs **Labels:** deployment, docs
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 40 min
## Ziel ## Ziel
ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben. ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben.
## Aufgaben ## Aufgaben
- [ ] Dockerfile (Multi-Stage, Standalone-Output, Non-root) - [x] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
- [ ] `docker-compose.yml` (web, worker, postgres), `.env.example` - [x] `docker-compose.yml` (web, worker, postgres), `.env.example`
- [ ] Migrationen beim Start, Healthcheck - [x] Migrationen beim Start, Healthcheck
- [ ] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste) - [x] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
- [ ] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik) - [x] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
- [ ] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen - [x] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
- [ ] Backup-/Restore-Anleitung für die Datenbank und Uploads - [x] Backup-/Restore-Anleitung für die Datenbank und Uploads
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard - [x] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
- [ ] LXC-Installer auf PVE 9 erfolgreich - [x] LXC-Installer auf PVE 9 erfolgreich
## Abhängigkeiten ## Abhängigkeiten
#013 #013
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Migrationen als eigener Dienst:** Das Laufzeit-Abbild enthält keine Prisma-CLI mehr. Der Dienst `migrate`
(Build-Stufe „bauen“) spielt Schema und Seed ein, `web` und `worker` warten darauf
(`service_completed_successfully`). Vorher scheiterte der Start an fehlenden CLI-Abhängigkeiten.
- **Worker als ESM-Bündel:** Der erzeugte Prisma-Client nutzt `import.meta.url`, das CommonJS nicht kennt.
Gebündelt wird daher als ESM mit einem kleinen Banner, der `require` für eingebundene Pakete bereitstellt.
Extern bleiben nur `@prisma/client`, `@node-rs/argon2` und `pg-native`.
- Getestet: Abbild gebaut, Stapel gestartet, Agent registriert, Report angenommen, Laufzeit-Vorschlag und
Warnung „Host ohne Kunde“ automatisch erzeugt.
- Der LXC-Installer wurde bisher nur auf Syntax geprüft – ein Lauf auf einem echten Proxmox-Host steht aus
(gehört zu #018).
+45 -10
View File
@@ -1,24 +1,59 @@
# #015 Feinschliff für 1.0 # #015 Feinschliff für 1.0
**Status:** offen **Status:** erledigt
**Meilenstein:** v1.0.0 **Meilenstein:** v0.11.0
**Labels:** ui, sicherheit **Labels:** ui, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min
## Ziel ## Ziel
Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung. Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung.
## Aufgaben ## Aufgaben
- [ ] Dark-Mode vollständig (alle Seiten, PDF bleibt hell) - [x] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
- [ ] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px - [x] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
- [ ] Kontrast- und Tastaturprüfung - [x] Kontrast- und Tastaturprüfung
- [ ] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens) - [x] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
- [ ] Performance-Check mit 200 Hosts / 5 Jahren Reports - [x] Performance-Check mit 200 Hosts / 5 Jahren Reports
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Alle Seiten in Hell und Dunkel lesbar - [x] Alle Seiten in Hell und Dunkel lesbar
- [ ] Keine bekannten kritischen Schwachstellen in Abhängigkeiten - [x] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
## Abhängigkeiten ## Abhängigkeiten
#014 #014
## Notizen / Entscheidungen ## Notizen / Entscheidungen
### Ergebnisse
**Dark-Mode:** Das Logo war auf dunklem Grund praktisch unsichtbar (dunkles P auf dunklem Hintergrund).
Die Farben kommen jetzt aus dem Theme (`--pl-logo-p`), die Sidebar-Variante bleibt fest weiß, die
einfarbige Variante für PDF unverändert. Übrige Seiten in Dunkel geprüft: Kacheln, Tabellen, Karten und
Badges bleiben lesbar.
**Mobil:** Bei 390 px musste man vorher quer scrollen. Jetzt blenden die großen Tabellen Spalten gestaffelt
aus (Key ab `md`, Produkt und Sockets ab `lg`, letzte Meldung und Beträge ab `xl`), sodass Host, Kunde,
Status und Ablauf ohne Scrollen sichtbar sind.
**Lasttest** (200 Hosts, 365.000 Reports, 1.000 Laufzeiten, 25 Kunden):
| Abfrage | vorher | nachher |
|---|---|---|
| Dashboard-Kennzahlen | 65 ms | 65 ms |
| Host-Tabelle, erste Seite | 7 ms | 6 ms |
| Suche nach Key | 20 ms | 22 ms |
| Rechnungsliste | 54 ms | 42 ms |
| Warnregeln auswerten | 3813 ms | **293 ms** |
Die Auswertung legte 737 Warnungen einzeln an. Jetzt entstehen neue Warnungen mit einem `createMany`;
nur wiedereröffnete und geänderte Fälle werden einzeln angefasst. Außerdem zeigt die Aufgaben-Karte auf
dem Dashboard höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste – vorher wären es
600 Einträge auf einer Karte gewesen.
**Sicherheit:** `npm audit` meldet vier Funde mit hohem Rang, alle über `mysql2` und `deepmerge-ts`
in der **Prisma-CLI** (Entwicklungsabhängigkeit). Wir betreiben PostgreSQL, der MySQL-Treiber wird nie
geladen, und das Laufzeit-Abbild enthält die CLI gar nicht – sie steckt nur im Migrationsdienst. Behoben
wäre es erst mit einem Prisma-Downgrade auf 6.x; das wäre der schlechtere Tausch. Beobachten und mit dem
nächsten Prisma-Update erneut prüfen (#019).
**Noch offen für 1.0.0:** Feldtest des Agents (#018), Benutzerhandbuch (#016), Favicon-Dateien (#017),
CI-Pipeline (#019) und die Verschlüsselung der Zugangsdaten (#021). Deshalb wird diese Phase als v0.11.0
veröffentlicht, nicht als 1.0.0.
+3 -3
View File
@@ -17,12 +17,12 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min |
| [#014](014-deployment.md) | Deployment und Dokumentation | in Arbeit | v0.10.0 | 8 h | – | | [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **5 h 23 min** | | | **Summe** | | | **116 h** | **6 h 38 min** |
+11 -1
View File
@@ -7,7 +7,17 @@ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
prettier, prettier,
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]), globalIgnores([
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
"src/generated/**",
// Erzeugte Dateien: das Worker-Bündel und der esbuild-Banner.
"dist/**",
"deploy/worker-banner.js",
]),
]); ]);
export default eslintConfig; export default eslintConfig;
+2
View File
@@ -10,6 +10,8 @@ const SICHERHEITS_KOPFZEILEN = [
]; ];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Für das Container-Abbild: nur die tatsächlich benötigten Dateien mitnehmen.
output: "standalone",
// Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden. // Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden.
outputFileTracingIncludes: { outputFileTracingIncludes: {
"/install.sh": ["./agent/install.sh"], "/install.sh": ["./agent/install.sh"],
+3 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.8.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.8.0", "version": "0.9.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -31,6 +31,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.9.0", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -20,7 +20,10 @@
"postinstall": "prisma generate", "postinstall": "prisma generate",
"issues:sync": "tsx scripts/gitea-issues.ts", "issues:sync": "tsx scripts/gitea-issues.ts",
"worker": "tsx src/worker/index.ts", "worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.ts" "alerts:check": "tsx scripts/alerts-pruefen.ts",
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
"build:all": "npm run build && npm run build:worker",
"lasttest": "tsx scripts/lasttest.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -45,6 +48,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
+166
View File
@@ -0,0 +1,166 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { hostsNachAblauf, offeneAufgaben } from "@/server/queries/dashboard";
import { hostListe } from "@/server/queries/hostliste";
import { kennzahlen, suchIndex } from "@/server/queries/hosts";
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
import { rechnungsListe } from "@/server/queries/rechnungen";
/**
* Lasttest: legt eine große Datenmenge an und misst die Abfragen der wichtigsten Seiten.
* Läuft gegen die Testdatenbank (`TEST_DATABASE_URL`), damit die Entwicklungsdaten unberührt bleiben.
*
* npm run lasttest
*/
const HOSTS = Number(process.env.LAST_HOSTS ?? 200);
const KUNDEN = Number(process.env.LAST_KUNDEN ?? 25);
const JAHRE = Number(process.env.LAST_JAHRE ?? 5);
const REPORTS_PRO_HOST = JAHRE * 365;
const url = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
if (!url) throw new Error("TEST_DATABASE_URL oder DATABASE_URL setzen.");
process.env.DATABASE_URL = url;
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
async function messen<T>(name: string, arbeit: () => Promise<T>): Promise<T> {
const start = performance.now();
const ergebnis = await arbeit();
const dauer = performance.now() - start;
const anzahl = Array.isArray(ergebnis) ? ` (${ergebnis.length} Einträge)` : "";
console.log(` ${name.padEnd(38)} ${dauer.toFixed(0).padStart(6)} ms${anzahl}`);
return ergebnis;
}
async function datenAnlegen() {
console.log(`Lege Testdaten an: ${KUNDEN} Kunden, ${HOSTS} Hosts, je ${REPORTS_PRO_HOST} Reports …`);
const start = performance.now();
await prisma.$executeRawUnsafe(`
TRUNCATE TABLE "Alert","Benachrichtigung","Report","Laufzeit","ProxmoxRechnungPosition",
"ProxmoxRechnung","KundenrechnungPosition","Kundenrechnung","ServerIdHistorie",
"SubscriptionKeyHistorie","AgentToken","Host","Standort","Kunde" RESTART IDENTITY CASCADE
`);
for (let k = 0; k < KUNDEN; k++) {
await prisma.kunde.create({
data: { id: `k${k}`, name: `Kunde ${k} GmbH`, kundennummer: `K-${9000 + k}` },
});
}
const heute = new Date();
for (let h = 0; h < HOSTS; h++) {
const ablauf = new Date(heute.getTime() + ((h % 400) - 30) * 86_400_000);
await prisma.host.create({
data: {
id: `h${h}`,
agentId: `last-${h}`,
hostname: `pve-last-${String(h).padStart(3, "0")}`,
produkt: h % 7 === 0 ? "PBS" : "PVE",
kundeId: h % 20 === 0 ? null : `k${h % KUNDEN}`,
subKey: `pve1c-${String(h).padStart(10, "0")}`,
subStatus: h % 11 === 0 ? "EXPIRED" : "ACTIVE",
subNextduedate: ablauf,
subLizenzierteSockets: 1,
sockets: h % 13 === 0 ? 2 : 1,
ips: [`10.0.${Math.floor(h / 254)}.${h % 254}`],
letzterReportAm: new Date(heute.getTime() - (h % 5) * 86_400_000),
},
});
// Reports in Blöcken, sonst dauert es unnötig lange.
const reports = Array.from({ length: REPORTS_PRO_HOST }, (_, i) => ({
reportId: `last-${h}-${i}`,
hostId: `h${h}`,
erhobenAm: new Date(heute.getTime() - i * 86_400_000),
subStatus: "ACTIVE" as const,
subKey: `pve1c-${String(h).padStart(10, "0")}`,
rohdaten: {},
}));
for (let i = 0; i < reports.length; i += 2000) {
await prisma.report.createMany({ data: reports.slice(i, i + 2000) });
}
// Fünf Laufzeiten je Host, jede zweite mit Rechnungen.
for (let j = 0; j < JAHRE; j++) {
const beginn = new Date(Date.UTC(heute.getUTCFullYear() - j, h % 12, ((h + j) % 27) + 1));
const laufzeit = await prisma.laufzeit.create({
data: {
hostId: `h${h}`,
key: `pve1c-${String(h).padStart(10, "0")}`,
start: beginn,
ende: new Date(Date.UTC(beginn.getUTCFullYear() + 1, beginn.getUTCMonth(), beginn.getUTCDate() - 1)),
status: "BESTAETIGT",
},
});
if ((h + j) % 2 === 0) {
const rechnung = await prisma.proxmoxRechnung.create({
data: {
nummer: `PX-LAST-${h}-${j}`,
datum: beginn,
faelligAm: new Date(beginn.getTime() + 14 * 86_400_000),
nettoBetrag: 115,
bezahlt: j > 0,
positionen: { create: [{ bezeichnung: "Laufzeit", nettoBetrag: 115 }] },
},
include: { positionen: true },
});
await prisma.laufzeit.update({
where: { id: laufzeit.id },
data: { proxmoxPositionId: rechnung.positionen[0].id },
});
}
}
}
console.log(`Daten angelegt in ${((performance.now() - start) / 1000).toFixed(1)} s\n`);
}
async function main() {
await datenAnlegen();
await warnregelnSicherstellen();
const [hosts, reports, laufzeiten] = await Promise.all([
prisma.host.count(),
prisma.report.count(),
prisma.laufzeit.count(),
]);
console.log(`Bestand: ${hosts} Hosts, ${reports} Reports, ${laufzeiten} Laufzeiten\n`);
console.log("Dashboard:");
await messen("Kennzahlen", () => kennzahlen());
await messen("Hosts nach Ablauf", () => hostsNachAblauf());
await messen("Offene Aufgaben", () => offeneAufgaben());
await messen("Kundenliste", () => kundenListe());
await messen("Rechnungspunkte je Kunde", () => offeneRechnungspunkteProKunde());
console.log("\nHost-Tabelle:");
await messen("Erste Seite", () => hostListe({}));
await messen("Suche nach Key", () => hostListe({ q: "pve1c-0000000100" }));
await messen("Filter: bald ablaufend", () => hostListe({ ablauf: "30" }));
await messen("Filter: offene Rechnungen", () => hostListe({ rechnung: "offen" }));
await messen("Sortierung nach Ablauf", () => hostListe({ sortierung: "ablauf" }));
console.log("\nWeitere Seiten:");
await messen("Rechnungsliste (offen)", () => rechnungsListe({ status: "offen" }));
await messen("Suchindex (Strg+K)", () => suchIndex());
await messen("Warnregeln auswerten", () => alertsAbgleichen());
const offeneAlerts = await prisma.alert.count({ where: { erledigtAm: null } });
console.log(`\nOffene Warnungen nach der Auswertung: ${offeneAlerts}`);
}
main()
.then(async () => {
await prisma.$disconnect();
process.exit(0);
})
.catch(async (fehler) => {
console.error(fehler);
await prisma.$disconnect();
process.exit(1);
});
+15 -7
View File
@@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<tr> <tr>
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} /> <SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
<SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} /> <SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Produkt</th> <th scope="col" className="d-none d-lg-table-cell">
Produkt
</th>
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} /> <SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Key</th> <th scope="col" className="d-none d-md-table-cell">
Key
</th>
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} /> <SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Sockets</th> <th scope="col" className="d-none d-lg-table-cell">
Sockets
</th>
<SortierKopf <SortierKopf
titel="Letzte Meldung" titel="Letzte Meldung"
feld="meldung" feld="meldung"
@@ -165,17 +171,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<div className="small text-body-secondary">{host.standort.name}</div> <div className="small text-body-secondary">{host.standort.name}</div>
) : null} ) : null}
</td> </td>
<td>{PRODUKT_TEXT[host.produkt]}</td> <td className="d-none d-lg-table-cell">{PRODUKT_TEXT[host.produkt]}</td>
<td> <td>
<StatusBadge status={host.subStatus} /> <StatusBadge status={host.subStatus} />
</td> </td>
<td> <td className="d-none d-md-table-cell">
<CopyableCode wert={host.subKey} /> <CopyableCode wert={host.subKey} />
</td> </td>
<td> <td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} /> <AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td> </td>
<td> <td className="d-none d-lg-table-cell">
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"} {host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? ( {host.socketAbweichung ? (
<i <i
@@ -184,7 +190,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
/> />
) : null} ) : null}
</td> </td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td> <td className="small d-none d-xl-table-cell">
{formatDatumZeit(host.letzterReportAm)}
</td>
</tr> </tr>
))} ))}
{hosts.length === 0 ? ( {hosts.length === 0 ? (
+14 -4
View File
@@ -15,7 +15,7 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardSeite() { export default async function DashboardSeite() {
const heute = new Date(); const heute = new Date();
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([ const [zahlen, ohneKunde, ablaufListe, aufgabenDaten, kunden, rechnungspunkte] = await Promise.all([
kennzahlen(heute), kennzahlen(heute),
hostsOhneKunde(), hostsOhneKunde(),
hostsNachAblauf(heute), hostsNachAblauf(heute),
@@ -25,7 +25,7 @@ export default async function DashboardSeite() {
]); ]);
const aufgaben: Aufgabe[] = [ const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({ ...aufgabenDaten.aufgaben.map((aufgabe) => ({
id: aufgabe.id, id: aufgabe.id,
titel: aufgabe.titel, titel: aufgabe.titel,
detail: [ detail: [
@@ -41,7 +41,7 @@ export default async function DashboardSeite() {
href: aufgabe.href, href: aufgabe.href,
aktion: aufgabe.aktion, aktion: aufgabe.aktion,
})), })),
...ohneKunde.map((host) => ({ ...ohneKunde.slice(0, 5).map((host) => ({
id: `zuweisen-${host.id}`, id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`, titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`, detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
@@ -183,9 +183,19 @@ export default async function DashboardSeite() {
<Card <Card
titel="Aufgaben" titel="Aufgaben"
rahmen="warning" rahmen="warning"
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>} werkzeuge={
<span className="badge pl-bg-warning">
{aufgabenDaten.gesamt + ohneKunde.length}
</span>
}
> >
<TodoList aufgaben={aufgaben} /> <TodoList aufgaben={aufgaben} />
{aufgabenDaten.gesamt > aufgabenDaten.aufgaben.length ? (
<p className="small text-body-secondary mt-3 mb-0">
{aufgabenDaten.gesamt - aufgabenDaten.aufgaben.length} weitere Punkte unter{" "}
<Link href="/rechnungen">Rechnungen</Link>.
</p>
) : null}
</Card> </Card>
</div> </div>
</div> </div>
+22 -10
View File
@@ -201,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
<th scope="col">Host</th> <th scope="col" className="d-none d-md-table-cell">
<th scope="col">Laufzeit</th> Host
</th>
<th scope="col" className="d-none d-lg-table-cell">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</th> <th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th> <th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Bezahlt Bezahlt
</th> </th>
<th scope="col">Kundenrechnung</th> <th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th> <th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Erstellt Erstellt
</th> </th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Bezahlt Bezahlt
</th> </th>
<th scope="col">Marge</th> <th scope="col" className="d-none d-xl-table-cell">
Marge
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -233,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<span className="badge pl-bg-neutral">Nicht zugeordnet</span> <span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)} )}
</td> </td>
<td> <td className="d-none d-md-table-cell">
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none"> <Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td> <td className="d-none d-lg-table-cell">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none"> <Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)} {formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link> </Link>
@@ -252,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div> </div>
) : null} ) : null}
</td> </td>
<td>{formatEuro(zeile.einkaufBetrag)}</td> <td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
@@ -266,7 +276,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<td> <td>
<CopyableCode wert={zeile.verkaufNummer} /> <CopyableCode wert={zeile.verkaufNummer} />
</td> </td>
<td>{formatEuro(zeile.verkaufBetrag)}</td> <td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
@@ -286,7 +296,9 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung vom Kunden bezahlt" titel="Kundenrechnung vom Kunden bezahlt"
/> />
</td> </td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}> <td
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)} {zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td> </td>
</tr> </tr>
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Einfache Zustandsprüfung für Container und Reverse Proxy. */
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ ok: true, datenbank: "erreichbar", zeit: new Date().toISOString() });
} catch {
return Response.json({ ok: false, datenbank: "nicht erreichbar" }, { status: 503 });
}
}
+12
View File
@@ -19,6 +19,8 @@
--pl-sidebar-text: #c2c7d0; --pl-sidebar-text: #c2c7d0;
--pl-sidebar-divider: #4b545c; --pl-sidebar-divider: #4b545c;
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; --pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
--pl-logo-p: #1e2a44;
--pl-logo-linien: #17a2b8;
--bs-primary: var(--pl-primary); --bs-primary: var(--pl-primary);
--bs-primary-rgb: 0, 123, 255; --bs-primary-rgb: 0, 123, 255;
@@ -33,6 +35,16 @@
:root[data-bs-theme="dark"] { :root[data-bs-theme="dark"] {
--pl-content-bg: #1b1f24; --pl-content-bg: #1b1f24;
--pl-sidebar-bg: #22272d; --pl-sidebar-bg: #22272d;
/* Auf dunklem Grund wäre das dunkle P unsichtbar. */
--pl-logo-p: #e9ecef;
}
.pl-logo-p {
fill: var(--pl-logo-p);
}
.pl-logo-linien {
stroke: var(--pl-logo-linien);
} }
body { body {
+17 -8
View File
@@ -1,6 +1,7 @@
/** /**
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien. * ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen). * Varianten: "standard" folgt dem Farbschema (im Dunkelmodus hell), "sidebar" ist immer weiß,
* "mono" einfarbig für PDF und Rechnungen.
*/ */
export type LogoVariante = "standard" | "sidebar" | "mono"; export type LogoVariante = "standard" | "sidebar" | "mono";
@@ -9,18 +10,19 @@ interface LogoProps {
variante?: LogoVariante; variante?: LogoVariante;
groesse?: number; groesse?: number;
className?: string; className?: string;
/** Für dekorative Verwendung neben dem Schriftzug. */ /** Für dekorative Verwendung neben dem Schriftzug leer lassen. */
titel?: string; titel?: string;
} }
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = { const FESTE_FARBEN: Record<Exclude<LogoVariante, "standard">, { p: string; linien: string }> = {
standard: { p: "#1E2A44", linien: "#17A2B8" },
sidebar: { p: "#FFFFFF", linien: "#17A2B8" }, sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
mono: { p: "#1E2A44", linien: "#1E2A44" }, mono: { p: "#1E2A44", linien: "#1E2A44" },
}; };
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) { export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
const farben = FARBEN[variante]; // Die Standardvariante nimmt ihre Farben aus dem Theme, damit sie auch auf dunklem Grund lesbar bleibt.
const farben = variante === "standard" ? null : FESTE_FARBEN[variante];
return ( return (
<svg <svg
viewBox="0 0 48 48" viewBox="0 0 48 48"
@@ -36,14 +38,21 @@ export function Logo({ variante = "standard", groesse = 40, className, titel }:
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={farben.p} className={farben ? undefined : "pl-logo-p"}
fill={farben?.p}
/>
<path
d="M22 36h20M22 43h14"
className={farben ? undefined : "pl-logo-linien"}
stroke={farben?.linien}
strokeWidth="3.5"
strokeLinecap="round"
/> />
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
</svg> </svg>
); );
} }
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */ /** Logo mit Schriftzug, wie in Sidebar und Anmeldung verwendet. */
export function LogoMitSchriftzug({ export function LogoMitSchriftzug({
variante = "standard", variante = "standard",
groesse = 36, groesse = 36,
+1
View File
@@ -11,6 +11,7 @@ const OEFFENTLICH = [
"/login", "/login",
"/api/auth", "/api/auth",
"/api/agent", "/api/agent",
"/api/gesundheit",
"/install.sh", "/install.sh",
"/agent.sh", "/agent.sh",
"/favicon.ico", "/favicon.ico",
+31 -14
View File
@@ -82,26 +82,27 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
const kontext = await kontextLaden(jetzt); const kontext = await kontextLaden(jetzt);
const kandidaten = alleRegelnPruefen(kontext); const kandidaten = alleRegelnPruefen(kontext);
const offene = await prisma.alert.findMany({ where: { erledigtAm: null } }); // Alle betroffenen Warnungen in einem Zug laden: die offenen und die, deren Fall wieder auftritt.
const fingerprints = kandidaten.map((k) => k.fingerprint);
const [offene, bekannte] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null } }),
fingerprints.length > 0
? prisma.alert.findMany({ where: { fingerprint: { in: fingerprints } } })
: Promise.resolve([]),
]);
const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert])); const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert]));
const bekannteNachFingerprint = new Map(bekannte.map((alert) => [alert.fingerprint, alert]));
const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k])); const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint)); const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint)); const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
for (const kandidat of neu) { // Fälle, die es noch nie gab: in einem Rutsch anlegen.
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen. const wirklichNeu = neu.filter((k) => !bekannteNachFingerprint.has(k.fingerprint));
await prisma.alert.upsert({ if (wirklichNeu.length > 0) {
where: { fingerprint: kandidat.fingerprint }, await prisma.alert.createMany({
update: { data: wirklichNeu.map((kandidat) => ({
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
create: {
fingerprint: kandidat.fingerprint, fingerprint: kandidat.fingerprint,
regel: kandidat.regel, regel: kandidat.regel,
schwere: kandidat.schwere, schwere: kandidat.schwere,
@@ -111,6 +112,22 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
kundeId: kandidat.kundeId, kundeId: kandidat.kundeId,
laufzeitId: kandidat.laufzeitId, laufzeitId: kandidat.laufzeitId,
geoeffnetAm: jetzt, geoeffnetAm: jetzt,
})),
skipDuplicates: true,
});
}
// Früher erledigte Fälle, die wieder auftreten: denselben Eintrag erneut öffnen.
for (const kandidat of neu.filter((k) => bekannteNachFingerprint.has(k.fingerprint))) {
await prisma.alert.update({
where: { fingerprint: kandidat.fingerprint },
data: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
}, },
}); });
} }
+7 -3
View File
@@ -47,8 +47,12 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
}); });
} }
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */ /**
export async function offeneAufgaben() { * Offene Rechnungspunkte als Aufgabenliste fürs Dashboard.
* Begrenzt, weil die Karte sonst bei vielen Hosts endlos lang wird – die vollständige
* Liste steht unter „Rechnungen“.
*/
export async function offeneAufgaben(grenze = 12) {
const laufzeiten = await prisma.laufzeit.findMany({ const laufzeiten = await prisma.laufzeit.findMany({
where: { where: {
status: "BESTAETIGT", status: "BESTAETIGT",
@@ -116,5 +120,5 @@ export async function offeneAufgaben() {
} }
} }
return aufgaben; return { aufgaben: aufgaben.slice(0, grenze), gesamt: aufgaben.length };
} }