Compare commits

..

13 Commits

Author SHA1 Message Date
40f3ce250d STATUS-MODEL-FE: Zucht/Liebhaber/Abzugeben wählbar; Verstorben+Abgegeben abgeleitet (gated auf Pam BE) 2026-06-07 03:36:10 +02:00
2d54cb901f Merge feature/litter-juvenile-details (LITTER-JUVENILE-DETAILS): pro Jungtier Farbschlag/Abgabedatum/Abnehmer/Todestag/Ursache im Wurf-Detail
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:25:53 +02:00
03f8e4de90 LITTER-JUVENILE-DETAILS: Farbschlag/Abgabe/Abnehmer/Todesdaten pro Jungtier im Wurf-Detail 2026-06-07 03:23:22 +02:00
25af10a736 Merge feature/farbschlag-genotype-sync (FARBSCHLAG-GENOTYPE-SYNC): Farbschlag↔Gencode bidirektionale Sync im Tier-Formular
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:16:55 +02:00
ade40eef5e FARBSCHLAG-GENOTYPE-SYNC: Farbschlag↔Gencode bidirektionale Sync im Tier-Formular 2026-06-07 03:16:02 +02:00
c1b215d06f Merge feature/stammbaum-fulldate (STAMMBAUM): volles Geburtsdatum + Gencode-Chip + GerbilIcon (🐭→Rennmaus app-weit)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 03:03:45 +02:00
7525970ffc STAMMBAUM-FULLDATE: GerbilIcon-SVG + Gencode-Chip + Gerbil-Icon ersetzt 🐭 app-weit 2026-06-07 02:53:31 +02:00
bfc90f8581 STAMMBAUM-FULLDATE: volles Geburtsdatum auf Knoten-Karten (formatDate statt slice 4) + e2e 2026-06-07 02:47:12 +02:00
c45bcc99f0 Merge feature/ops-2 (OPS-2): TrueNAS Goldeye deploy config (registry git.rismer.de + JailStorage paths) + ops runbook
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
# Conflicts:
#	deploy/truenas/compose.yaml
2026-06-07 02:28:00 +02:00
c4a661b5b6 Merge feature/search-1 (SEARCH-1): regression tests for separator-insensitive nameSearch + OriginBreeder filter 2026-06-07 02:27:21 +02:00
30b2fff775 Merge feature/web-2 (WEB-2): self-host public site — POST /api/publish (atomic swap) + publicsite nginx + vhost + docs
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
CI / Backend Tests (.NET) (push) Successful in 59s
2026-06-07 01:45:30 +02:00
2198c33898 OPS-2: TrueNAS Goldeye 25.10.2.1 Deploy finalisiert
Registry truenas:13000 -> git.rismer.de/gulum (alle 6 Stellen).
Dataset-Pfade auf /mnt/JailStorage/DockerVolumes/gerbilmanager/ gesetzt.
docs/ops.md: vollstaendiges Goldeye-Runbook (Ordner + UID-999-Perms,
Registry-Login, Port-80-Fallback, Restore-Drill-Pflichtschritt, ZFS-Pfade).
docker compose config: OK.
2026-06-07 01:38:25 +02:00
f20a42a94b SEARCH-1: tests — Normalize unit + separator-insensitive nameSearch filter + OriginBreeder/breeders endpoint (SQLite in-memory round-trip) 2026-06-06 09:04:20 +02:00
21 changed files with 633 additions and 207 deletions

View File

@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Http.Json;
using GerbilManagerWebAPI.Models;
namespace GerbilManager.Tests;
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
public class GerbilSearchTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
JsonContent.Create(new
{
name,
gender = "female",
originBreeder,
});
[Theory]
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
Assert.Equal(expected, GerbilSearch.Normalize(input));
[Fact]
public async Task NameSearch_filter_matches_across_separators()
{
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
// The client normalizes the user's term the same way the column is normalized.
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
Assert.Contains("Clan-Kleine-Chaoten", json);
// a term that doesn't normalize-match must NOT return it
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
}
[Fact]
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
{
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
// distinct-values dropdown source
var breeders = await _client.GetStringAsync("/gerbils/breeders");
Assert.Contains("Zucht der kleinen Chaoten", breeders);
// Gridify filter on the field
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
Assert.Contains("Herkunftstier", filtered);
}
}

View File

@@ -7,16 +7,16 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80) # Externer Port fuer das Frontend (Standard: 80)
PORT=80 PORT=80
# Gitea Container Registry (Standard: truenas:13000/gulum) # Container Registry (git.rismer.de/gulum)
REGISTRY=truenas:13000/gulum REGISTRY=git.rismer.de/gulum
TAG=latest TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>) # NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
PGDATA_PATH=/mnt/SSD/gerbil/pgdata PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung) # AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7

View File

@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) --- # --- .NET API (GerbilManagerWebAPI) ---
api: api:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -66,7 +66,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) --- # --- nginx Frontend (React SPA + API-Proxy) ---
frontend: frontend:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: gerbil-manager-web/Dockerfile dockerfile: gerbil-manager-web/Dockerfile
@@ -113,19 +113,19 @@ services:
volumes: volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren). # NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata # TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
pgdata: pgdata:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}" device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
photos: photos:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung. # AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos). # Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys: keys:
@@ -133,13 +133,13 @@ volumes:
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}" device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro). # WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite: publicsite:
driver: local driver: local

View File

@@ -1,4 +1,4 @@
# GerbilManager — Betriebsanleitung (TrueNAS) # GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung). > Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/) > Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
@@ -9,13 +9,12 @@
1. [Übersicht & Architektur](#1-übersicht--architektur) 1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen) 2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas) 3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren) 4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung) 5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung) 6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions) 7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen) 8. [Fehlerbehebung](#8-fehlerbehebung)
9. [Fehlerbehebung](#9-fehlerbehebung)
--- ---
@@ -23,7 +22,7 @@
``` ```
Browser / Handy Browser / Handy
| HTTP :80 | HTTP :80 (oder PORT aus .env, z.B. 8080)
v v
┌──────────────────┐ ┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy │ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
@@ -40,9 +39,10 @@ Browser / Handy
│ db (Postgres 17)│ │ backup (Sidecar) │ │ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │ └──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘ │ └──────────────────────┘
└─ pgdata-Volume (NAS-Dataset) └─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
photos-Volume (NAS-Dataset) photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
backups-Volume (NAS-Dataset) backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
``` ```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`). **Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
| Was | Details | | Was | Details |
|-----|---------| |-----|---------|
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) | | TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` | | Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) | | Docker | bereits auf TrueNAS Goldeye vorhanden |
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) | | Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
--- ---
## 3. Erstinstallation auf TrueNAS ## 3. Erstinstallation auf TrueNAS Goldeye
### 3.1 ZFS-Datasets anlegen ### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
In TrueNAS → **Datasets****Dataset hinzufügen** (je einmal wiederholen): Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
| 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 ```bash
# SSH in TrueNAS oder lokale Shell # Vier Ordner anlegen
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager 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
cd /opt/gerbilmanager cd /opt/gerbilmanager
``` ```
### 3.3 Konfiguration anlegen ### 3.4 Konfiguration anlegen
```bash ```bash
cp deploy/truenas/.env.example deploy/truenas/.env cp deploy/truenas/.env.example deploy/truenas/.env
# Jetzt .env bearbeiten:
nano deploy/truenas/.env nano deploy/truenas/.env
``` ```
Mindestens setzen: Mindestens setzen:
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
### 3.4 Images bauen und App starten | 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
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen). Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
### 3.5 Prüfen > **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
```bash ```bash
# Alle Container laufen? # Alle 4 Container laufen?
docker compose -f deploy/truenas/compose.yaml ps docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck # API-Healthcheck (erwartet: {"status":"Healthy"})
curl http://localhost/api/health curl -s http://localhost/api/health
# Webapp im Browser # Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
http://<NAS-IP>/ 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
``` ```
--- ---
@@ -125,6 +164,7 @@ http://<NAS-IP>/
### Starten ### Starten
```bash ```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
docker compose -f deploy/truenas/compose.yaml down docker compose -f deploy/truenas/compose.yaml down
``` ```
### Aktualisieren (nach `git push` auf main) ### Aktualisieren (nach CI-Push auf main)
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
git pull git pull
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig. > 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 ## 5. Backup & Wiederherstellung
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
- Komprimiertes Foto-Archiv als `.tar.gz` - Komprimiertes Foto-Archiv als `.tar.gz`
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht - Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/` Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
``` ```
/mnt/SSD/gerbil/backups/ /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
2026-06-06_03-00/ 2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank) gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
photos_2026-06-06_03-00.tar.gz (Fotos) photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll) backup.log (Protokoll)
``` ```
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
### Backup-Log prüfen ### Backup-Log prüfen
```bash ```bash
tail -50 /mnt/SSD/gerbil/backups/backup.log tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/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 ### Wiederherstellung — Runbook
> **WARNUNG:** Alle aktuellen Daten werden überschrieben! > **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
**Schritt 1:** App stoppen (optional, aber empfohlen) **Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
```bash ```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend docker compose -f deploy/truenas/compose.yaml stop api frontend
``` ```
**Schritt 2:** Restore ausführen **Schritt 2:** Restore ausführen
```bash
# Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh
# Bestimmtes Backup wiederherstellen: ```bash
docker compose -f deploy/truenas/compose.yaml exec backup \ # Neuestes Backup automatisch wählen und bestätigen:
/bin/sh /scripts/restore.sh 2026-06-05_03-00 docker compose -f deploy/truenas/compose.yaml exec -T backup \
/bin/sh /scripts/restore.sh latest -f
# 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
``` ```
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 **Schritt 3:** API neu starten
```bash ```bash
docker compose -f deploy/truenas/compose.yaml start api frontend docker compose -f deploy/truenas/compose.yaml start api frontend
``` ```
**Schritt 4:** Prüfen **Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
```bash ```bash
curl http://localhost/api/color-varieties | grep -c '"id"' # Tier-Anzahl prüfen
# Erwarteter Wert: 73 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
``` ```
### Restore-Nachweis (Round-Trip-Test) ### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09): Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
``` ```
73 ColorVarieties vorhanden 73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend → DELETE 12 Zeilen → 61 verbleibend
→ pg_restore eingespielt → psql < dump.sql eingespielt
→ 73 ColorVarieties bestätigt → 73 ColorVarieties bestätigt
Exit-Code: 0 Exit-Code: 0
``` ```
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren. **Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
--- ---
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration ### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets**Dataset auswählen**Snapshots****Regelmäßige Snapshots**: In TrueNAS → **Datasets**`JailStorage/DockerVolumes/gerbilmanager`**Snapshots****Regelmäßige Snapshots**:
| Dataset | Häufigkeit | Aufbewahrung | | Unterordner | Häufigkeit | Aufbewahrung |
|---------|-----------|--------------| |-------------|-----------|--------------|
| `gerbil/photos` | Stündlich | 24 Stunden | | `.../photos` | Stündlich | 24 Stunden |
| `gerbil/photos` | Täglich | 30 Tage | | `.../photos` | Täglich | 30 Tage |
| `gerbil/pgdata` | Stündlich | 24 Stunden | | `.../pgdata` | Stündlich | 24 Stunden |
| `gerbil/pgdata` | Täglich | 30 Tage | | `.../pgdata` | Täglich | 30 Tage |
| `gerbil/backups` | Täglich | 90 Tage | | `.../backups` | Täglich | 90 Tage |
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent, > **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, > aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
### Snapshot manuell erstellen (z. B. vor Update) ### Snapshot manuell erstellen (z. B. vor Update)
```bash ```bash
# TrueNAS CLI # Pool-/Dataset-Name anpassen falls nötig
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
``` ```
### Aus ZFS-Snapshot wiederherstellen (Fotos) ### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash ```bash
# Snapshot auflisten # Snapshots auflisten
zfs list -t snapshot SSD/gerbil/photos zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
# Datei aus Snapshot kopieren # Einzelne Datei aus Snapshot kopieren
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/ cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
``` ```
--- ---
## 7. CI/CD via Gitea Actions ## 7. CI/CD via Gitea Actions
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**. CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
`git.rismer.de/gulum/gerbilmanager-frontend`.
### Aktivierung ### Registry-Secrets in Gitea
1. **Gitea Actions aktivieren:**
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
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"
3. **Registry-Secrets konfigurieren:**
Gitea → Repository → Einstellungen → Secrets: Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
### Workflow nach Aktivierung | Secret | Wert |
|--------|------|
| `REGISTRY_USER` | Gitea-Benutzername |
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
``` ### Update nach CI-Push
git push origin main
→ Gitea Actions: dotnet test + npm test + npm run build ```bash
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/ # Auf der NAS nach erfolgreichem CI-Lauf:
→ Auf NAS: docker compose pull + up -d cd /opt/gerbilmanager
git pull
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d
``` ```
--- ---
## 8. Offene Fragen ## 8. Fehlerbehebung
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 ### App startet nicht
```bash ```bash
# Logs aller Container
docker compose -f deploy/truenas/compose.yaml logs 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 api
docker compose -f deploy/truenas/compose.yaml logs db 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 ### Datenbank nicht erreichbar
```bash ```bash
# DB-Container läuft?
docker compose -f deploy/truenas/compose.yaml ps db docker compose -f deploy/truenas/compose.yaml ps db
# Verbindung testen
docker compose -f deploy/truenas/compose.yaml exec db \ docker compose -f deploy/truenas/compose.yaml exec db \
psql -U postgres -d gerbilmanager -c "\dt" psql -U postgres -d gerbilmanager -c "\dt"
``` ```
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
### Backup-Fehler ### Backup-Fehler
```bash ```bash
# Backup-Log prüfen tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
cat /mnt/SSD/gerbil/backups/backup.log | tail -30 docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
# Backup manuell starten (mit Fehlerausgabe)
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/backup.sh
``` ```
### Fotos werden nicht angezeigt ### Fotos werden nicht angezeigt
Prüfe ob das `photos`-Volume korrekt gemounted ist:
```bash ```bash
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos 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) ### Kompletter Neustart (Daten bleiben erhalten)
```bash ```bash

View File

@@ -0,0 +1,79 @@
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Vor der Auswahl ist das Gencode-Feld leer.
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
})
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('cv-schwarz')
})
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(farbschlagSelect).not.toHaveValue('')
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('')
})
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
await farbschlagSelect.selectOption({ label: 'Blau' })
await expect(farbschlagSelect).not.toHaveValue('')
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
await expect(farbschlagSelect).toHaveValue('')
})

View File

@@ -0,0 +1,30 @@
/** LITTER-JUVENILE-DETAILS: Jungtier-Felder (Farbschlag/Abgabe/Verstorben) im Wurf-Detail. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.litters
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
await expect(kruemelCard).toBeVisible()
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard).toBeVisible()
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard).toBeVisible()
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
})

View File

@@ -91,7 +91,7 @@ function gerbil(
id, id,
name, name,
gender, gender,
status: 'Active', status: 'Breeding',
dateOfBirth, dateOfBirth,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,
@@ -151,6 +151,19 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer', enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker', notes: 'neugieriger Entdecker',
}, },
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
{
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
status: 'GivenAway' as const,
goHomeDate: '2025-05-01',
receiverContactId: 'con-huber',
},
{
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
status: 'Deceased' as const,
dateOfDeath: '2025-04-15',
causeOfDeath: 'Altersschwäche',
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail. // UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null), gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
] ]

View File

@@ -137,3 +137,20 @@ test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', a
await expect(page.locator('.pedigree-card').first()).toBeVisible() await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible() await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
}) })
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
skipUnlessMock()
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
})
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
})

View File

@@ -0,0 +1,58 @@
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
// Zucht (Breeding) ist vorausgewählt
await expect(statusField.locator('select')).toHaveValue('Breeding')
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
const opts = statusField.locator('select option')
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
})
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
skipUnlessMock()
// Willi ist Deceased
await page.goto('/rennmaeuse/willi/bearbeiten')
// Status-Select ist NICHT vorhanden (abgeleitet)
const statusField = page.locator('.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await expect(statusField).toBeVisible()
await expect(statusField.locator('select')).toHaveCount(0)
await expect(statusField).toContainText(t.statusLabels.Deceased)
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
})
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
const statusSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) })
.locator('select')
await expect(statusSelect).toHaveValue('Breeding')
})
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
})

View File

@@ -10,9 +10,11 @@
export type Gender = 'unknown' | 'male' | 'female' export type Gender = 'unknown' | 'male' | 'female'
export const GENDERS: Gender[] = ['unknown', 'male', 'female'] export const GENDERS: Gender[] = ['unknown', 'male', 'female']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */ /** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale' export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway'] export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */ /** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
export type DateOnlyString = string export type DateOnlyString = string

View File

@@ -1,19 +1,21 @@
import { useState } from 'react' import { useState } from 'react'
import type { ReactNode } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import './appShell.css' import './appShell.css'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
icon: string icon: ReactNode
end?: boolean end?: boolean
} }
// Primary items are always visible in the phone bottom bar; secondary items // Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar. // move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [ const PRIMARY: NavItem[] = [
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' }, { to: '/rennmaeuse', label: de.nav.gerbils, icon: <GerbilIcon size="1.25em" /> },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' }, { to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' }, { to: '/genetik', label: de.nav.genetics, icon: '🧬' },
] ]
@@ -46,7 +48,7 @@ export default function AppShell() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<span className="app-logo" aria-hidden="true">🐭</span> <span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
<h1 className="app-title">{de.app.title}</h1> <h1 className="app-title">{de.app.title}</h1>
</header> </header>

View File

@@ -0,0 +1,35 @@
import type { CSSProperties } from 'react'
interface Props {
size?: string | number
style?: CSSProperties
className?: string
}
/** Reusable gerbil silhouette icon — upright sitting pose, long tail with tuft. fill=currentColor. */
export default function GerbilIcon({ size = '1em', style, className }: Props) {
return (
<svg
viewBox="0 0 64 64"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
style={{ display: 'inline-block', verticalAlign: '-0.125em', flexShrink: 0, ...style }}
className={className}
role="img"
aria-label="Rennmaus"
>
{/* body — sitting upright */}
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
{/* ear */}
<circle cx="46" cy="15" r="5" />
{/* hind paw */}
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
{/* long curved tail with tuft */}
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
{/* front paw */}
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
</svg>
)
}

View File

@@ -3,10 +3,10 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils' import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, GERBIL_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel' import NameSuggestPanel from '../components/NameSuggestPanel'
import '../components/NameSuggestPanel.css' import '../components/NameSuggestPanel.css'
@@ -34,7 +34,7 @@ interface FormState {
const EMPTY: FormState = { const EMPTY: FormState = {
name: '', name: '',
gender: '', gender: '',
status: 'Active', status: 'Breeding',
dateOfBirth: '', dateOfBirth: '',
dateOfDeath: '', dateOfDeath: '',
causeOfDeath: '', causeOfDeath: '',
@@ -245,16 +245,29 @@ export default function GerbilFormPage() {
{errors.gender && <small className="error-text">{errors.gender}</small>} {errors.gender && <small className="error-text">{errors.gender}</small>}
</label> </label>
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
<div className="field">
<span>{t.fields.status}</span>
<span className="muted">
{statusLabel(form.status)}
<small className="muted" style={{ marginLeft: '0.5rem' }}>
({t.form.statusDerivedHint[form.status]})
</small>
</span>
</div>
) : (
<label className="field"> <label className="field">
<span>{t.fields.status}</span> <span>{t.fields.status}</span>
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}> <select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
{GERBIL_STATUSES.map((s) => ( {SELECTABLE_STATUSES.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{statusLabel(s)} {statusLabel(s)}
</option> </option>
))} ))}
</select> </select>
</label> </label>
)}
<label className="field"> <label className="field">
<span>{t.fields.dateOfBirth}</span> <span>{t.fields.dateOfBirth}</span>
@@ -307,7 +320,14 @@ export default function GerbilFormPage() {
<span className="farbschlag-value"> <span className="farbschlag-value">
<select <select
value={form.colorVarietyId} value={form.colorVarietyId}
onChange={(e) => set('colorVarietyId', e.target.value)} onChange={(e) => {
const newId = e.target.value
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
> >
<option value="">{t.form.none}</option> <option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => ( {(colorVarieties.data ?? []).map((cv) => (
@@ -387,7 +407,23 @@ export default function GerbilFormPage() {
className="input" className="input"
value={form.genotype} value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere" placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => set('genotype', e.target.value)} onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)} aria-invalid={Boolean(errors.genotype)}
/> />
<small className={errors.genotype ? 'error-text' : 'muted'}> <small className={errors.genotype ? 'error-text' : 'muted'}>

View File

@@ -29,7 +29,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = {
export default function GerbilsPage() { export default function GerbilsPage() {
const t = de.pages.gerbils const t = de.pages.gerbils
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [status, setStatus] = useState<GerbilStatus | ''>('Active') const [status, setStatus] = useState<GerbilStatus | ''>('Breeding')
const [gender, setGender] = useState<Gender | ''>('') const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('') const [colorVarietyId, setColorVarietyId] = useState('')
const [originBreeder, setOriginBreeder] = useState('') const [originBreeder, setOriginBreeder] = useState('')
@@ -64,7 +64,7 @@ export default function GerbilsPage() {
const resetFilters = () => { const resetFilters = () => {
setSearch('') setSearch('')
setStatus('Active') setStatus('Breeding')
setGender('') setGender('')
setColorVarietyId('') setColorVarietyId('')
setOriginBreeder('') setOriginBreeder('')
@@ -85,7 +85,7 @@ export default function GerbilsPage() {
// UX-MOBILE-1: count non-default filter values for the badge. // UX-MOBILE-1: count non-default filter values for the badge.
const activeFilterCount = const activeFilterCount =
(status !== 'Active' ? 1 : 0) + (status !== 'Breeding' ? 1 : 0) +
(gender !== '' ? 1 : 0) + (gender !== '' ? 1 : 0) +
(colorVarietyId !== '' ? 1 : 0) + (colorVarietyId !== '' ? 1 : 0) +
(originBreeder !== '' ? 1 : 0) + (originBreeder !== '' ? 1 : 0) +

View File

@@ -28,6 +28,7 @@ import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil, Litter } from '../api/types' import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
@@ -379,7 +380,7 @@ function PedigreeCard({
const t = de.pages.stammbaum const t = de.pages.stammbaum
const g = node.gerbil const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null const chip = farbschlag ? chipColorFor(farbschlag) : null
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
return ( return (
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
@@ -389,7 +390,7 @@ function PedigreeCard({
> >
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */} {/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
<div className="pedigree-card__photo" aria-hidden="true"> <div className="pedigree-card__photo" aria-hidden="true">
🐭 <GerbilIcon size="1.6rem" />
</div> </div>
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
@@ -406,11 +407,12 @@ function PedigreeCard({
<span <span
className="pedigree-chip" className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined} style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag}
> >
{farbschlag} {g.genotype || farbschlag}
</span> </span>
)} )}
{year && <span className="pedigree-card__year">* {year}</span>} {dob && <span className="pedigree-card__year">* {dob}</span>}
</div> </div>
{node.expandable && ( {node.expandable && (
<button <button

View File

@@ -1,13 +1,14 @@
import { useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getLitter } from '../api/litters' import { getLitter } from '../api/litters'
import { getGerbil, listGerbils } from '../api/gerbils' import { getGerbil, listGerbils } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups'
import { condition } from '../api/gridify' import { condition } from '../api/gridify'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { isValidGenotype } from '../format/genotypeText' import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, type BreedingResult } from '../genetics' import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView' import BreedingResultView from '../components/BreedingResultView'
export default function WurfDetailPage() { export default function WurfDetailPage() {
@@ -43,6 +44,38 @@ export default function WurfDetailPage() {
return breed(fromDisplayString(fg), fromDisplayString(mg)) return breed(fromDisplayString(fg), fromDisplayString(mg))
}, [father.data, mother.data]) }, [father.data, mother.data])
// Farbschlag + Abnehmer lookups for the juvenile list.
const colorVarieties = useApi(() => listColorVarieties(), [])
const contacts = useApi(() => listContacts(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const contactNameById = useMemo(
() => new Map((contacts.data ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const farbschlagOf = useCallback(
(g: { colorVarietyId: string | null; genotype: string | null }): string | null => {
if (g.colorVarietyId) {
const name = colorNameById.get(g.colorVarietyId)
if (name) return name
}
if (g.genotype?.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
return name === UNKNOWN_FARBSCHLAG ? null : name
} catch {
return null
}
}
return null
},
[colorNameById],
)
if (litter.loading) return <p className="muted">{de.common.loading}</p> if (litter.loading) return <p className="muted">{de.common.loading}</p>
if (litter.error || !litter.data) { if (litter.error || !litter.data) {
return ( return (
@@ -117,14 +150,24 @@ export default function WurfDetailPage() {
{!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>} {!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>}
{registered > 0 && ( {registered > 0 && (
<ul className="card-list"> <ul className="card-list">
{(juveniles.data?.items ?? []).map((g) => ( {(juveniles.data?.items ?? []).map((g) => {
const farbe = farbschlagOf(g)
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) : null
const tJf = t.detail.juvenileFields
return (
<li key={g.id}> <li key={g.id}>
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> <Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">{g.name}</span> <span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">{de.pages.gerbils.genderLabels[g.gender]}</span> <span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
</Link> </Link>
</li> </li>
))} )
})}
</ul> </ul>
)} )}

View File

@@ -285,14 +285,15 @@
.pedigree-chip { .pedigree-chip {
align-self: flex-start; align-self: flex-start;
max-width: 100%; max-width: 100%;
font-size: 0.68rem; font-size: 0.62rem;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
border-radius: 1rem; border-radius: 1rem;
background: var(--color-accent-soft); background: var(--color-accent-soft);
color: var(--color-accent); color: var(--color-accent);
white-space: nowrap; white-space: normal;
word-break: break-word;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
} }
.pedigree-card__year { .pedigree-card__year {

View File

@@ -22,7 +22,7 @@ function makeGerbil(id: string, name: string, litterId: string | null = null): G
id, id,
name, name,
gender: 'unknown', gender: 'unknown',
status: 'Active', status: 'Breeding',
dateOfBirth: null, dateOfBirth: null,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,

View File

@@ -27,7 +27,7 @@ function makeGerbil(over: Partial<Gerbil> & { id: string }): Gerbil {
return { return {
name: over.id, name: over.id,
gender: 'unknown', gender: 'unknown',
status: 'Active' as GerbilStatus, status: 'Breeding' as GerbilStatus,
dateOfBirth: null, dateOfBirth: null,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,

View File

@@ -83,7 +83,7 @@ export function farbschlagDistribution(
): NameCount[] { ): NameCount[] {
const byName = new Map<string | null, number>() const byName = new Map<string | null, number>()
for (const g of gerbils) { for (const g of gerbils) {
if (g.status !== 'Active') continue if (g.status !== 'Breeding' && g.status !== 'Pet') continue
const name = resolveName(g) const name = resolveName(g)
byName.set(name, (byName.get(name) ?? 0) + 1) byName.set(name, (byName.get(name) ?? 0) + 1)
} }

View File

@@ -124,6 +124,11 @@ export const de = {
isDeafUnknown: 'Unbekannt', isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja', isDeafYes: 'Ja',
isDeafNo: 'Nein', isDeafNo: 'Nein',
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
GivenAway: 'Wird automatisch gesetzt nach Abgabe.',
},
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',
@@ -141,8 +146,10 @@ export const de = {
male: 'Männlich', male: 'Männlich',
female: 'Weiblich', female: 'Weiblich',
}, },
// STATUS-MODEL: Breeding/Pet ersetzen Active; Deceased+GivenAway = abgeleitet (Backend)
statusLabels: { statusLabels: {
Active: 'Aktiv', Breeding: 'Zucht',
Pet: 'Liebhaber',
Deceased: 'Verstorben', Deceased: 'Verstorben',
GivenAway: 'Abgegeben', GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe', ForSale: 'Zur Abgabe',
@@ -189,6 +196,14 @@ export const de = {
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Liste', back: 'Zurück zur Liste',
notFound: 'Dieser Wurf wurde nicht gefunden.', notFound: 'Dieser Wurf wurde nicht gefunden.',
// LITTER-JUVENILE-DETAILS: pro-Jungtier-Felder
juvenileFields: {
colorVariety: 'Farbschlag',
goHomeDate: 'Abgegeben',
receiver: 'Abnehmer',
dateOfDeath: 'Verstorben',
causeOfDeath: 'Todesursache',
},
}, },
// Formular // Formular
form: { form: {