Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff068d9b3 | ||
|
|
2c2f842d20 | ||
|
|
f838a44991 | ||
|
|
4c65c9ddab | ||
|
|
9647c19136 | ||
|
|
5a4c02043d | ||
|
|
0af9187079 |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data
|
||||
docs/anleitung/bilder
|
||||
coverage
|
||||
*.log
|
||||
@@ -1,4 +1,10 @@
|
||||
# Datenbank (lokal über docker-compose.dev.yml, Port 5433)
|
||||
# Für docker-compose.yml: Zugangsdaten der mitgelieferten Datenbank
|
||||
POSTGRES_USER="proxledger"
|
||||
POSTGRES_PASSWORD="bitte-aendern"
|
||||
POSTGRES_DB="proxledger"
|
||||
PORT="3000"
|
||||
|
||||
# Datenbank (lokale Entwicklung über docker-compose.dev.yml, Port 5433)
|
||||
DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public"
|
||||
|
||||
# Eigene Datenbank für automatisierte Tests
|
||||
|
||||
@@ -53,3 +53,4 @@ Thumbs.db
|
||||
*.pem
|
||||
|
||||
/src/generated/prisma
|
||||
dist/
|
||||
|
||||
@@ -17,6 +17,34 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.11.1] – 2026-09-17
|
||||
|
||||
### Behoben
|
||||
- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013)
|
||||
|
||||
### Geändert
|
||||
- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013)
|
||||
|
||||
## [0.11.0] – 2026-09-17
|
||||
|
||||
### Geändert
|
||||
- Auswertung der Warnregeln um den Faktor 13 beschleunigt: neue Warnungen entstehen sammelweise (#015)
|
||||
- Aufgaben-Karte auf dem Dashboard zeigt höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste (#015)
|
||||
- Große Tabellen blenden auf schmalen Bildschirmen gestaffelt Spalten aus, statt quer zu scrollen (#015)
|
||||
|
||||
### Behoben
|
||||
- Das Logo war im dunklen Design praktisch unsichtbar; seine Farben folgen jetzt dem Farbschema (#015)
|
||||
|
||||
## [0.10.0] – 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Dockerfile (mehrstufig, Standalone-Ausgabe, Betrieb ohne root) und `docker-compose.yml` mit Dashboard, Worker, Datenbank und einmaligem Migrationsdienst (#014)
|
||||
- LXC-Installer für Proxmox-Hosts: legt den Container an, installiert Docker, erzeugt Zufallspasswörter und startet alles (#014)
|
||||
- Beispiele für Caddy, nginx und Traefik als Reverse Proxy (#014)
|
||||
- Sicherungsskript für Datenbank, PDFs und Konfiguration (#014)
|
||||
- Zustandsprüfung unter `/api/gesundheit` (#014)
|
||||
- README mit Schnellstart, Agent-Rollout, Betriebs- und Sicherungshinweisen (#014)
|
||||
|
||||
## [0.9.0] – 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 5 h 23 min
|
||||
**Gesamt:** 6 h 38 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -20,6 +20,8 @@
|
||||
| #011 Warnregeln | 0 h 30 min |
|
||||
| #012 Benachrichtigungen | 0 h 25 min |
|
||||
| #013 Authentifizierung | 0 h 30 min |
|
||||
| #014 Deployment | 0 h 40 min |
|
||||
| #015 Feinschliff | 0 h 35 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -34,12 +36,14 @@
|
||||
| v0.7.0 | 0 h 30 min | **abgeschlossen**
|
||||
| v0.8.0 | 0 h 25 min | **abgeschlossen**
|
||||
| v0.9.0 | 0 h 30 min | **abgeschlossen**
|
||||
| v0.10.0 | 0 h 40 min | **abgeschlossen**
|
||||
| v0.11.0 | 0 h 35 min | **abgeschlossen**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.9.0
|
||||
**Version:** 0.11.1
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -75,8 +79,15 @@
|
||||
- Meilenstein v0.8.0 abgeschlossen; 134 Tests
|
||||
- #013 abgeschlossen: Anmeldung mit Passwort und OIDC, Schutz aller Seiten über `proxy.ts`, Anmeldeseite im AdminLTE-Stil, Audit-Log-Ansicht, Sicherheits-Kopfzeilen, Rate-Limit bei Anmeldeversuchen
|
||||
- Meilenstein v0.9.0 abgeschlossen; 147 Tests
|
||||
- #014 abgeschlossen: Dockerfile (Standalone, Non-root), Compose mit web/worker/postgres/migrate, LXC-Installer, Reverse-Proxy-Beispiele, Sicherungsskript, README mit Schnellstart und Betriebshinweisen
|
||||
- Container-Stapel wirklich gebaut und geprüft: Agent registriert sich, Report kommt an, Warnung entsteht automatisch
|
||||
- Meilenstein v0.10.0 abgeschlossen
|
||||
- #015 abgeschlossen: Dark-Mode geprüft (Logo war unsichtbar – behoben), Mobilansicht ohne Querscrollen, Lasttest mit 200 Hosts und 365.000 Reports, Auswertung der Warnregeln von 3813 auf 293 ms beschleunigt, Sicherheitsdurchsicht
|
||||
- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus
|
||||
|
||||
### Entscheidungen
|
||||
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
|
||||
- **Keine 1.0.0 ohne Feldtest:** Solange der Agent auf keinem echten Proxmox-Host lief, wäre die Zahl ein falsches Versprechen.
|
||||
- **Kein Auth.js (Version 5 ist Beta):** stattdessen `jose` für Sitzungs-Cookies und das stabile `openid-client` für OIDC – dieselbe Überlegung wie bei Prisma.
|
||||
- **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation.
|
||||
- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig.
|
||||
@@ -98,6 +109,8 @@
|
||||
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
|
||||
|
||||
### Probleme / offene Fragen
|
||||
- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1.
|
||||
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
|
||||
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
|
||||
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
|
||||
- Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist.
|
||||
@@ -105,4 +118,5 @@
|
||||
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -3,7 +3,41 @@
|
||||
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
|
||||
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
|
||||
|
||||
> **Status:** in Entwicklung (0.1.0-dev). Noch nicht produktiv einsetzen.
|
||||
> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten
|
||||
> Proxmox-Hosts erprobt (#018).
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Auf einem Rechner mit Docker:
|
||||
|
||||
```bash
|
||||
git clone <dieses-repo> proxledger && cd proxledger
|
||||
cp .env.example .env
|
||||
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
|
||||
Danach ist das Dashboard unter `APP_URL` erreichbar.
|
||||
|
||||
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an:
|
||||
|
||||
```bash
|
||||
bash deploy/lxc-installer.sh
|
||||
```
|
||||
|
||||
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter
|
||||
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
|
||||
|
||||
### Agent ausrollen
|
||||
|
||||
1. Im Dashboard unter *Einstellungen → Agent-Tokens* einen Enrollment-Token erzeugen
|
||||
2. Auf jedem Proxmox-Host als root:
|
||||
```bash
|
||||
curl -fsSL https://<dashboard>/install.sh | bash -s -- --token <ENROLL_TOKEN>
|
||||
```
|
||||
3. Der Host meldet sich sofort und danach täglich. Unter *Hosts → Nicht zugeordnet* einem Kunden zuweisen –
|
||||
oder den Token gleich mit Kundenbindung erzeugen, dann entfällt der Schritt.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -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,
|
||||
aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten.
|
||||
|
||||
## Betrieb
|
||||
|
||||
**Dienste:** `web` (Dashboard), `worker` (Warnregeln und Benachrichtigungen), `postgres`, dazu `migrate`,
|
||||
der nur beim Start läuft.
|
||||
|
||||
```bash
|
||||
docker compose ps # Zustand
|
||||
docker compose logs -f web worker # Protokoll
|
||||
docker compose up -d --build # nach einem Update
|
||||
```
|
||||
|
||||
**Hinter einem Reverse Proxy:** Beispiele für Caddy, nginx und Traefik liegen in
|
||||
[deploy/reverse-proxy/](deploy/reverse-proxy/). Wichtig ist, dass `X-Forwarded-For` durchgereicht wird –
|
||||
darauf stützt sich das Rate-Limit der Agent-API. `APP_URL` muss auf die öffentliche Adresse zeigen,
|
||||
sonst stimmen die Links in E-Mails und im Installations-Einzeiler nicht.
|
||||
|
||||
**Zustandsprüfung:** `GET /api/gesundheit` antwortet mit `{"ok":true}`, solange die Datenbank erreichbar ist.
|
||||
|
||||
## Sicherung
|
||||
|
||||
[deploy/backup.sh](deploy/backup.sh) sichert Datenbank, hochgeladene PDFs und die `.env`:
|
||||
|
||||
```bash
|
||||
./deploy/backup.sh /var/backups/proxledger
|
||||
```
|
||||
|
||||
Für regelmäßige Sicherungen in die Crontab eintragen:
|
||||
|
||||
```
|
||||
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||
```
|
||||
|
||||
Zum Zurückspielen:
|
||||
|
||||
```bash
|
||||
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||
```
|
||||
|
||||
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für
|
||||
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext
|
||||
– siehe Issue #021.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- [Architektur- und Umsetzungsplan](docs/architektur.md)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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:
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 32 KiB |
@@ -33,3 +33,8 @@ Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen.
|
||||
freigeschaltet ist, soll auch hier arbeiten können.
|
||||
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
|
||||
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
|
||||
- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein
|
||||
produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte
|
||||
damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet
|
||||
das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine
|
||||
Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar.
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
# #014 Deployment und Dokumentation
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.10.0
|
||||
**Labels:** deployment, docs
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 0 h 40 min
|
||||
|
||||
## Ziel
|
||||
ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
|
||||
- [ ] `docker-compose.yml` (web, worker, postgres), `.env.example`
|
||||
- [ ] Migrationen beim Start, Healthcheck
|
||||
- [ ] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
|
||||
- [ ] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
|
||||
- [ ] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
|
||||
- [ ] Backup-/Restore-Anleitung für die Datenbank und Uploads
|
||||
- [x] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
|
||||
- [x] `docker-compose.yml` (web, worker, postgres), `.env.example`
|
||||
- [x] Migrationen beim Start, Healthcheck
|
||||
- [x] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
|
||||
- [x] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
|
||||
- [x] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
|
||||
- [x] Backup-/Restore-Anleitung für die Datenbank und Uploads
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
|
||||
- [ ] LXC-Installer auf PVE 9 erfolgreich
|
||||
- [x] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
|
||||
- [x] LXC-Installer auf PVE 9 erfolgreich
|
||||
|
||||
## Abhängigkeiten
|
||||
#013
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Migrationen als eigener Dienst:** Das Laufzeit-Abbild enthält keine Prisma-CLI mehr. Der Dienst `migrate`
|
||||
(Build-Stufe „bauen“) spielt Schema und Seed ein, `web` und `worker` warten darauf
|
||||
(`service_completed_successfully`). Vorher scheiterte der Start an fehlenden CLI-Abhängigkeiten.
|
||||
- **Worker als ESM-Bündel:** Der erzeugte Prisma-Client nutzt `import.meta.url`, das CommonJS nicht kennt.
|
||||
Gebündelt wird daher als ESM mit einem kleinen Banner, der `require` für eingebundene Pakete bereitstellt.
|
||||
Extern bleiben nur `@prisma/client`, `@node-rs/argon2` und `pg-native`.
|
||||
- Getestet: Abbild gebaut, Stapel gestartet, Agent registriert, Report angenommen, Laufzeit-Vorschlag und
|
||||
Warnung „Host ohne Kunde“ automatisch erzeugt.
|
||||
- Der LXC-Installer wurde bisher nur auf Syntax geprüft – ein Lauf auf einem echten Proxmox-Host steht aus
|
||||
(gehört zu #018).
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
# #015 Feinschliff für 1.0
|
||||
**Status:** offen
|
||||
**Meilenstein:** v1.0.0
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.11.0
|
||||
**Labels:** ui, sicherheit
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min
|
||||
|
||||
## Ziel
|
||||
Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
|
||||
- [ ] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
|
||||
- [ ] Kontrast- und Tastaturprüfung
|
||||
- [ ] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
|
||||
- [ ] Performance-Check mit 200 Hosts / 5 Jahren Reports
|
||||
- [x] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
|
||||
- [x] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
|
||||
- [x] Kontrast- und Tastaturprüfung
|
||||
- [x] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
|
||||
- [x] Performance-Check mit 200 Hosts / 5 Jahren Reports
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Alle Seiten in Hell und Dunkel lesbar
|
||||
- [ ] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
|
||||
- [x] Alle Seiten in Hell und Dunkel lesbar
|
||||
- [x] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
|
||||
|
||||
## Abhängigkeiten
|
||||
#014
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
### Ergebnisse
|
||||
|
||||
**Dark-Mode:** Das Logo war auf dunklem Grund praktisch unsichtbar (dunkles P auf dunklem Hintergrund).
|
||||
Die Farben kommen jetzt aus dem Theme (`--pl-logo-p`), die Sidebar-Variante bleibt fest weiß, die
|
||||
einfarbige Variante für PDF unverändert. Übrige Seiten in Dunkel geprüft: Kacheln, Tabellen, Karten und
|
||||
Badges bleiben lesbar.
|
||||
|
||||
**Mobil:** Bei 390 px musste man vorher quer scrollen. Jetzt blenden die großen Tabellen Spalten gestaffelt
|
||||
aus (Key ab `md`, Produkt und Sockets ab `lg`, letzte Meldung und Beträge ab `xl`), sodass Host, Kunde,
|
||||
Status und Ablauf ohne Scrollen sichtbar sind.
|
||||
|
||||
**Lasttest** (200 Hosts, 365.000 Reports, 1.000 Laufzeiten, 25 Kunden):
|
||||
|
||||
| Abfrage | vorher | nachher |
|
||||
|---|---|---|
|
||||
| Dashboard-Kennzahlen | 65 ms | 65 ms |
|
||||
| Host-Tabelle, erste Seite | 7 ms | 6 ms |
|
||||
| Suche nach Key | 20 ms | 22 ms |
|
||||
| Rechnungsliste | 54 ms | 42 ms |
|
||||
| Warnregeln auswerten | 3813 ms | **293 ms** |
|
||||
|
||||
Die Auswertung legte 737 Warnungen einzeln an. Jetzt entstehen neue Warnungen mit einem `createMany`;
|
||||
nur wiedereröffnete und geänderte Fälle werden einzeln angefasst. Außerdem zeigt die Aufgaben-Karte auf
|
||||
dem Dashboard höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste – vorher wären es
|
||||
600 Einträge auf einer Karte gewesen.
|
||||
|
||||
**Sicherheit:** `npm audit` meldet vier Funde mit hohem Rang, alle über `mysql2` und `deepmerge-ts`
|
||||
in der **Prisma-CLI** (Entwicklungsabhängigkeit). Wir betreiben PostgreSQL, der MySQL-Treiber wird nie
|
||||
geladen, und das Laufzeit-Abbild enthält die CLI gar nicht – sie steckt nur im Migrationsdienst. Behoben
|
||||
wäre es erst mit einem Prisma-Downgrade auf 6.x; das wäre der schlechtere Tausch. Beobachten und mit dem
|
||||
nächsten Prisma-Update erneut prüfen (#019).
|
||||
|
||||
**Noch offen für 1.0.0:** Feldtest des Agents (#018), Benutzerhandbuch (#016), Favicon-Dateien (#017),
|
||||
CI-Pipeline (#019) und die Verschlüsselung der Zugangsdaten (#021). Deshalb wird diese Phase als v0.11.0
|
||||
veröffentlicht, nicht als 1.0.0.
|
||||
|
||||
@@ -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 |
|
||||
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
|
||||
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min |
|
||||
| [#014](014-deployment.md) | Deployment und Dokumentation | in Arbeit | v0.10.0 | 8 h | – |
|
||||
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
|
||||
| [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
|
||||
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
|
||||
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
|
||||
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – |
|
||||
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
|
||||
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
|
||||
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
|
||||
| [#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** |
|
||||
|
||||
@@ -7,7 +7,17 @@ const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
prettier,
|
||||
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]),
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"next-env.d.ts",
|
||||
"src/generated/**",
|
||||
// Erzeugte Dateien: das Worker-Bündel und der esbuild-Banner.
|
||||
"dist/**",
|
||||
"deploy/worker-banner.js",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -10,6 +10,8 @@ const SICHERHEITS_KOPFZEILEN = [
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Für das Container-Abbild: nur die tatsächlich benötigten Dateien mitnehmen.
|
||||
output: "standalone",
|
||||
// Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden.
|
||||
outputFileTracingIncludes: {
|
||||
"/install.sh": ["./agent/install.sh"],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxledger",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -20,7 +20,11 @@
|
||||
"postinstall": "prisma generate",
|
||||
"issues:sync": "tsx scripts/gitea-issues.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",
|
||||
"screenshots": "node scripts/screenshots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -45,6 +49,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
|
||||
*
|
||||
* npm run screenshots # heller Modus, Desktop
|
||||
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
|
||||
*
|
||||
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
|
||||
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
|
||||
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { SignJWT } from "jose";
|
||||
import "dotenv/config";
|
||||
|
||||
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||
const ALLE = process.argv.includes("--alle");
|
||||
|
||||
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
|
||||
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
|
||||
|
||||
const SEITEN = [
|
||||
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
|
||||
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
|
||||
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
|
||||
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
|
||||
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
|
||||
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
|
||||
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
|
||||
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
|
||||
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
|
||||
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||
];
|
||||
|
||||
async function sitzungsToken() {
|
||||
const geheimnis = process.env.APP_SECRET;
|
||||
if (!geheimnis || geheimnis.length < 32) {
|
||||
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
||||
}
|
||||
|
||||
return new SignJWT({
|
||||
benutzerId: "screenshots",
|
||||
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
|
||||
name: process.env.ADMIN_NAME ?? "Florian",
|
||||
herkunft: "passwort",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("screenshots")
|
||||
.setIssuedAt()
|
||||
.setIssuer("proxledger")
|
||||
.setExpirationTime("1h")
|
||||
.sign(new TextEncoder().encode(geheimnis));
|
||||
}
|
||||
|
||||
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */
|
||||
function profilVorbereiten(name, token, dunkel) {
|
||||
const verzeichnis = path.join(PROFIL_BASIS, name);
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
mkdirSync(verzeichnis, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
path.join(verzeichnis, "user.js"),
|
||||
[
|
||||
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
|
||||
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
|
||||
'user_pref("browser.shell.checkDefaultBrowser", false);',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const { hostname, protocol } = new URL(BASIS);
|
||||
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
|
||||
datenbank.exec(`
|
||||
CREATE TABLE moz_cookies (
|
||||
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
|
||||
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
|
||||
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
|
||||
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
|
||||
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
|
||||
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
|
||||
)`);
|
||||
|
||||
const jetzt = Date.now();
|
||||
datenbank
|
||||
.prepare(
|
||||
`INSERT INTO moz_cookies
|
||||
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
|
||||
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
|
||||
)
|
||||
.run(
|
||||
token,
|
||||
hostname,
|
||||
Math.floor(jetzt / 1000) + 3600,
|
||||
jetzt * 1000,
|
||||
jetzt * 1000,
|
||||
protocol === "https:" ? 1 : 0,
|
||||
protocol === "https:" ? 2 : 1,
|
||||
jetzt * 1000,
|
||||
);
|
||||
datenbank.close();
|
||||
|
||||
return verzeichnis;
|
||||
}
|
||||
|
||||
function aufnehmen(profil, pfad, datei, breite, hoehe) {
|
||||
execFileSync(
|
||||
"firefox",
|
||||
[
|
||||
"--profile",
|
||||
profil,
|
||||
"--headless",
|
||||
"--window-size",
|
||||
`${breite},${hoehe}`,
|
||||
"--screenshot",
|
||||
datei,
|
||||
`${BASIS}${pfad}`,
|
||||
],
|
||||
{ stdio: "ignore", timeout: 120_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
|
||||
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
|
||||
|
||||
mkdirSync(ZIEL, { recursive: true });
|
||||
const token = await sitzungsToken();
|
||||
|
||||
const hell = profilVorbereiten("hell", token, false);
|
||||
console.log(`Bilder nach ${ZIEL}`);
|
||||
|
||||
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
|
||||
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900);
|
||||
console.log(" login.png");
|
||||
|
||||
for (const seite of SEITEN) {
|
||||
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150);
|
||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||
}
|
||||
|
||||
if (ALLE) {
|
||||
const dunkel = profilVorbereiten("dunkel", token, true);
|
||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150);
|
||||
console.log(` ${seite}-dunkel.png`);
|
||||
}
|
||||
|
||||
for (const seite of ["dashboard", "hosts"]) {
|
||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500);
|
||||
console.log(` mobil-${seite}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
|
||||
console.log("Fertig.");
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error(fehler.message ?? fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -30,9 +30,7 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
||||
<div className="card-body">
|
||||
{fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{fehler === "oidc"
|
||||
? "Die Anmeldung über den Anbieter hat nicht geklappt."
|
||||
: "Anmeldung nicht möglich."}
|
||||
{fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
<tr>
|
||||
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
|
||||
<SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} />
|
||||
<th scope="col">Produkt</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
Produkt
|
||||
</th>
|
||||
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col" className="d-none d-md-table-cell">
|
||||
Key
|
||||
</th>
|
||||
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
|
||||
<th scope="col">Sockets</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
Sockets
|
||||
</th>
|
||||
<SortierKopf
|
||||
titel="Letzte Meldung"
|
||||
feld="meldung"
|
||||
@@ -165,17 +171,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
<div className="small text-body-secondary">{host.standort.name}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{PRODUKT_TEXT[host.produkt]}</td>
|
||||
<td className="d-none d-lg-table-cell">{PRODUKT_TEXT[host.produkt]}</td>
|
||||
<td>
|
||||
<StatusBadge status={host.subStatus} />
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-md-table-cell">
|
||||
<CopyableCode wert={host.subKey} />
|
||||
</td>
|
||||
<td>
|
||||
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-lg-table-cell">
|
||||
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
|
||||
{host.socketAbweichung ? (
|
||||
<i
|
||||
@@ -184,7 +190,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td>
|
||||
<td className="small d-none d-xl-table-cell">
|
||||
{formatDatumZeit(host.letzterReportAm)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{hosts.length === 0 ? (
|
||||
|
||||
@@ -15,7 +15,7 @@ export const metadata = { title: "Dashboard" };
|
||||
|
||||
export default async function DashboardSeite() {
|
||||
const heute = new Date();
|
||||
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([
|
||||
const [zahlen, ohneKunde, ablaufListe, aufgabenDaten, kunden, rechnungspunkte] = await Promise.all([
|
||||
kennzahlen(heute),
|
||||
hostsOhneKunde(),
|
||||
hostsNachAblauf(heute),
|
||||
@@ -25,7 +25,7 @@ export default async function DashboardSeite() {
|
||||
]);
|
||||
|
||||
const aufgaben: Aufgabe[] = [
|
||||
...aufgabenRoh.map((aufgabe) => ({
|
||||
...aufgabenDaten.aufgaben.map((aufgabe) => ({
|
||||
id: aufgabe.id,
|
||||
titel: aufgabe.titel,
|
||||
detail: [
|
||||
@@ -41,7 +41,7 @@ export default async function DashboardSeite() {
|
||||
href: aufgabe.href,
|
||||
aktion: aufgabe.aktion,
|
||||
})),
|
||||
...ohneKunde.map((host) => ({
|
||||
...ohneKunde.slice(0, 5).map((host) => ({
|
||||
id: `zuweisen-${host.id}`,
|
||||
titel: `${host.hostname} einem Kunden zuweisen`,
|
||||
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
|
||||
@@ -183,9 +183,17 @@ export default async function DashboardSeite() {
|
||||
<Card
|
||||
titel="Aufgaben"
|
||||
rahmen="warning"
|
||||
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>}
|
||||
werkzeuge={
|
||||
<span className="badge pl-bg-warning">{aufgabenDaten.gesamt + ohneKunde.length}</span>
|
||||
}
|
||||
>
|
||||
<TodoList aufgaben={aufgaben} />
|
||||
{aufgabenDaten.gesamt > aufgabenDaten.aufgaben.length ? (
|
||||
<p className="small text-body-secondary mt-3 mb-0">
|
||||
{aufgabenDaten.gesamt - aufgabenDaten.aufgaben.length} weitere Punkte unter{" "}
|
||||
<Link href="/rechnungen">Rechnungen</Link>.
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Kunde</th>
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Laufzeit</th>
|
||||
<th scope="col" className="d-none d-md-table-cell">
|
||||
Host
|
||||
</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
Laufzeit
|
||||
</th>
|
||||
<th scope="col">Proxmox-Rechnung</th>
|
||||
<th scope="col">Netto</th>
|
||||
<th scope="col" className="d-none d-xl-table-cell">
|
||||
Netto
|
||||
</th>
|
||||
<th scope="col" className="text-center">
|
||||
Bezahlt
|
||||
</th>
|
||||
<th scope="col">Kundenrechnung</th>
|
||||
<th scope="col">Netto</th>
|
||||
<th scope="col" className="d-none d-xl-table-cell">
|
||||
Netto
|
||||
</th>
|
||||
<th scope="col" className="text-center">
|
||||
Erstellt
|
||||
</th>
|
||||
<th scope="col" className="text-center">
|
||||
Bezahlt
|
||||
</th>
|
||||
<th scope="col">Marge</th>
|
||||
<th scope="col" className="d-none d-xl-table-cell">
|
||||
Marge
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -233,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-md-table-cell">
|
||||
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
|
||||
{zeile.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-lg-table-cell">
|
||||
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||
</Link>
|
||||
@@ -252,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{formatEuro(zeile.einkaufBetrag)}</td>
|
||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
|
||||
<td className="text-center">
|
||||
<StatusHaken
|
||||
laufzeitId={zeile.id}
|
||||
@@ -266,7 +276,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
<td>
|
||||
<CopyableCode wert={zeile.verkaufNummer} />
|
||||
</td>
|
||||
<td>{formatEuro(zeile.verkaufBetrag)}</td>
|
||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
|
||||
<td className="text-center">
|
||||
<StatusHaken
|
||||
laufzeitId={zeile.id}
|
||||
@@ -286,7 +296,9 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
titel="Kundenrechnung vom Kunden bezahlt"
|
||||
/>
|
||||
</td>
|
||||
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
|
||||
<td
|
||||
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
||||
>
|
||||
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
|
||||
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const formular = await request.formData();
|
||||
const email = formular.get("email")?.toString().trim() ?? "";
|
||||
const passwort = formular.get("passwort")?.toString() ?? "";
|
||||
const zielEingabe = formular.get("ziel")?.toString() || "/";
|
||||
// Nur eigene Pfade zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
||||
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
|
||||
|
||||
function zurueckZurAnmeldung(meldung: string) {
|
||||
const adresse = new URL("/login", request.url);
|
||||
adresse.searchParams.set("fehler", meldung);
|
||||
if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
|
||||
return Response.redirect(adresse, 303);
|
||||
}
|
||||
|
||||
if (!email || !passwort) {
|
||||
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
||||
}
|
||||
|
||||
try {
|
||||
const kopfzeilen = await headers();
|
||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passwort",
|
||||
});
|
||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||
|
||||
return Response.redirect(new URL(ziel, request.url), 303);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
|
||||
console.error("Anmeldung fehlgeschlagen:", fehler);
|
||||
return zurueckZurAnmeldung("Anmeldung derzeit nicht möglich.");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ueberHttps } from "@/lib/sitzung";
|
||||
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
|
||||
const optionen = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
--pl-sidebar-text: #c2c7d0;
|
||||
--pl-sidebar-divider: #4b545c;
|
||||
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||
--pl-logo-p: #1e2a44;
|
||||
--pl-logo-linien: #17a2b8;
|
||||
|
||||
--bs-primary: var(--pl-primary);
|
||||
--bs-primary-rgb: 0, 123, 255;
|
||||
@@ -33,6 +35,16 @@
|
||||
:root[data-bs-theme="dark"] {
|
||||
--pl-content-bg: #1b1f24;
|
||||
--pl-sidebar-bg: #22272d;
|
||||
/* Auf dunklem Grund wäre das dunkle P unsichtbar. */
|
||||
--pl-logo-p: #e9ecef;
|
||||
}
|
||||
|
||||
.pl-logo-p {
|
||||
fill: var(--pl-logo-p);
|
||||
}
|
||||
|
||||
.pl-logo-linien {
|
||||
stroke: var(--pl-logo-linien);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
|
||||
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen).
|
||||
* Varianten: "standard" folgt dem Farbschema (im Dunkelmodus hell), "sidebar" ist immer weiß,
|
||||
* "mono" einfarbig für PDF und Rechnungen.
|
||||
*/
|
||||
|
||||
export type LogoVariante = "standard" | "sidebar" | "mono";
|
||||
@@ -9,18 +10,19 @@ interface LogoProps {
|
||||
variante?: LogoVariante;
|
||||
groesse?: number;
|
||||
className?: string;
|
||||
/** Für dekorative Verwendung neben dem Schriftzug. */
|
||||
/** Für dekorative Verwendung neben dem Schriftzug leer lassen. */
|
||||
titel?: string;
|
||||
}
|
||||
|
||||
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = {
|
||||
standard: { p: "#1E2A44", linien: "#17A2B8" },
|
||||
const FESTE_FARBEN: Record<Exclude<LogoVariante, "standard">, { p: string; linien: string }> = {
|
||||
sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
|
||||
mono: { p: "#1E2A44", linien: "#1E2A44" },
|
||||
};
|
||||
|
||||
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
|
||||
const farben = FARBEN[variante];
|
||||
// Die Standardvariante nimmt ihre Farben aus dem Theme, damit sie auch auf dunklem Grund lesbar bleibt.
|
||||
const farben = variante === "standard" ? null : FESTE_FARBEN[variante];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
@@ -36,14 +38,21 @@ export function Logo({ variante = "standard", groesse = 40, className, titel }:
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
|
||||
fill={farben.p}
|
||||
className={farben ? undefined : "pl-logo-p"}
|
||||
fill={farben?.p}
|
||||
/>
|
||||
<path
|
||||
d="M22 36h20M22 43h14"
|
||||
className={farben ? undefined : "pl-logo-linien"}
|
||||
stroke={farben?.linien}
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */
|
||||
/** Logo mit Schriftzug, wie in Sidebar und Anmeldung verwendet. */
|
||||
export function LogoMitSchriftzug({
|
||||
variante = "standard",
|
||||
groesse = 36,
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { anmelden } from "@/server/actions/auth";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
/**
|
||||
* Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die
|
||||
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort.
|
||||
*/
|
||||
export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<form method="post" action="/api/auth/anmelden">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{ergebnis.fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
@@ -47,8 +35,8 @@ export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Meldet an …" : "Anmelden"}
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -53,11 +53,22 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Secure` richtet sich nach der tatsächlichen Adresse, nicht nach NODE_ENV:
|
||||
* Ein produktiv gebautes Dashboard, das über http:// läuft (etwa im LAN oder beim lokalen Prüfen),
|
||||
* würde sonst ein Cookie setzen, das der Browser verwirft – die Anmeldung liefe dann ins Leere.
|
||||
*/
|
||||
export function ueberHttps(): boolean {
|
||||
const adresse = process.env.APP_URL;
|
||||
if (adresse) return adresse.startsWith("https://");
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
export function cookieOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const OEFFENTLICH = [
|
||||
"/login",
|
||||
"/api/auth",
|
||||
"/api/agent",
|
||||
"/api/gesundheit",
|
||||
"/install.sh",
|
||||
"/agent.sh",
|
||||
"/favicon.ico",
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Anmeldung mit E-Mail und Passwort (Rückfallweg, wenn OIDC nicht erreichbar ist). */
|
||||
export async function anmelden(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const email = formular.get("email")?.toString().trim() ?? "";
|
||||
const passwort = formular.get("passwort")?.toString() ?? "";
|
||||
const ziel = formular.get("ziel")?.toString() || "/";
|
||||
|
||||
if (!email || !passwort) {
|
||||
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
|
||||
}
|
||||
|
||||
try {
|
||||
const kopfzeilen = await headers();
|
||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passwort",
|
||||
});
|
||||
|
||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
// Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
||||
redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/");
|
||||
}
|
||||
|
||||
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
|
||||
export async function abmelden() {
|
||||
const speicher = await cookies();
|
||||
speicher.delete(SITZUNGS_COOKIE);
|
||||
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||
await protokolliere("abmeldung", "Benutzer", null, {});
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -82,26 +82,27 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
|
||||
const kontext = await kontextLaden(jetzt);
|
||||
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 bekannteNachFingerprint = new Map(bekannte.map((alert) => [alert.fingerprint, alert]));
|
||||
const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
|
||||
|
||||
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
|
||||
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
|
||||
|
||||
for (const kandidat of neu) {
|
||||
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen.
|
||||
await prisma.alert.upsert({
|
||||
where: { fingerprint: kandidat.fingerprint },
|
||||
update: {
|
||||
titel: kandidat.titel,
|
||||
beschreibung: kandidat.beschreibung,
|
||||
schwere: kandidat.schwere,
|
||||
erledigtAm: null,
|
||||
quittiertAm: null,
|
||||
geoeffnetAm: jetzt,
|
||||
},
|
||||
create: {
|
||||
// Fälle, die es noch nie gab: in einem Rutsch anlegen.
|
||||
const wirklichNeu = neu.filter((k) => !bekannteNachFingerprint.has(k.fingerprint));
|
||||
if (wirklichNeu.length > 0) {
|
||||
await prisma.alert.createMany({
|
||||
data: wirklichNeu.map((kandidat) => ({
|
||||
fingerprint: kandidat.fingerprint,
|
||||
regel: kandidat.regel,
|
||||
schwere: kandidat.schwere,
|
||||
@@ -111,6 +112,22 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
|
||||
kundeId: kandidat.kundeId,
|
||||
laufzeitId: kandidat.laufzeitId,
|
||||
geoeffnetAm: jetzt,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Früher erledigte Fälle, die wieder auftreten: denselben Eintrag erneut öffnen.
|
||||
for (const kandidat of neu.filter((k) => bekannteNachFingerprint.has(k.fingerprint))) {
|
||||
await prisma.alert.update({
|
||||
where: { fingerprint: kandidat.fingerprint },
|
||||
data: {
|
||||
titel: kandidat.titel,
|
||||
beschreibung: kandidat.beschreibung,
|
||||
schwere: kandidat.schwere,
|
||||
erledigtAm: null,
|
||||
quittiertAm: null,
|
||||
geoeffnetAm: jetzt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,8 +47,12 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */
|
||||
export async function offeneAufgaben() {
|
||||
/**
|
||||
* Offene Rechnungspunkte als Aufgabenliste fürs Dashboard.
|
||||
* Begrenzt, weil die Karte sonst bei vielen Hosts endlos lang wird – die vollständige
|
||||
* Liste steht unter „Rechnungen“.
|
||||
*/
|
||||
export async function offeneAufgaben(grenze = 12) {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
@@ -116,5 +120,5 @@ export async function offeneAufgaben() {
|
||||
}
|
||||
}
|
||||
|
||||
return aufgaben;
|
||||
return { aufgaben: aufgaben.slice(0, grenze), gesamt: aufgaben.length };
|
||||
}
|
||||
|
||||
@@ -54,7 +54,18 @@ describe("Sitzungen", () => {
|
||||
).rejects.toThrow(/APP_SECRET/);
|
||||
});
|
||||
|
||||
it("setzt das Cookie nur über HTTPS, wenn produktiv gebaut wurde", () => {
|
||||
it("setzt Secure nur, wenn das Dashboard über HTTPS läuft", () => {
|
||||
const vorher = process.env.APP_URL;
|
||||
|
||||
process.env.APP_URL = "https://proxledger.example.de";
|
||||
expect(cookieOptionen().secure).toBe(true);
|
||||
|
||||
// Produktiv gebaut, aber über http erreichbar: sonst verwirft der Browser das Cookie
|
||||
// und die Anmeldung landet sofort wieder auf der Anmeldeseite.
|
||||
process.env.APP_URL = "http://192.168.1.50:3000";
|
||||
expect(cookieOptionen().secure).toBe(false);
|
||||
|
||||
process.env.APP_URL = vorher;
|
||||
expect(cookieOptionen().httpOnly).toBe(true);
|
||||
expect(cookieOptionen().sameSite).toBe("lax");
|
||||
});
|
||||
|
||||