Compare commits

...
12 Commits
Author SHA1 Message Date
florian.wilde 97f6450f1f Merge branch 'phase/035-mobil-tabellen'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:20:20 +02:00
florian.wildeandClaude Opus 5 ca2af0c2fc feat(ui): Tabellen als Karten auf dem Handy, Standort am Host (#035, #036)
Breite Tabellen (Kunden, Rechnungen, Ablauf-Kalender) werden unterhalb von
768 px zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts
abgeschnitten zu sein. Kartenkoepfe brechen um, die Jahresbahn bekommt eine
schmalere Namensspalte und einen Hinweis aufs Wischen.

Standort am Host (#036): Das Formular sendete nur den Kunden, die Aktion las
aber auch standortId - jede Kundenzuweisung hat den Standort still geloescht.
Jetzt stehen Kunde und Standort in einem Formular, die Liste richtet sich nach
dem Kunden, und die Aktion prueft, dass der Standort zum Kunden gehoert.

Nebenbefund: Das Kontrollkaestchen fuer den Rechnungsstatus trug
pl-touch-target und wurde dadurch selbst zum 44 Pixel hohen leeren Kasten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:20:20 +02:00
florian.wilde 9c873a4069 Merge branch 'phase/034-mobil'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:55:59 +02:00
florian.wildeandClaude Opus 5 09bb98235b fix(ui): Seitenmenue auf dem Handy schliessbar machen (#034)
Das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht
einbinden. Die Klasse sidebar-open liess sich damit setzen, aber nichts nahm
sie wieder weg: keine Flaeche zum Antippen, kein Schliessen beim Navigieren,
kein Escape. Auf dem Telefon blieb das Menue ueber dem Inhalt stehen.

Jetzt gibt es einen eigenen Zustand, auf den Kopfzeile und Menue gemeinsam
hoeren, eine abdunkelnde Flaeche dahinter, einen Schliessen-Knopf im Menuekopf
und das Schliessen beim Navigieren. Der Inhalt dahinter scrollt nicht mehr mit.

Ausserdem zeigt die Dashboard-Tabelle auf dem Telefon nur Host und Ablauf, den
Kunden klein darunter - vorher war rechts die Haelfte abgeschnitten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:55:59 +02:00
florian.wilde 4bcf3daedd Merge branch 'phase/033-adresse-haerten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:35:19 +02:00
florian.wildeandClaude Opus 5 94d5edbc6c fix(adresse): Weiterleitungen nie aus der Lausch-Adresse bauen (#033, #032)
Abmelden fuehrte im Docker-Betrieb auf 0.0.0.0:3000 - eine Adresse, die kein
Browser aufrufen kann. Ursache war hausgemacht: ENV HOSTNAME=0.0.0.0 aus
#031 reparierte die Zustandspruefung und lenkte zugleich alle Weiterleitungen
aus Server Actions dorthin, weil Next sie serverseitig absolut macht. Vorher
stand dort der Containername - derselbe Fehler, nur unauffaelliger.

Neuer Helfer eigenerPfad() baut die Adresse aus Einstellung, APP_URL oder den
Kopfzeilen der Anfrage. Betroffen waren Abmelden, die Sitzungspruefung und das
Loeschen eines Kunden. Zusaetzlich uebergeht oeffentlicheBasis() eine als
Lausch-Adresse eingetragene APP_URL, statt sie zu uebernehmen.

Gegen einen Docker-Stapel geprueft: Mit APP_URL=http://0.0.0.0:3000 leitet die
Anwendung jetzt auf den tatsaechlich aufgerufenen Namen.

Dazu #032: Das ct-Skript setzt die Herkunft der App-Skripte selbst, und die
Speichervorgabe steht auf 4 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:19 +02:00
florian.wilde 92210e9099 Merge branch 'phase/032-ct-basis'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:01:58 +02:00
florian.wildeandClaude Opus 5 6dfa85381a fix(lxc): Herkunft der Skripte im ct-Skript festhalten
Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln.
Das Geruest suchte install/proxledger-install.sh deshalb bei den
community-scripts auf GitHub und brach mit 404 ab, nachdem der Container
bereits angelegt war. Jetzt setzt das Skript COMMUNITY_SCRIPTS_URL selbst;
fuer Forks und andere Zweige bleibt es ueberschreibbar.

Ausserdem die Vorgabe fuer den Arbeitsspeicher auf 4 GB angehoben: next build
braucht waehrend der Installation deutlich mehr als 2 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:01:58 +02:00
florian.wildeandClaude Opus 5 11e514dd25 feat(deploy): Update und Sicherung fuer beide Installationsarten (#031)
Prüfung / pruefung (push) Canceled after 0s
deploy/update.sh und deploy/backup.sh konnten nur Docker. Beide erkennen die
Installationsart jetzt selbst und beherrschen auch die Installation als
Dienst: npm ci, prisma migrate deploy, bauen, systemctl restart - und
pg_dump ueber DATABASE_URL fuer die Sicherung.

Beim Durchspielen gegen einen laufenden Stapel zwei Fehler gefunden:

1. Die Zustandspruefung des Web-Containers konnte nie gelingen. Next bindet
   im Standalone-Modus an die Adresse aus HOSTNAME, und Docker setzt dort
   den Containernamen - der Server lauschte nur auf der Container-IP. Der
   Dienst web blieb dauerhaft auf "health: starting". Behoben mit
   ENV HOSTNAME=0.0.0.0; geprueft wird jetzt ueber 127.0.0.1.
2. Eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf. Jetzt
   bricht sie ab, bevor etwas veraendert wird, und nennt die Auswege.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 07:26:30 +02:00
florian.wilde 9f1eb189db Merge branch 'phase/031-aktualisierung'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 00:25:03 +02:00
florian.wildeandClaude Opus 5 585173622d fix(deps): Next.js auf 16.3.8 wegen kritischer Meldung
Fuer Next.js 16.2.0 bis 16.3.5 wurde eine als kritisch eingestufte Luecke in
next/og gemeldet (RCE, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt next/og nicht
und war damit nicht angreifbar; vor einem Produktivgang gehoert die Fassung
trotzdem aktualisiert.

Ausserdem @simplewebauthn/server auf 14.0.3. Tests, Typen, Lint und Build
gruen, npm audit ohne Befund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 00:25:03 +02:00
florian.wildeandClaude Opus 5 9c6c6a1c35 docs: echte Repository-Adresse eintragen
Prüfung / pruefung (push) Canceled after 0s
README und Inbetriebnahme nannten Platzhalter, die community-scripts-Dateien
einen falschen Benutzerpfad (fw statt florian.wilde) - der Einzeiler haette
dort ins Leere gezeigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:21:12 +02:00
68 changed files with 1208 additions and 221 deletions
+46
View File
@@ -17,6 +17,52 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.21.0] – 2026-10-03
### Hinzugefügt
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
### Behoben
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
## [0.20.2] – 2026-10-03
### Behoben
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
### Hinzugefügt
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
## [0.20.1] – 2026-10-03
### Behoben
- **Abmelden führte auf `0.0.0.0:3000`** und damit ins Leere. Next.js macht Weiterleitungen aus Server Actions serverseitig absolut und nahm dafür die Lausch-Adresse des Servers. Betroffen waren Abmelden, die Sitzungsprüfung und das Löschen eines Kunden (#033)
- Eine als Lausch-Adresse eingetragene `APP_URL` (`0.0.0.0`, `::`) wird jetzt übergangen statt übernommen (#033)
- Die LXC-Installation brach mit *404 – Could not fetch install/proxledger-install.sh* ab: Ein per curl geladenes ct-Skript kennt seine Herkunft nicht und suchte bei den community-scripts (#032)
### Geändert
- Vorgabe für den Arbeitsspeicher des LXC von 2 auf 4 GB – `next build` scheitert sonst während der Installation am Speicher (#032)
## [0.20.0] – 2026-10-03
### Hinzugefügt
- `deploy/update.sh` und `deploy/backup.sh` erkennen die Installationsart selbst und beherrschen jetzt auch die Installation als Dienst (Node und PostgreSQL direkt im Container) – nicht mehr nur Docker (#031)
- Eine fehlgeschlagene Sicherung bricht die Aktualisierung ab, bevor etwas verändert wird (#031)
### Behoben
- Die Zustandsprüfung des Web-Containers konnte nie gelingen: Next bindet im Standalone-Modus an die Adresse aus `HOSTNAME`, und Docker setzt dort den Containernamen – der Server lauschte nur auf der Container-IP. `web` blieb dauerhaft auf „health: starting“ (#031)
## [0.19.2] – 2026-10-03
### Sicherheit
- Next.js auf 16.3.8 angehoben. Für 16.2.0 bis 16.3.5 wurde eine als **kritisch** eingestufte Lücke in `next/og` gemeldet (Remote Code Execution, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt `next/og` nicht, war also nicht angreifbar – die Fassung gehört trotzdem aktualisiert, bevor etwas produktiv läuft
- `@simplewebauthn/server` auf 14.0.3
## [0.19.1] – 2026-09-19 ## [0.19.1] – 2026-09-19
### Hinzugefügt ### Hinzugefügt
+26 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 15 h 23 min **Gesamt:** 18 h 58 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -36,6 +36,12 @@
| #028 Passkeys | 0 h 45 min | | #028 Passkeys | 0 h 45 min |
| #029 Einrichtungsdokumentation | 0 h 40 min | | #029 Einrichtungsdokumentation | 0 h 40 min |
| #030 netbird Reverse Proxy | 0 h 20 min | | #030 netbird Reverse Proxy | 0 h 20 min |
| #031 Update für beide Varianten | 0 h 45 min |
| #032 LXC-Installation korrigiert | 0 h 20 min |
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
| #034 Bedienung auf dem Handy | 0 h 40 min |
| #035 Tabellen als Karten | 0 h 50 min |
| #036 Standort am Host | 0 h 30 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -58,7 +64,7 @@
## 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.19.1 **Version:** 0.21.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -165,6 +171,24 @@
- netbird Reverse Proxy (#030) in die Anleitung aufgenommen – Florians Hinweis, dass die Beta gut läuft. Beim Durchgehen der Möglichkeiten fiel der Fallstrick auf: Eine Anmeldehürde vor dem Dienst trifft auch `/api/agent` und legt alle Agents still - netbird Reverse Proxy (#030) in die Anleitung aufgenommen – Florians Hinweis, dass die Beta gut läuft. Beim Durchgehen der Möglichkeiten fiel der Fallstrick auf: Eine Anmeldehürde vor dem Dienst trifft auch `/api/agent` und legt alle Agents still
### 03.10.2026 – Prüfung vor dem Einsatz
- `npm audit` vor der Freigabe erneut laufen lassen: In den zwei Wochen seit dem letzten Stand kam eine kritische Meldung zu Next.js dazu (RCE in `next/og`). Wir nutzen `next/og` nicht, waren also nicht angreifbar – angehoben auf 16.3.8, Tests und Build grün
- Lehre für den Betrieb: Vor jedem Produktivgang und danach regelmäßig `npm audit` prüfen, nicht nur bei Änderungen am Code
- Update- und Sicherungsskript für beide Installationsarten (#031). Beim Durchspielen zwei Fehler gefunden: Die Zustandsprüfung des Web-Containers konnte nie gelingen (Next bindet an `HOSTNAME`, Docker setzt dort den Containernamen), und eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf
- Lehre: Ein Skript, das nie unter echten Bedingungen gelaufen ist, ist eine Behauptung. Erst der Durchlauf gegen einen laufenden Stapel hat beide Fehler gezeigt
- Erster echter Einsatz durch Florian – und prompt zwei Fehler, die kein Test gezeigt hatte (#032, #033)
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+3
View File
@@ -24,6 +24,9 @@ RUN npx prisma generate \
FROM basis AS laufzeit FROM basis AS laufzeit
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
# Ohne diese Zeile nimmt Next den von Docker gesetzten HOSTNAME als Bindeadresse und lauscht
# nur auf der Container-IP. Die Zustandsprüfung über 127.0.0.1 schlägt dann immer fehl.
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
+2 -2
View File
@@ -13,7 +13,7 @@ Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push
Auf einem Rechner mit Docker: Auf einem Rechner mit Docker:
```bash ```bash
git clone <dieses-repo> proxledger && cd proxledger git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env cp .env.example .env
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL # Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d docker compose up -d
@@ -37,7 +37,7 @@ bash deploy/lxc-installer.sh # eigener Installer: Container mit Docker
```bash ```bash
# Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin # Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin
bash -c "$(curl -fsSL https://<git>/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)" bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
``` ```
Der zweite Weg folgt den Konventionen von [community-scripts.org](https://community-scripts.org) und Der zweite Weg folgt den Konventionen von [community-scripts.org](https://community-scripts.org) und
+42 -8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Sichert Datenbank und hochgeladene PDFs von ProxLedger. # Sichert Datenbank, hochgeladene PDFs und die Konfiguration von ProxLedger.
# Erkennt selbst, ob die Installation mit Docker oder als Dienst im Container läuft.
# #
# ./backup.sh /pfad/zum/sicherungsordner # ./backup.sh /pfad/zum/sicherungsordner
# #
@@ -13,17 +14,50 @@ set -o pipefail
ZIEL="${1:-/var/backups/proxledger}" ZIEL="${1:-/var/backups/proxledger}"
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}" AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
STAND=$(date +%Y%m%d-%H%M%S) STAND=$(date +%Y%m%d-%H%M%S)
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}" VERZEICHNIS="${COMPOSE_VERZEICHNIS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
mkdir -p "$ZIEL" mkdir -p "$ZIEL"
cd "$COMPOSE_VERZEICHNIS" cd "$VERZEICHNIS"
echo "Sichere Datenbank …" # Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \ if systemctl cat proxledger.service >/dev/null 2>&1; then
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz" ART="dienst"
else
ART="docker"
fi
echo "Sichere hochgeladene Dateien …" echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
if [ "$ART" = "docker" ]; then
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
else
# DATABASE_URL steht in der .env; pg_dump versteht sie direkt.
# shellcheck disable=SC1091
set -o allexport
. ./.env
set +o allexport
[ -n "${DATABASE_URL:-}" ] || {
echo "DATABASE_URL fehlt in der .env – ohne sie keine Sicherung." >&2
exit 1
}
echo "Sichere Datenbank …"
pg_dump "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
if [ -d "$UPLOADS" ]; then
tar -czf "${ZIEL}/uploads-${STAND}.tar.gz" -C "$(dirname "$UPLOADS")" "$(basename "$UPLOADS")"
else
echo " (kein Upload-Verzeichnis unter ${UPLOADS})"
fi
fi
echo "Sichere Konfiguration …" echo "Sichere Konfiguration …"
cp .env "${ZIEL}/env-${STAND}.txt" cp .env "${ZIEL}/env-${STAND}.txt"
+25 -3
View File
@@ -6,6 +6,10 @@ installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Reposito
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
`/opt/proxledger/.credentials`. `/opt/proxledger/.credentials`.
**Ressourcen:** Die Vorgabe ist 2 CPU-Kerne, **4 GB RAM** und 12 GB Platte. Die 4 GB braucht
`next build` während der Installation; im Betrieb genügen 2 GB, die sich im Nachhinein in Proxmox
wieder absenken lassen.
| Datei | Zweck | | Datei | Zweck |
|---|---| |---|---|
| `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) | | `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) |
@@ -16,11 +20,29 @@ systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsda
Auf dem Proxmox-VE-Host als root: Auf dem Proxmox-VE-Host als root:
```bash ```bash
bash -c "$(curl -fsSL https://git.wilde-server.de/fw/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)" bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
``` ```
Das Gerüst (`build.func`, `install.func`) lädt das Skript von den community-scripts nach – das ist Das Gerüst (`build.func`) lädt das Skript von den community-scripts nach – das ist öffentlich und ohne
öffentlich und ohne Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git. Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git.
**Wichtig:** Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Ohne
Angabe sucht das Gerüst `install/proxledger-install.sh` bei den community-scripts auf GitHub und
bricht mit *404 – Could not fetch install/proxledger-install.sh* ab. Das ct-Skript setzt deshalb
```bash
COMMUNITY_SCRIPTS_URL="https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts"
```
selbst. Für einen anderen Zweig oder einen Fork lässt sich die Angabe von außen überschreiben:
```bash
COMMUNITY_SCRIPTS_URL=https://git.example.de/<nutzer>/ProxLedger/raw/branch/<zweig>/deploy/community-scripts \
bash -c "$(curl -fsSL …/ct/proxledger.sh)"
```
Das Verzeichnis dahinter muss `ct/` und `install/` enthalten – deshalb zeigt die Angabe auf
`deploy/community-scripts`, nicht auf die Wurzel des Repositorys.
Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist: Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist:
+11 -4
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env bash #!/usr/bin/env bash
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main" # Woher die App-Skripte kommen. Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht
# ermitteln – ohne diese Angabe sucht das Geruest install/proxledger-install.sh bei den
# community-scripts und bricht mit 404 ab. Ueberschreibbar fuer eigene Zweige oder Forks.
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts}"
export COMMUNITY_SCRIPTS_URL
_CS_DEFAULT_URL="$COMMUNITY_SCRIPTS_URL"
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func" _cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func") source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 tteck # Copyright (c) 2021-2026 tteck
# Author: Florian Wilde # Author: Florian Wilde
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.wilde-server.de/fw/ProxLedger # Source: https://git.wilde-server.de/florian.wilde/ProxLedger
APP="ProxLedger" APP="ProxLedger"
var_tags="${var_tags:-monitoring;licensing}" var_tags="${var_tags:-monitoring;licensing}"
var_cpu="${var_cpu:-2}" var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}" # 4 GB fuer die Installation: `next build` braucht deutlich mehr als 2 GB und bricht sonst mit
var_disk="${var_disk:-10}" # einem Speicherfehler ab. Nach der Installation genuegen 2 GB im Betrieb.
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-12}"
var_os="${var_os:-debian}" var_os="${var_os:-debian}"
var_version="${var_version:-13}" var_version="${var_version:-13}"
var_arm64="${var_arm64:-yes}" var_arm64="${var_arm64:-yes}"
@@ -3,7 +3,7 @@
# Copyright (c) 2021-2026 tteck # Copyright (c) 2021-2026 tteck
# Author: Florian Wilde # Author: Florian Wilde
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.wilde-server.de/fw/ProxLedger # Source: https://git.wilde-server.de/florian.wilde/ProxLedger
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color color
@@ -14,7 +14,7 @@ network_check
update_os update_os
# Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist. # Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist.
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/fw/ProxLedger.git}" QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/florian.wilde/ProxLedger.git}"
ZWEIG="${PROXLEDGER_BRANCH:-main}" ZWEIG="${PROXLEDGER_BRANCH:-main}"
msg_info "Installing Dependencies" msg_info "Installing Dependencies"
@@ -40,7 +40,13 @@ msg_info "Setting up ProxLedger"
$STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger $STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger
cd /opt/proxledger cd /opt/proxledger
IP_ADRESSE=$(hostname -I | awk '{print $1}') # Erreichbare Adresse des Containers ermitteln. `hostname -I` liefert sie in aller Regel;
# notfalls hilft die Route nach draussen. 0.0.0.0 waere eine Lausch-Adresse und im Browser nutzlos.
IP_ADRESSE=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -z "$IP_ADRESSE" ] || [ "$IP_ADRESSE" = "0.0.0.0" ]; then
IP_ADRESSE=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {print $7; exit}')
fi
[ -n "$IP_ADRESSE" ] || IP_ADRESSE="localhost"
cat <<EOF >/opt/proxledger/.env cat <<EOF >/opt/proxledger/.env
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public" DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
APP_SECRET="${APP_SECRET}" APP_SECRET="${APP_SECRET}"
@@ -107,6 +113,8 @@ msg_ok "Created Services"
{ {
echo "ProxLedger" echo "ProxLedger"
echo "Adresse: http://${IP_ADRESSE}:3000" echo "Adresse: http://${IP_ADRESSE}:3000"
echo " (Im Journal steht 0.0.0.0:3000 - das ist nur die Lausch-Adresse,"
echo " im Browser gehoert die obige IP oder der spaetere DNS-Name hinein.)"
echo "Benutzer: admin@proxledger.local" echo "Benutzer: admin@proxledger.local"
echo "Passwort: ${ADMIN_PASS}" echo "Passwort: ${ADMIN_PASS}"
echo echo
+2 -1
View File
@@ -181,4 +181,5 @@ printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beisp
echo echo
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht." 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 ' 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" printf ' Aktualisieren: pct exec %s -- bash -c "bash /opt/proxledger/deploy/update.sh"\n' "$CTID"
printf ' Sichern: pct exec %s -- bash -c "bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger"\n' "$CTID"
+85 -17
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ProxLedger aktualisieren (Docker-Installation). # ProxLedger aktualisieren – erkennt selbst, ob die Installation mit Docker
# oder als Dienst im Container (Node + PostgreSQL direkt) läuft.
# #
# bash deploy/update.sh # Quelltext holen, neu bauen, migrieren, neu starten # bash deploy/update.sh # sichern, holen, bauen, migrieren, neu starten
# bash deploy/update.sh --ohne-sicherung # bash deploy/update.sh --ohne-sicherung
# #
# Vor dem Aktualisieren wird gesichert – das dauert Sekunden und erspart schlechte Abende. # Vor dem Aktualisieren wird gesichert – das dauert Sekunden und erspart schlechte Abende.
@@ -23,38 +24,105 @@ fehler() {
SICHERN="ja" SICHERN="ja"
[ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein" [ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein"
command -v docker >/dev/null 2>&1 || fehler "Docker nicht gefunden." # Wohin gesichert wird. Der Standardort gehört root; wer anders arbeitet, setzt SICHERUNG_ZIEL.
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?" SICHERUNG_ZIEL="${SICHERUNG_ZIEL:-/var/backups/proxledger}"
# ---------------------------------------------------------------- Art der Installation
#
# Der systemd-Dienst ist das eindeutigere Merkmal: Die docker-compose.yml liegt auch im
# Quelltext der Dienst-Installation, ein Dienst namens proxledger aber nur dort.
installationsart() {
if systemctl cat proxledger.service >/dev/null 2>&1; then
printf 'dienst'
elif command -v docker >/dev/null 2>&1 && [ -f docker-compose.yml ]; then
printf 'docker'
fi
}
ART=$(installationsart)
[ -n "$ART" ] || fehler "Keine Installation gefunden – weder der Dienst proxledger noch Docker mit docker-compose.yml."
blau "Installationsart: ${ART}"
# ---------------------------------------------------------------- Sicherung
if [ "$SICHERN" = "ja" ]; then if [ "$SICHERN" = "ja" ]; then
blau "Sicherung anlegen …" blau "Sicherung anlegen nach ${SICHERUNG_ZIEL} …"
bash deploy/backup.sh if ! bash deploy/backup.sh "$SICHERUNG_ZIEL"; then
fehler "Die Sicherung ist fehlgeschlagen – deshalb wird nicht aktualisiert.
Häufigste Ursache: fehlende Rechte am Zielordner. Mögliche Wege:
sudo bash deploy/update.sh
SICHERUNG_ZIEL=/pfad/mit/rechten bash deploy/update.sh
bash deploy/update.sh --ohne-sicherung (nur wenn anderweitig gesichert ist)"
fi
gruen "Sicherung abgeschlossen." gruen "Sicherung abgeschlossen."
else
printf '\033[1;33m! Ohne Sicherung – auf eigenes Risiko.\033[0m\n'
fi fi
# ---------------------------------------------------------------- Quelltext
if [ -d .git ]; then if [ -d .git ]; then
blau "Neuen Stand holen …" blau "Neuen Stand holen …"
git pull --ff-only git pull --ff-only
else
printf '\033[1;33m! Kein Git-Verzeichnis – der Quelltext bleibt wie er ist.\033[0m\n'
fi fi
blau "Abbilder neu bauen …" # ---------------------------------------------------------------- Aktualisieren
docker compose build --pull if [ "$ART" = "docker" ]; then
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
blau "Datenbank migrieren …" blau "Abbilder neu bauen …"
docker compose run --rm migrate docker compose build --pull
blau "Dienste neu starten …" blau "Datenbank migrieren …"
docker compose up -d docker compose run --rm migrate
# Kurz warten und nachsehen, ob das Dashboard wieder antwortet. blau "Dienste neu starten …"
docker compose up -d
else
command -v npm >/dev/null 2>&1 || fehler "npm nicht gefunden – gehört zur Installation als Dienst."
[ -f .env ] || fehler "Keine .env gefunden (erwartet in ${VERZEICHNIS})."
# Die .env trägt APP_SECRET; ein git pull fasst sie nicht an, aber sicher ist sicher.
cp .env "/tmp/proxledger-env-$(date +%s).sicherung"
blau "Abhängigkeiten installieren …"
npm ci
blau "Datenbank migrieren …"
npx prisma generate
npx prisma migrate deploy
blau "Anwendung bauen …"
npm run build
npm run build:worker
blau "Dienste neu starten …"
systemctl restart proxledger proxledger-worker
fi
# ---------------------------------------------------------------- Prüfen
blau "Warte auf das Dashboard …" blau "Warte auf das Dashboard …"
for versuch in $(seq 1 30); do PORT="${PORT:-3000}"
if docker compose exec -T web wget -qO- http://localhost:3000/api/gesundheit >/dev/null 2>&1; then
for versuch in $(seq 1 45); do
if [ "$ART" = "docker" ]; then
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
else
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
fi
if [ "$erreichbar" = "ja" ]; then
gruen "ProxLedger läuft wieder (nach ${versuch} s)." gruen "ProxLedger läuft wieder (nach ${versuch} s)."
docker compose ps [ "$ART" = "docker" ] && docker compose ps
[ "$ART" = "dienst" ] && systemctl --no-pager --lines=0 status proxledger proxledger-worker | grep -E "●|Active:"
exit 0 exit 0
fi fi
sleep 1 sleep 1
done done
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web" if [ "$ART" = "docker" ]; then
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
else
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: journalctl -u proxledger -n 50"
fi
+1 -1
View File
@@ -60,7 +60,7 @@ services:
volumes: volumes:
- uploads:/data - uploads:/data
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+31 -8
View File
@@ -175,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
--- ---
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
## 4a. Cluster ## 4a. Cluster
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei **In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
@@ -396,31 +399,46 @@ Rückweg immer nutzbar.
**Sichern:** **Sichern:**
```bash ```bash
./deploy/backup.sh /var/backups/proxledger bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger
``` ```
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env`. Für regelmäßige Sicherungen in die Das Skript sichert Datenbank, hochgeladene PDFs und die `.env` – und erkennt selbst, ob die
Crontab eintragen: Installation mit Docker oder als Dienst im Container läuft. Für regelmäßige Sicherungen in die Crontab:
``` ```
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger 30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
``` ```
**Zurückspielen:** **Zurückspielen** (Docker):
```bash ```bash
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
``` ```
Bei der Installation als Dienst stattdessen:
```bash
gunzip -c datenbank-20260917-023000.sql.gz | psql "$DATABASE_URL"
```
**Aktualisieren:** **Aktualisieren:**
```bash ```bash
cd /opt/proxledger bash /opt/proxledger/deploy/update.sh
git pull
docker compose up -d --build
``` ```
Migrationen laufen dabei automatisch. Ein Befehl für beide Installationsarten. Der Reihe nach: sichern, neuen Stand holen, bauen,
migrieren, neu starten – und zum Schluss prüfen, ob das Dashboard wieder antwortet. Schlägt die
Sicherung fehl, bricht das Skript ab, **bevor** etwas verändert wird.
| Aufruf | Wirkung |
|---|---|
| `bash deploy/update.sh` | der übliche Weg |
| `SICHERUNG_ZIEL=/pfad bash deploy/update.sh` | anderer Ort für die Sicherung |
| `bash deploy/update.sh --ohne-sicherung` | nur, wenn anderweitig gesichert ist |
Beim LXC-Weg nach Art der Helper-Scripts geht es auch über das Skript auf dem Proxmox-Host – dort
erkennt das Gerüst eine vorhandene Installation und bietet die Aktualisierung an.
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine > `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt. > Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
@@ -458,6 +476,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
## Anhang: dunkles Design und Mobilansicht ## Anhang: dunkles Design und Mobilansicht
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
übrigen Angaben stehen auf der jeweiligen Detailseite.
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
Kopfleiste umschalten. Kopfleiste umschalten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 252 KiB

+2 -2
View File
@@ -20,7 +20,7 @@ die Domäne, unter der sie entstanden sind, und die Adresse steckt im Installati
**Docker** (beliebiger Host mit Docker): **Docker** (beliebiger Host mit Docker):
```bash ```bash
git clone <repo> proxledger && cd proxledger git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env cp .env.example .env
# Pflicht: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL # Pflicht: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d docker compose up -d
@@ -34,7 +34,7 @@ bash deploy/lxc-installer.sh # Container mit Docker darin
```bash ```bash
# Nach Art der Proxmox VE Helper-Scripts, ohne Docker: # Nach Art der Proxmox VE Helper-Scripts, ohne Docker:
bash -c "$(curl -fsSL https://<git>/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)" bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
``` ```
Der erste Administrator entsteht beim Einrichten aus `ADMIN_EMAIL` und `ADMIN_PASSWORD`; beim Der erste Administrator entsteht beim Einrichten aus `ADMIN_EMAIL` und `ADMIN_PASSWORD`; beim
+7 -2
View File
@@ -14,10 +14,15 @@ Maschinenkennung, nicht an einem Token.
## Sichern ## Sichern
```bash ```bash
bash deploy/backup.sh # Datenbank und Uploads bash deploy/backup.sh /var/backups/proxledger
``` ```
Beim LXC-Weg reicht zusätzlich die gewohnte Proxmox-Sicherung des Containers. Das Skript erkennt selbst, ob die Installation mit Docker oder als Dienst läuft, und sichert in beiden
Fällen Datenbank, Uploads und `.env`. Beim LXC-Weg reicht zusätzlich die gewohnte Proxmox-Sicherung
des Containers.
Vor jeder Aktualisierung läuft es ohnehin automatisch – [`deploy/update.sh`](../../deploy/update.sh)
ruft es als Erstes auf und bricht ab, wenn es fehlschlägt.
## Zurückspielen ## Zurückspielen
@@ -0,0 +1,33 @@
# #031 Update- und Sicherungsskript für beide Installationsarten
**Status:** erledigt
**Meilenstein:** v0.20.0
**Labels:** deployment
**Geschätzt:** 2 h **Tatsächlich:** 0 h 45 min
## Ziel
`deploy/update.sh` und `deploy/backup.sh` konnten nur Docker. Die Installation als Dienst (Node und
PostgreSQL direkt im LXC) ging leer aus – dort half nur der Weg über das Gerüst der community-scripts.
## Aufgaben
- [x] Beide Skripte erkennen die Installationsart selbst (systemd-Dienst oder Docker)
- [x] Dienst-Weg: `npm ci`, `prisma migrate deploy`, bauen, `systemctl restart`
- [x] Sicherung für den Dienst-Weg: `pg_dump` über `DATABASE_URL`, Uploads aus `UPLOAD_DIR`
- [x] Fehlgeschlagene Sicherung bricht die Aktualisierung ab – **vor** jeder Änderung
- [x] Hinweis am Ende von `deploy/lxc-installer.sh` auf die Skripte umgestellt
- [x] Handbuch und Betriebsdoku angepasst
- [x] Docker-Weg vollständig durchgespielt
## Zwei Fehler beim Durchspielen gefunden
1. **Die Zustandsprüfung des Web-Containers konnte nie gelingen.** Next bindet im Standalone-Modus an
die Adresse aus `HOSTNAME` – und Docker setzt darin den Containernamen. Der Server lauschte also
nur auf der Container-IP, nicht auf `127.0.0.1`. Der Dienst `web` blieb dauerhaft auf
„health: starting“; von außen war er trotzdem erreichbar, deshalb fiel es bisher nicht auf.
Behoben mit `ENV HOSTNAME=0.0.0.0` im Dockerfile; die Prüfungen verwenden jetzt `127.0.0.1`
statt `localhost` (das löst im Container auch auf `::1` auf).
2. **Die Sicherung scheiterte stumm an fehlenden Rechten** (`/var/backups` gehört root) und die
Aktualisierung lief trotzdem weiter. Jetzt bricht sie ab und nennt die drei Auswege.
## Prüfung
Vollständiger Durchlauf gegen einen laufenden Stapel: Sicherung (Datenbank 6,3 KB mit 25 Tabellen,
Uploads, `.env`), Abbilder gebaut, migriert, neu gestartet, Dashboard nach zwei Sekunden wieder da.
Danach stand `web` zum ersten Mal auf **healthy**.
@@ -0,0 +1,27 @@
# #032 LXC-Installation: Herkunft der Skripte und Speicher
**Status:** erledigt
**Meilenstein:** v0.20.1
**Labels:** deployment
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Auslöser
Erster echter Lauf auf `hv-rz5-pve-02`: Der Container entstand, dann brach es ab mit
```
curl: (22) The requested URL returned error: 404
✖️ Could not fetch install/proxledger-install.sh
📁 Looked in: https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main
```
## Ursache
Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Das Gerüst setzt dann
die Standardadresse der community-scripts ein und sucht dort `install/proxledger-install.sh`.
## Behebung
- Das ct-Skript setzt `COMMUNITY_SCRIPTS_URL` selbst auf die eigene Gitea-Adresse (für Forks und
andere Zweige weiterhin überschreibbar).
- Vorgabe für den Arbeitsspeicher von 2 auf **4 GB** angehoben: `next build` braucht während der
Installation deutlich mehr als 2 GB und wäre nach zehn Minuten am Speicher gescheitert.
- Die ermittelte Adresse wird geprüft; `hostname -I` ohne Ergebnis fällt auf die Route nach außen
zurück, und in den Zugangsdaten steht der Hinweis, dass `0.0.0.0` aus dem Journal keine
aufrufbare Adresse ist.
@@ -0,0 +1,35 @@
# #033 Abmelden führte auf die Lausch-Adresse
**Status:** erledigt
**Meilenstein:** v0.20.1
**Labels:** bug, deployment
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
## Auslöser
Im Docker-Betrieb führte **Abmelden** auf `0.0.0.0:3000/login` – eine Adresse, die kein Browser
aufrufen kann (`ERR_ADDRESS_INVALID`).
## Ursache
Hausgemacht, und zwar in #031: Für die Zustandsprüfung des Containers wurde `ENV HOSTNAME=0.0.0.0`
gesetzt, damit der Server auf allen Schnittstellen lauscht. Next.js macht eine Weiterleitung aus
einer Server Action aber **serverseitig absolut** und nimmt dafür genau diese Adresse. Vorher stand
dort der Containername (`http://0a5963a591df:3000/`) – derselbe Fehler, nur weniger auffällig.
Betroffen waren alle Weiterleitungen, die nicht über eine `Request` liefen: Abmelden, die Prüfung in
`sitzungErforderlich` und das Löschen eines Kunden.
## Behebung
- Neuer Helfer `eigenerPfad()` in `src/lib/adresse.ts`: baut die Adresse aus Einstellung, `APP_URL`
oder den Kopfzeilen der Anfrage – nie aus der Lausch-Adresse.
- `oeffentlicheBasis()` übergeht unbrauchbare Angaben (`0.0.0.0`, `::`) jetzt ausdrücklich, statt sie
zu übernehmen. Eine falsch eingetragene Adresse macht das Dashboard damit nicht unbenutzbar.
- Zwei zusätzliche Tests.
## Prüfung
Gegen einen Docker-Stapel durchgespielt: Mit `APP_URL=http://0.0.0.0:3000` in der `.env` – also der
Fehlkonfiguration – leitet die Anwendung jetzt auf den tatsächlich aufgerufenen Namen
(`http://localhost:3003/login`) statt auf die Lausch-Adresse.
## Lehre
Der Fix für die Zustandsprüfung hat einen zweiten Fehler sichtbar gemacht, der vorher nur anders
aussah. Weiterleitungen gehören grundsätzlich aus der **konfigurierten** Adresse gebaut, nicht aus
dem, was der Server zufällig über sich selbst weiß.
+28
View File
@@ -0,0 +1,28 @@
# #034 Bedienung auf dem Handy
**Status:** erledigt
**Meilenstein:** v0.20.2
**Labels:** ui, bug
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
rechts die Hälfte abgeschnitten war.
## Ursache
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
## Behebung
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
- Schließt beim Navigieren und mit Escape
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
- Fünf Tests für die Zustandslogik
## Nicht geändert
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
+28
View File
@@ -0,0 +1,28 @@
# #035 Tabellen auf dem Handy als Karten
**Status:** erledigt
**Meilenstein:** v0.21.0
**Labels:** ui
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
## Auslöser
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
brachen Buchstabe für Buchstabe um.
## Aufgaben
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
ihre Beschriftung aus `data-label` davor
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
## Warum Karten statt Scrollen
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
## Nebenbefund
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
das Kästchen behält seine Größe.
+22
View File
@@ -0,0 +1,22 @@
# #036 Standort am Host setzen
**Status:** erledigt
**Meilenstein:** v0.21.0
**Labels:** bug, ui
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
## Auslöser
„Den Standort bei den Hosts kann man nicht einstellen.“
## Was wirklich los war
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
## Behebung
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
Zuordnung wäre schlimmer als keine
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
+7 -1
View File
@@ -34,4 +34,10 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min | | [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min |
| [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min | | [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min |
| [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min | | [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min |
| | **Summe** | | | **147 h** | **15 h 23 min** | | [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
| | **Summe** | | | **157 h** | **18 h 58 min** |
+54 -54
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.17.0", "version": "0.19.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.17.0", "version": "0.19.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -14,13 +14,13 @@
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.2", "@simplewebauthn/server": "^14.0.3",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -35,7 +35,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
@@ -1646,15 +1646,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
"integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
"integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1695,9 +1695,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
"integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1711,9 +1711,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
"integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1727,9 +1727,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
"integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1746,9 +1746,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
"integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1765,9 +1765,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
"integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1784,9 +1784,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
"integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1803,9 +1803,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
"integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1819,9 +1819,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
"integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3285,9 +3285,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/server": { "node_modules/@simplewebauthn/server": {
"version": "14.0.2", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
"integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==", "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hexagon/base64": "^1.1.27", "@hexagon/base64": "^1.1.27",
@@ -5889,13 +5889,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
"integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.3.5", "@next/eslint-plugin-next": "16.3.8",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -8184,12 +8184,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
"integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.5", "@next/env": "16.3.8",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -8203,14 +8203,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.5", "@next/swc-darwin-arm64": "16.3.8",
"@next/swc-darwin-x64": "16.3.5", "@next/swc-darwin-x64": "16.3.8",
"@next/swc-linux-arm64-gnu": "16.3.5", "@next/swc-linux-arm64-gnu": "16.3.8",
"@next/swc-linux-arm64-musl": "16.3.5", "@next/swc-linux-arm64-musl": "16.3.8",
"@next/swc-linux-x64-gnu": "16.3.5", "@next/swc-linux-x64-gnu": "16.3.8",
"@next/swc-linux-x64-musl": "16.3.5", "@next/swc-linux-x64-musl": "16.3.8",
"@next/swc-win32-arm64-msvc": "16.3.5", "@next/swc-win32-arm64-msvc": "16.3.8",
"@next/swc-win32-x64-msvc": "16.3.5", "@next/swc-win32-x64-msvc": "16.3.8",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
"peerDependencies": { "peerDependencies": {
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.19.1", "version": "0.21.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -35,13 +35,13 @@
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.2", "@simplewebauthn/server": "^14.0.3",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -56,7 +56,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
+2 -1
View File
@@ -195,7 +195,8 @@ async function main() {
console.log(` ${seite}-dunkel.png`); console.log(` ${seite}-dunkel.png`);
} }
for (const seite of ["dashboard", "hosts"]) { // Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
const eintrag = SEITEN.find((s) => s.name === seite); const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse); await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`); console.log(` mobil-${seite}.png`);
+12 -26
View File
@@ -16,7 +16,7 @@ import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige"; import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { hostKundeSetzen } from "@/server/actions/host-detail"; import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
import { hostTokenWiderrufen } from "@/server/actions/tokens"; import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste"; import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts"; import { meldungsGrenze } from "@/server/queries/hosts";
@@ -43,7 +43,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
const kunden = await prisma.kunde.findMany({ const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: {
id: true,
name: true,
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
},
}); });
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022). // Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
@@ -167,30 +171,12 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile> <Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile> <Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
<form action={hostKundeSetzen} className="mt-3"> <KundeStandortFormular
<input type="hidden" name="hostId" value={host.id} /> hostId={host.id}
<label className="form-label small" htmlFor="kundeId"> kunden={kunden}
Kunde ändern kundeId={host.kundeId}
</label> standortId={host.standortId}
<div className="input-group input-group-sm"> />
<select
id="kundeId"
name="kundeId"
className="form-select"
defaultValue={host.kundeId ?? ""}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
</form>
</Card> </Card>
</div> </div>
+16 -15
View File
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p> <p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<div style={{ minWidth: "820px" }}> <div className="pl-timeline">
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}> <div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
{monate.map((monat) => ( {monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}> <div key={monat} style={{ width: `${100 / 12}%` }}>
{monat} {monat}
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
: "var(--pl-info)"; : "var(--pl-info)";
return ( return (
<div key={eintrag.hostId} className="d-flex align-items-center mb-1"> <div key={eintrag.hostId} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "13rem" }}> <div className="small text-truncate pl-timeline-name">
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none"> <Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname} {eintrag.hostname}
</Link> </Link>
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
)} )}
<p className="small text-body-secondary mb-0"> <p className="small text-body-secondary mb-0">
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb <span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen. unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
</p> </p>
</Card> </Card>
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
tabelle tabelle
> >
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th> <th scope="col">Host</th>
<th scope="col">Ablauf</th>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
<th scope="col">Key</th> <th scope="col">Key</th>
<th scope="col">Proxmox</th> <th scope="col">Proxmox</th>
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
<tbody> <tbody>
{naechste90Tage.map((eintrag) => ( {naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}> <tr key={eintrag.hostId}>
<td> <td className="pl-karten-kopf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} /> <Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname} {eintrag.hostname}
</Link> </Link>
</td> </td>
<td> <td data-label="Ablauf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td data-label="Kunde">
{eintrag.kunde ? ( {eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name} {eintrag.kunde.name}
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
<span className="badge pl-bg-neutral">Nicht zugeordnet</span> <span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)} )}
</td> </td>
<td> <td data-label="Key">
<CopyableCode wert={eintrag.key} /> <CopyableCode wert={eintrag.key} />
</td> </td>
<td> <td data-label="Proxmox">
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} /> <EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td> </td>
<td> <td data-label="Kundenrechnung">
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} /> <VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td> </td>
</tr> </tr>
+10 -8
View File
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
} }
> >
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
const punkte = rechnungspunkte.get(kunde.id); const punkte = rechnungspunkte.get(kunde.id);
return ( return (
<tr key={kunde.id}> <tr key={kunde.id}>
<td> <td className="pl-karten-kopf">
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none"> <Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name} {kunde.name}
</Link> </Link>
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div> <div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td> </td>
<td className="pl-mono">{kunde.kundennummer}</td> <td className="pl-mono" data-label="Kundennummer">
<td>{kunde.hosts}</td> {kunde.kundennummer}
<td>{kunde.aktiv}</td> </td>
<td> <td data-label="Hosts">{kunde.hosts}</td>
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
<td data-label="Nächster Ablauf">
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} /> <AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
</td> </td>
<td> <td data-label="Warnungen">
{kunde.offeneAlerts > 0 ? ( {kunde.offeneAlerts > 0 ? (
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge> <Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
) : ( ) : (
<Badge farbe="success">keine</Badge> <Badge farbe="success">keine</Badge>
)} )}
</td> </td>
<td> <td data-label="Rechnungen">
{punkte && punkte.offen > 0 ? ( {punkte && punkte.offen > 0 ? (
<Badge farbe="danger">{punkte.offen} offen</Badge> <Badge farbe="danger">{punkte.offen} offen</Badge>
) : ( ) : (
+22 -8
View File
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
<thead> <thead>
<tr> <tr>
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
per Seitwärtsscrollen ins Bild (#034). */}
<th scope="col">Host</th> <th scope="col">Host</th>
<th scope="col">Kunde</th> <th scope="col" className="d-none d-md-table-cell">
<th scope="col">Key</th> Kunde
</th>
<th scope="col" className="d-none d-lg-table-cell">
Key
</th>
<th scope="col">Ablauf</th> <th scope="col">Ablauf</th>
<th scope="col">Proxmox</th> <th scope="col" className="d-none d-sm-table-cell">
<th scope="col">Kundenrechnung</th> Proxmox
</th>
<th scope="col" className="d-none d-sm-table-cell">
Kundenrechnung
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {host.meldetNicht ? <KeineMeldungBadge /> : null}
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
<div className="small text-body-secondary d-md-none">
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
</div>
</td> </td>
<td> <td className="d-none d-md-table-cell">
{host.kunde ? ( {host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name} {host.kunde.name}
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
</Link> </Link>
)} )}
</td> </td>
<td> <td className="d-none d-lg-table-cell">
<CopyableCode wert={host.subKey} /> <CopyableCode wert={host.subKey} />
</td> </td>
<td> <td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} /> <AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td> </td>
<td> <td className="d-none d-sm-table-cell">
<EinkaufBadge bezahlt={host.einkaufBezahlt} /> <EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td> </td>
<td> <td className="d-none d-sm-table-cell">
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} /> <VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td> </td>
</tr> </tr>
+22 -11
View File
@@ -197,7 +197,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</form> </form>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute); const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return ( return (
<tr key={zeile.id}> <tr key={zeile.id}>
<td> {/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
<td className="d-md-none pl-karten-kopf">
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td data-label="Kunde">
{zeile.kunde ? ( {zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name} {zeile.kunde.name}
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td className="d-none d-lg-table-cell"> <td className="d-none d-lg-table-cell" data-label="Laufzeit">
<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>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null} {zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td> </td>
<td> <td data-label="Proxmox-Rechnung">
<CopyableCode wert={zeile.einkaufNummer} /> <CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? ( {zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger"> <div className="small pl-text-danger">
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div> </div>
) : null} ) : null}
</td> </td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td> <td className="d-none d-xl-table-cell" data-label="Einkauf">
<td className="text-center"> {formatEuro(zeile.einkaufBetrag)}
</td>
<td className="text-center" data-label="Einkauf bezahlt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Proxmox-Rechnung bezahlt" titel="Proxmox-Rechnung bezahlt"
/> />
</td> </td>
<td> <td data-label="Kundenrechnung">
<CopyableCode wert={zeile.verkaufNummer} /> <CopyableCode wert={zeile.verkaufNummer} />
</td> </td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td> <td className="d-none d-xl-table-cell" data-label="Verkauf">
<td className="text-center"> {formatEuro(zeile.verkaufBetrag)}
</td>
<td className="text-center" data-label="Rechnung erstellt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung erstellt" titel="Kundenrechnung erstellt"
/> />
</td> </td>
<td className="text-center"> <td className="text-center" data-label="Vom Kunden bezahlt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
/> />
</td> </td>
<td <td
data-label="Marge"
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`} 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)}
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
})} })}
{zeilen.length === 0 ? ( {zeilen.length === 0 ? (
<tr> <tr>
<td colSpan={11} className="text-center text-body-secondary py-4"> <td colSpan={12} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link> Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td> </td>
</tr> </tr>
+131
View File
@@ -186,6 +186,19 @@ body {
display: none; display: none;
} }
/*
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
* damit sie auch per Tastatur erreichbar ist.
*/
.pl-menu-hintergrund {
position: fixed;
inset: 0;
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
padding: 0;
background-color: rgba(0, 0, 0, 0.5);
border: 0;
}
/* ---------- Inhaltsbereich ---------- */ /* ---------- Inhaltsbereich ---------- */
.app-content-header { .app-content-header {
@@ -369,10 +382,128 @@ body {
transform: translateX(-50%); transform: translateX(-50%);
} }
/* Zeitleiste der Jahresbahn: Breiten als Klassen, damit sie auf dem Telefon kleiner ausfallen. */
.pl-timeline {
min-width: 820px;
}
.pl-timeline-kopf {
margin-left: 13rem;
}
.pl-timeline-name {
width: 13rem;
}
/* ---------- Mobil ---------- */ /* ---------- Mobil ---------- */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.pl-touch-target { .pl-touch-target {
min-height: 44px; min-height: 44px;
} }
/*
* Ein Kontrollkästchen darf nicht selbst 44 px hoch werden – sonst steht in der Zeile ein
* großer leerer Kasten. Die Fläche zum Treffen bekommt stattdessen sein Umfeld (#035).
*/
.pl-haken {
min-width: 44px;
min-height: 44px;
}
.pl-haken .form-check-input {
width: 1.4rem;
height: 1.4rem;
}
}
/*
* Breite Tabellen auf dem Telefon: Jede Zeile wird zur Karte, jede Zelle bekommt ihre
* Beschriftung aus `data-label` davor. Das ersetzt das Scrollen nach rechts, bei dem die
* Hälfte der Angaben unsichtbar bleibt (#035).
*
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
*/
@media (max-width: 767.98px) {
.pl-karten-tabelle,
.pl-karten-tabelle tbody,
.pl-karten-tabelle tr,
.pl-karten-tabelle td {
display: block;
width: 100%;
}
.pl-karten-tabelle thead {
display: none;
}
.pl-karten-tabelle tr {
padding: 0.35rem 0.25rem;
border-bottom: 1px solid var(--bs-border-color);
}
.pl-karten-tabelle tr:last-child {
border-bottom: 0;
}
.pl-karten-tabelle td {
display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.3rem 0.75rem;
text-align: right;
border: 0;
}
.pl-karten-tabelle td::before {
flex: 0 0 auto;
font-size: 0.8125rem;
color: var(--bs-secondary-color);
text-align: left;
content: attr(data-label);
}
/* Die erste Zelle trägt den Namen – sie steht als Überschrift der Karte für sich. */
.pl-karten-tabelle td.pl-karten-kopf {
display: block;
padding-top: 0.6rem;
font-size: 1.0625rem;
text-align: left;
}
.pl-karten-tabelle td.pl-karten-kopf::before {
content: none;
}
/* Lange Namen und Schlüssel sollen umbrechen, aber nicht Buchstabe für Buchstabe. */
.pl-karten-tabelle td a,
.pl-karten-tabelle td .pl-mono {
overflow-wrap: anywhere;
word-break: normal;
}
/* Auf dem Telefon bleibt mehr Platz für die Monate, wenn die Namensspalte schrumpft. */
.pl-timeline {
min-width: 560px;
}
.pl-timeline-kopf {
margin-left: 7rem;
}
.pl-timeline-name {
width: 7rem;
}
/* Kartenkopf mit Knöpfen: untereinander statt nebeneinander über den Rand hinaus. */
.card-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.card-header .card-tools {
flex-wrap: wrap;
width: 100%;
}
} }
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { hostKundeSetzen } from "@/server/actions/host-detail";
export interface KundeMitStandorten {
id: string;
name: string;
standorte: { id: string; name: string }[];
}
/**
* Kunde und Standort eines Hosts ändern (#036).
*
* Beides gehört in **ein** Formular: Die Standorte hängen am Kunden, und ein Formular, das nur den
* Kunden sendet, würde den Standort beim Speichern mitlöschen – genau das passierte vorher.
*/
export function KundeStandortFormular({
hostId,
kunden,
kundeId,
standortId,
}: {
hostId: string;
kunden: KundeMitStandorten[];
kundeId: string | null;
standortId: string | null;
}) {
const [gewaehlterKunde, setGewaehlterKunde] = useState(kundeId ?? "");
const standorte = kunden.find((kunde) => kunde.id === gewaehlterKunde)?.standorte ?? [];
return (
<form action={hostKundeSetzen} className="mt-3">
<input type="hidden" name="hostId" value={hostId} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</label>
<select
id="kundeId"
name="kundeId"
className="form-select form-select-sm mb-2"
value={gewaehlterKunde}
onChange={(e) => setGewaehlterKunde(e.target.value)}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<label className="form-label small" htmlFor="standortId">
Standort
</label>
<div className="input-group input-group-sm">
<select
id="standortId"
name="standortId"
className="form-select"
defaultValue={standortId ?? ""}
disabled={standorte.length === 0}
// `key` erzwingt ein frisches Feld, wenn der Kunde wechselt – sonst bliebe die alte Auswahl stehen.
key={gewaehlterKunde}
>
<option value="">– keiner –</option>
{standorte.map((standort) => (
<option key={standort.id} value={standort.id}>
{standort.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
{gewaehlterKunde && standorte.length === 0 ? (
<div className="form-text">
Für diesen Kunden gibt es noch keinen Standort. Anlegen lässt er sich auf der Kundenseite.
</div>
) : null}
</form>
);
}
+3 -9
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { SearchDialog, type SuchEintrag } from "./SearchDialog"; import { SearchDialog, type SuchEintrag } from "./SearchDialog";
import type { Rolle } from "@/generated/prisma/enums"; import type { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_TEXT } from "@/server/auth/rollen"; import { ROLLEN_TEXT } from "@/server/auth/rollen";
import { menuUmschalten } from "./menuZustand";
export interface KopfleistenAlert { export interface KopfleistenAlert {
id: string; id: string;
@@ -58,16 +59,9 @@ export function Header({
return () => window.removeEventListener("keydown", tasten); return () => window.removeEventListener("keydown", tasten);
}, []); }, []);
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */ /** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
const sidebarUmschalten = useCallback(() => { const sidebarUmschalten = useCallback(() => {
const body = document.body; menuUmschalten();
if (window.innerWidth < 992) {
body.classList.toggle("sidebar-open");
body.classList.remove("sidebar-collapse");
} else {
body.classList.toggle("sidebar-collapse");
body.classList.remove("sidebar-open");
}
}, []); }, []);
function themaUmschalten() { function themaUmschalten() {
+40 -4
View File
@@ -2,10 +2,11 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useEffect, useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo"; import { LogoMitSchriftzug } from "@/components/Logo";
import type { Rolle } from "@/generated/prisma/enums"; import type { Rolle } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { menuSchliessen, useMenuOffen } from "./menuZustand";
interface MenuEintrag { interface MenuEintrag {
href: string; href: string;
@@ -30,6 +31,21 @@ export function Sidebar({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
const menuOffen = useMenuOffen();
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
useEffect(() => {
menuSchliessen();
}, [pathname]);
// Escape schließt, wie bei jedem anderen Overlay auch.
useEffect(() => {
function taste(ereignis: KeyboardEvent) {
if (ereignis.key === "Escape") menuSchliessen();
}
window.addEventListener("keydown", taste);
return () => window.removeEventListener("keydown", taste);
}, []);
const haupt: MenuEintrag[] = [ const haupt: MenuEintrag[] = [
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" }, { href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
@@ -105,11 +121,30 @@ export function Sidebar({
} }
return ( return (
<aside className="app-sidebar shadow" data-bs-theme="dark"> <>
<div className="sidebar-brand"> {/* Fläche hinter dem Menü: antippen schließt. Ohne sie bleibt das Menü auf dem Handy stehen. */}
{menuOffen ? (
<button
type="button"
className="pl-menu-hintergrund d-lg-none"
aria-label="Menü schließen"
onClick={menuSchliessen}
/>
) : null}
<aside className="app-sidebar shadow" data-bs-theme="dark">
<div className="sidebar-brand d-flex align-items-center justify-content-between">
<Link href="/" className="brand-link"> <Link href="/" className="brand-link">
<LogoMitSchriftzug variante="sidebar" groesse={34} /> <LogoMitSchriftzug variante="sidebar" groesse={34} />
</Link> </Link>
<button
type="button"
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
aria-label="Menü schließen"
onClick={menuSchliessen}
>
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
</div> </div>
<div className="pl-sidebar-user"> <div className="pl-sidebar-user">
@@ -147,6 +182,7 @@ export function Sidebar({
</ul> </ul>
</nav> </nav>
</div> </div>
</aside> </aside>
</>
); );
} }
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useSyncExternalStore } from "react";
/**
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
*
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
* Kopfzeile und Menü gemeinsam hören.
*/
const SCHMAL = 992;
let offen = false;
const zuhoerer = new Set<() => void>();
function melden() {
for (const ruf of zuhoerer) ruf();
}
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
function klassenSetzen() {
const body = document.body;
body.classList.toggle("sidebar-open", offen);
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
}
export function menuUmschalten() {
if (window.innerWidth >= SCHMAL) {
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
document.body.classList.toggle("sidebar-collapse");
document.body.classList.remove("sidebar-open");
return;
}
offen = !offen;
document.body.classList.remove("sidebar-collapse");
klassenSetzen();
melden();
}
export function menuSchliessen() {
if (!offen) return;
offen = false;
klassenSetzen();
melden();
}
function abonnieren(ruf: () => void) {
zuhoerer.add(ruf);
return () => {
zuhoerer.delete(ruf);
};
}
export function useMenuOffen(): boolean {
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
return useSyncExternalStore(
abonnieren,
() => offen,
() => false,
);
}
+2 -2
View File
@@ -37,7 +37,7 @@ export function StatusHaken({
} }
return ( return (
<span className="d-inline-flex align-items-center justify-content-center"> <span className="pl-haken d-inline-flex align-items-center justify-content-center">
{laeuft ? ( {laeuft ? (
<span <span
className="spinner-border spinner-border-sm text-body-secondary" className="spinner-border spinner-border-sm text-body-secondary"
@@ -47,7 +47,7 @@ export function StatusHaken({
) : ( ) : (
<input <input
type="checkbox" type="checkbox"
className="form-check-input pl-touch-target" className="form-check-input"
checked={wert} checked={wert}
disabled={deaktiviert} disabled={deaktiviert}
onChange={(e) => umschalten(e.target.checked)} onChange={(e) => umschalten(e.target.checked)}
+38 -19
View File
@@ -9,31 +9,34 @@
* sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber * sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber
* unnötig. * unnötig.
*/ */
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL { /**
if (eingetragen) { * Lausch-Adressen taugen nicht als Ziel für einen Browser: `0.0.0.0` heißt „alle Schnittstellen“.
try { * Steht so etwas in der Konfiguration, wird es übergangen statt eine unerreichbare Seite zu bauen.
return new URL(eingetragen); */
} catch { const UNBRAUCHBAR = new Set(["0.0.0.0", "::", "[::]", "", "0"]);
// Unbrauchbarer Eintrag: weiter mit den übrigen Quellen.
}
}
if (process.env.APP_URL) { function brauchbar(adresse: string | undefined): URL | null {
try { if (!adresse) return null;
return new URL(process.env.APP_URL); try {
} catch { const ergebnis = new URL(adresse);
// ebenso return UNBRAUCHBAR.has(ergebnis.hostname) ? null : ergebnis;
} } catch {
return null;
} }
}
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
const ausEinstellung = brauchbar(eingetragen);
if (ausEinstellung) return ausEinstellung;
const ausUmgebung = brauchbar(process.env.APP_URL);
if (ausUmgebung) return ausUmgebung;
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) { if (gastgeber) {
const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", ""); const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
try { const ausKopfzeile = brauchbar(`${schema}://${gastgeber}`);
return new URL(`${schema}://${gastgeber}`); if (ausKopfzeile) return ausKopfzeile;
} catch {
// ebenso
}
} }
return new URL(request.url); return new URL(request.url);
@@ -43,3 +46,19 @@ export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL { export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL {
return new URL(pfad, oeffentlicheBasis(request, eingetragen)); return new URL(pfad, oeffentlicheBasis(request, eingetragen));
} }
/**
* Dasselbe für Server Actions und Seiten, die keine `Request` zur Hand haben.
*
* Nötig, weil Next.js eine Weiterleitung aus einer Server Action serverseitig absolut macht – und
* dafür die Adresse nimmt, auf die der Server lauscht. Im Container ist das `0.0.0.0` (oder der
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
*/
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
const { headers } = await import("next/headers");
const kopfzeilen = await headers();
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
return new URL(pfad, basis).toString();
}
+5 -1
View File
@@ -2,12 +2,16 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { SITZUNGS_COOKIE } from "@/lib/sitzung"; import { SITZUNGS_COOKIE } from "@/lib/sitzung";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { einstellungen } from "@/server/einstellungen";
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */ /** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
export async function abmelden() { export async function abmelden() {
(await cookies()).delete(SITZUNGS_COOKIE); (await cookies()).delete(SITZUNGS_COOKIE);
await protokolliere("abmeldung", "Benutzer", null, {}); await protokolliere("abmeldung", "Benutzer", null, {});
redirect("/login");
const { dashboardUrl } = await einstellungen();
redirect(await eigenerPfad("/login", dashboardUrl));
} }
+18 -2
View File
@@ -57,8 +57,24 @@ export async function hostKundeSetzen(formular: FormData) {
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
if (!hostId) return; if (!hostId) return;
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } }); // Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId }); // gewechselt hat –, wird der Standort verworfen statt eine falsche Zuordnung zu speichern.
const gueltigerStandort =
standortId && kundeId
? ((await prisma.standort.findFirst({
where: { id: standortId, kundeId },
select: { id: true },
})) ?? null)
: null;
await prisma.host.update({
where: { id: hostId },
data: { kundeId, standortId: gueltigerStandort?.id ?? null },
});
await protokolliere("host.zugewiesen", "Host", hostId, {
kundeId,
standortId: gueltigerStandort?.id ?? null,
});
if (kundeId) { if (kundeId) {
await prisma.alert.updateMany({ await prisma.alert.updateMany({
+2 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { eigenerPfad } from "@/lib/adresse";
export interface AktionsErgebnis { export interface AktionsErgebnis {
fehler?: string; fehler?: string;
@@ -79,7 +80,7 @@ export async function kundeLoeschen(formular: FormData) {
revalidatePath("/kunden"); revalidatePath("/kunden");
revalidatePath("/hosts/nicht-zugeordnet"); revalidatePath("/hosts/nicht-zugeordnet");
redirect("/kunden"); redirect(await eigenerPfad("/kunden"));
} }
const standortSchema = z.object({ const standortSchema = z.object({
+3 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import type { Rolle } from "@/generated/prisma/enums"; import type { Rolle } from "@/generated/prisma/enums";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung"; import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "./rollen"; import { darf, type Recht } from "./rollen";
@@ -68,10 +69,10 @@ export const angemeldet = cache(async (): Promise<AngemeldeterBenutzer | null> =
*/ */
export async function sitzungErforderlich(recht?: Recht): Promise<AngemeldeterBenutzer> { export async function sitzungErforderlich(recht?: Recht): Promise<AngemeldeterBenutzer> {
const benutzer = await angemeldet(); const benutzer = await angemeldet();
if (!benutzer) redirect("/login?fehler=Bitte+anmelden."); if (!benutzer) redirect(await eigenerPfad("/login?fehler=Bitte+anmelden."));
if (recht && !darf(benutzer.rolle, recht)) { if (recht && !darf(benutzer.rolle, recht)) {
redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung."); redirect(await eigenerPfad("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung."));
} }
return benutzer; return benutzer;
+94
View File
@@ -0,0 +1,94 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Zuweisung von Kunde und Standort an einen Host (#036). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
const { hostKundeSetzen } = await import("@/server/actions/host-detail");
async function aufbau() {
const [kundeA, kundeB] = await Promise.all([
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
]);
const [standortA, standortB] = await Promise.all([
testPrisma.standort.create({ data: { kundeId: kundeA.id, name: "Rechenzentrum A" } }),
testPrisma.standort.create({ data: { kundeId: kundeB.id, name: "Rechenzentrum B" } }),
]);
const host = await testPrisma.host.create({
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE" },
});
return { kundeA, kundeB, standortA, standortB, host };
}
function formular(werte: Record<string, string>) {
const daten = new FormData();
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
return daten;
}
describe.runIf(datenbankVerfuegbar)("Kunde und Standort setzen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("setzt Kunde und Standort zusammen", async () => {
const { kundeA, standortA, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBe(kundeA.id);
expect(danach.standortId).toBe(standortA.id);
});
it("verwirft einen Standort, der zu einem anderen Kunden gehört", async () => {
const { kundeA, standortB, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortB.id }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBe(kundeA.id);
expect(danach.standortId).toBeNull();
});
it("löst die Zuordnung samt Standort, wenn kein Kunde gewählt ist", async () => {
const { kundeA, standortA, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
await hostKundeSetzen(formular({ hostId: host.id, kundeId: "", standortId: "" }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBeNull();
expect(danach.standortId).toBeNull();
});
it("schließt die Warnung „Host ohne Kunde“ beim Zuweisen", async () => {
const { kundeA, host } = await aufbau();
await testPrisma.alert.create({
data: {
regel: "host-ohne-kunde",
fingerprint: `host-ohne-kunde:${host.id}`,
schwere: "INFO",
titel: "Host ohne Kunde",
beschreibung: "…",
hostId: host.id,
},
});
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id }));
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: host.id } });
expect(alert.erledigtAm).not.toBeNull();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Prüft das Öffnen und Schließen des Seitenmenüs. Statt eines echten Browsers genügen hier
* Attrappen für `document.body` und `window` – geprüft wird, welche Klassen gesetzt werden.
*/
const klassen = new Set<string>();
let breite = 390;
function aufraeumen() {
klassen.clear();
breite = 390;
}
beforeEach(async () => {
aufraeumen();
vi.resetModules();
const body = {
classList: {
toggle(name: string, an?: boolean) {
const soll = an ?? !klassen.has(name);
if (soll) klassen.add(name);
else klassen.delete(name);
},
remove(name: string) {
klassen.delete(name);
},
contains: (name: string) => klassen.has(name),
},
style: { overflow: "" },
};
vi.stubGlobal("document", { body });
vi.stubGlobal("window", {
get innerWidth() {
return breite;
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Seitenmenü auf schmalen Bildschirmen", () => {
it("öffnet und schließt wieder", async () => {
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect(klassen.has("sidebar-open")).toBe(true);
menuSchliessen();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("sperrt den Inhalt hinter dem offenen Menü gegen Scrollen", async () => {
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("hidden");
menuSchliessen();
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("");
});
it("schließt auch beim zweiten Antippen des Knopfes", async () => {
const { menuUmschalten } = await import("@/components/layout/menuZustand");
menuUmschalten();
menuUmschalten();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("macht ein zweites Schließen zum Nichts", async () => {
const { menuSchliessen } = await import("@/components/layout/menuZustand");
menuSchliessen();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("klappt auf breiten Bildschirmen nur ein, statt zu überlagern", async () => {
breite = 1400;
const { menuUmschalten } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect(klassen.has("sidebar-collapse")).toBe(true);
expect(klassen.has("sidebar-open")).toBe(false);
});
});
+13
View File
@@ -100,6 +100,19 @@ describe("Öffentliche Adresse", () => {
expect(eigeneAdresse(anfrageMit({}), "/").host).toBe("0a5963a591df:3000"); expect(eigeneAdresse(anfrageMit({}), "/").host).toBe("0a5963a591df:3000");
}); });
it("übergeht eine Lausch-Adresse in der Konfiguration", () => {
// 0.0.0.0 heißt „alle Schnittstellen“ – als Ziel im Browser ist das unbrauchbar.
// Genau so stand es nach einer Installation in der .env und führte beim Abmelden ins Leere.
const anfrage = anfrageMit({ host: "proxledger.firma.de" });
expect(eigeneAdresse(anfrage, "/login", "http://0.0.0.0:3000").host).toBe("proxledger.firma.de");
});
it("übergeht auch eine Lausch-Adresse aus der Umgebung", () => {
vi.stubEnv("APP_URL", "http://0.0.0.0:3000");
const anfrage = anfrageMit({ host: "proxledger.firma.de" });
expect(eigeneAdresse(anfrage, "/login").host).toBe("proxledger.firma.de");
});
it("übergeht einen unbrauchbaren Eintrag, statt zu scheitern", () => { it("übergeht einen unbrauchbaren Eintrag, statt zu scheitern", () => {
const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de" }); const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de" });
expect(eigeneAdresse(anfrage, "/", "keine-adresse").host).toBe("dashboard.kunde.de"); expect(eigeneAdresse(anfrage, "/", "keine-adresse").host).toBe("dashboard.kunde.de");