Compare commits
4 Commits
feature/op
...
04971bf3bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| c452b69dd6 |
@@ -7,16 +7,16 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
KEYS_PATH=/mnt/SSD/gerbil/keys
|
||||
|
||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
|
||||
# --- .NET API (GerbilManagerWebAPI) ---
|
||||
api:
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
@@ -95,19 +95,19 @@ services:
|
||||
|
||||
volumes:
|
||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
|
||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
@@ -115,10 +115,10 @@ volumes:
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
|
||||
325
docs/ops.md
325
docs/ops.md
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
@@ -9,12 +9,13 @@
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
2. [Voraussetzungen](#2-voraussetzungen)
|
||||
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
|
||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||
| HTTP :80
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
@@ -39,10 +40,9 @@ Browser / Handy
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
@@ -54,107 +54,68 @@ Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Erstinstallation auf TrueNAS Goldeye
|
||||
## 3. Erstinstallation auf TrueNAS
|
||||
|
||||
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
|
||||
### 3.1 ZFS-Datasets anlegen
|
||||
|
||||
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
||||
|
||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
||||
|---|---|---|
|
||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
||||
|
||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
||||
|
||||
### 3.2 Repository klonen
|
||||
|
||||
```bash
|
||||
# Vier Ordner anlegen
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
|
||||
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
|
||||
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
|
||||
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
|
||||
```
|
||||
|
||||
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
|
||||
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
|
||||
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
|
||||
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
|
||||
|
||||
### 3.2 Registry-Login auf der NAS
|
||||
|
||||
```bash
|
||||
docker login git.rismer.de
|
||||
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
|
||||
```
|
||||
|
||||
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
|
||||
|
||||
### 3.3 Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
cd /opt/gerbilmanager
|
||||
```
|
||||
|
||||
### 3.4 Konfiguration anlegen
|
||||
### 3.3 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
Mindestens setzen:
|
||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
||||
|
||||
| Variable | Wert |
|
||||
|----------|------|
|
||||
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
|
||||
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
|
||||
| `AI__ApiKey` | Dein Gemini API-Key |
|
||||
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
|
||||
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
|
||||
|
||||
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
|
||||
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
|
||||
|
||||
### 3.5 Images ziehen und App starten
|
||||
### 3.4 Images bauen und App starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||
|
||||
> **TrueNAS Goldeye Custom App (Alternative):**
|
||||
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
|
||||
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
|
||||
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
|
||||
|
||||
### 3.6 Verifikation
|
||||
### 3.5 Prüfen
|
||||
|
||||
```bash
|
||||
# Alle 4 Container laufen?
|
||||
# Alle Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||
curl -s http://localhost/api/health
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
|
||||
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# API-Doku (Scalar) im Browser
|
||||
http://<NAS-IP>/scalar
|
||||
|
||||
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -164,7 +125,6 @@ http://<NAS-IP>/scalar
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
@@ -174,17 +134,26 @@ docker compose -f deploy/truenas/compose.yaml up -d
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
```
|
||||
|
||||
### Aktualisieren (nach CI-Push auf main)
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||
|
||||
### Mit Gitea CI (wenn Actions aktiviert)
|
||||
|
||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
||||
Danach auf der NAS:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Wiederherstellung
|
||||
@@ -196,12 +165,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||
|
||||
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
|
||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
||||
|
||||
```
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
|
||||
/mnt/SSD/gerbil/backups/
|
||||
2026-06-06_03-00/
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
@@ -215,68 +184,52 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
||||
### Backup-Log prüfen
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
||||
```
|
||||
|
||||
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
|
||||
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
|
||||
|
||||
### Wiederherstellung — Runbook
|
||||
|
||||
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
|
||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
||||
|
||||
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
|
||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**Schritt 2:** Restore ausführen
|
||||
|
||||
```bash
|
||||
# Neuestes Backup automatisch wählen und bestätigen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh latest -f
|
||||
# Neuestes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh
|
||||
|
||||
# Bestimmtes Backup (Datum aus Verzeichnisname):
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
|
||||
# Bestimmtes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
1. Trennt alle offenen DB-Verbindungen
|
||||
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
|
||||
3. Entpackt das Foto-Archiv nach `/data/photos`
|
||||
|
||||
**Schritt 3:** API neu starten
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||
**Schritt 4:** Prüfen
|
||||
```bash
|
||||
# Tier-Anzahl prüfen
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
|
||||
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
|
||||
|
||||
# Foto stichprobenartig prüfen
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
```
|
||||
|
||||
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
|
||||
### Restore-Nachweis (Round-Trip-Test)
|
||||
|
||||
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
|
||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
||||
```
|
||||
73 ColorVarieties vorhanden
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ psql < dump.sql eingespielt
|
||||
→ pg_restore eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
Exit-Code: 0
|
||||
```
|
||||
|
||||
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
|
||||
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
|
||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,15 +240,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
||||
|
||||
### Empfohlene Snapshot-Konfiguration
|
||||
|
||||
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||
|-------------|-----------|--------------|
|
||||
| `.../photos` | Stündlich | 24 Stunden |
|
||||
| `.../photos` | Täglich | 30 Tage |
|
||||
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||
| `.../pgdata` | Täglich | 30 Tage |
|
||||
| `.../backups` | Täglich | 90 Tage |
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
|
||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||
@@ -304,93 +257,94 @@ In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# Pool-/Dataset-Name anpassen falls nötig
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshots auflisten
|
||||
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
|
||||
# Einzelne Datei aus Snapshot kopieren
|
||||
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD via Gitea Actions
|
||||
|
||||
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
|
||||
`git.rismer.de/gulum/gerbilmanager-frontend`.
|
||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
||||
|
||||
### Registry-Secrets in Gitea
|
||||
### Aktivierung
|
||||
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
1. **Gitea Actions aktivieren:**
|
||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
|
||||
| Secret | Wert |
|
||||
|--------|------|
|
||||
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
||||
|
||||
### Update nach CI-Push
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
|
||||
```bash
|
||||
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Fehlerbehebung
|
||||
## 8. Offene Fragen
|
||||
|
||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|-------|-----------|
|
||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
docker compose -f deploy/truenas/compose.yaml logs
|
||||
|
||||
# Logs eines bestimmten Containers
|
||||
docker compose -f deploy/truenas/compose.yaml logs api
|
||||
docker compose -f deploy/truenas/compose.yaml logs db
|
||||
```
|
||||
|
||||
### Port 80 belegt
|
||||
|
||||
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
|
||||
|
||||
```bash
|
||||
# In deploy/truenas/.env:
|
||||
PORT=8080
|
||||
# Dann neu starten:
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
### Postgres startet nicht (Permission denied auf pgdata)
|
||||
|
||||
```bash
|
||||
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
docker compose -f deploy/truenas/compose.yaml restart db
|
||||
```
|
||||
|
||||
### Registry-Pull schlägt fehl
|
||||
|
||||
```bash
|
||||
# Neu einloggen:
|
||||
docker login git.rismer.de
|
||||
# Dann pull wiederholen:
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
```
|
||||
|
||||
### Datenbank nicht erreichbar
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
@@ -398,16 +352,27 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||
```
|
||||
|
||||
### Container-Status zurücksetzen (Neustart)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml restart api
|
||||
```
|
||||
|
||||
### Kompletter Neustart (Daten bleiben erhalten)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -110,3 +110,30 @@ test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
|
||||
await expandBtn.click({ force: true })
|
||||
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
|
||||
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
||||
await page.goto('/rennmaeuse/fridolin/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
const panel = page.locator('.stammbaum-litters-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toContainText(t.littersTitle)
|
||||
await expect(panel).toContainText('Wurf K')
|
||||
await expect(panel).toContainText('5')
|
||||
|
||||
// Link-Klick → Wurf-Detailseite
|
||||
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(wurfLink).toBeVisible()
|
||||
await wurfLink.click()
|
||||
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
|
||||
})
|
||||
|
||||
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
|
||||
expect(codes).toContain('mythg')
|
||||
expect(codes).toContain('ger')
|
||||
expect(codes).toContain('arb')
|
||||
expect(codes).toHaveLength(5)
|
||||
expect(codes).toContain('disney')
|
||||
expect(codes).toContain('pokemon')
|
||||
expect(codes).toContain('encities')
|
||||
expect(codes).toContain('hrcities')
|
||||
expect(codes).toContain('usstates')
|
||||
expect(codes).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('every usage has a non-empty label', () => {
|
||||
|
||||
@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
|
||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||
{ code: 'ger', label: 'Deutsch' },
|
||||
{ code: 'arb', label: 'Arabisch' },
|
||||
{ code: 'disney', label: 'Disney' },
|
||||
{ code: 'pokemon', label: 'Pokémon' },
|
||||
{ code: 'encities', label: 'Englische Städte' },
|
||||
{ code: 'hrcities', label: 'Kroatische Städte' },
|
||||
{ code: 'usstates', label: 'US-Bundesstaaten' },
|
||||
] as const
|
||||
|
||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||
|
||||
@@ -22,9 +22,10 @@ import Tree from 'react-d3-tree'
|
||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||
import type { Gender, Gerbil } from '../api/types'
|
||||
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
@@ -156,6 +157,13 @@ export default function StammbaumPage() {
|
||||
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
||||
: t.inbreeding.unavailable
|
||||
|
||||
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
|
||||
const rootLitters = useApi(
|
||||
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||
[id],
|
||||
)
|
||||
const rootLitterItems = rootLitters.data?.items ?? []
|
||||
|
||||
/* ── react-d3-tree-Daten ── */
|
||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||
@@ -315,25 +323,30 @@ export default function StammbaumPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||
{view && (
|
||||
<Tree
|
||||
key={id}
|
||||
data={datum}
|
||||
renderCustomNodeElement={renderNode}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
translate={view.translate}
|
||||
zoom={view.zoom}
|
||||
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
||||
zoomable
|
||||
draggable
|
||||
collapsible={false}
|
||||
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
||||
separation={{ siblings: 1, nonSiblings: 1 }}
|
||||
onUpdate={handleTreeUpdate}
|
||||
/>
|
||||
<div className="stammbaum-layout">
|
||||
{rootLitterItems.length > 0 && (
|
||||
<LittersPanel litters={rootLitterItems} t={t} />
|
||||
)}
|
||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||
{view && (
|
||||
<Tree
|
||||
key={id}
|
||||
data={datum}
|
||||
renderCustomNodeElement={renderNode}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
translate={view.translate}
|
||||
zoom={view.zoom}
|
||||
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
||||
zoomable
|
||||
draggable
|
||||
collapsible={false}
|
||||
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
||||
separation={{ siblings: 1, nonSiblings: 1 }}
|
||||
onUpdate={handleTreeUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="stammbaum-hints">
|
||||
<li>{t.tapHint}</li>
|
||||
@@ -417,6 +430,36 @@ function PedigreeCard({
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
|
||||
|
||||
function LittersPanel({
|
||||
litters,
|
||||
t,
|
||||
}: {
|
||||
litters: Litter[]
|
||||
t: { littersTitle: string; littersJunge: string }
|
||||
}) {
|
||||
return (
|
||||
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
|
||||
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
|
||||
<ul className="stammbaum-litters-panel__list">
|
||||
{litters.map((l) => (
|
||||
<li key={l.id}>
|
||||
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
|
||||
<span className="stammbaum-litters-panel__name">{l.name}</span>
|
||||
{l.totalBorn != null && (
|
||||
<span className="stammbaum-litters-panel__born">
|
||||
{l.totalBorn} {t.littersJunge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SexIcon({ gender }: { gender: Gender }) {
|
||||
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
||||
return (
|
||||
|
||||
@@ -45,9 +45,132 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
|
||||
|
||||
.stammbaum-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Desktop: Würfe-Panel links vom Baum. */
|
||||
.stammbaum-litters-panel {
|
||||
flex: none;
|
||||
width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
overflow-y: auto;
|
||||
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 0.4rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__link:hover {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__born {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
|
||||
@media (max-width: 520px) {
|
||||
.stammbaum-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel {
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__title {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__list {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
max-height: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__link {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stammbaum-litters-panel__born {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Canvas behält explizite Höhe in column-Richtung. */
|
||||
.stammbaum-canvas {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||
|
||||
.stammbaum-canvas {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: clamp(18rem, 62dvh, 46rem);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
|
||||
@@ -358,6 +358,9 @@ export const de = {
|
||||
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||
hintName: 'Namenslink: Tierakte öffnen',
|
||||
hintExpand: '+: weitere Vorfahren nachladen',
|
||||
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
||||
littersTitle: 'Würfe',
|
||||
littersJunge: 'Junge',
|
||||
zoomIn: 'Vergrößern',
|
||||
zoomOut: 'Verkleinern',
|
||||
zoomFit: 'Ansicht einpassen',
|
||||
|
||||
Reference in New Issue
Block a user