Compare commits
12 Commits
feature/we
...
feature/d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 7f6855be62 | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| 2198c33898 | |||
| f20a42a94b |
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
327
docs/ops.md
327
docs/ops.md
@@ -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. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
Erster Start dauert ca. 2–3 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 → Einstellungen → Secrets:
|
||||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
|
||||||
|
|
||||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
| Secret | Wert |
|
||||||
```bash
|
|--------|------|
|
||||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||||
docker run -d --name gitea-runner \
|
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||||
-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:**
|
### Update nach CI-Push
|
||||||
Gitea → Repository → Einstellungen → Secrets:
|
|
||||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
|
||||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
|
||||||
|
|
||||||
### Workflow nach Aktivierung
|
```bash
|
||||||
|
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||||
```
|
cd /opt/gerbilmanager
|
||||||
git push origin main
|
git pull
|
||||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
docker compose -f deploy/truenas/compose.yaml pull
|
||||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
→ Auf NAS: docker compose pull + 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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
35
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
35
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -102,9 +102,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Skarlett von den Kleinen Chaoten",
|
"name": "Skarlett von den Kleinen Chaoten",
|
||||||
"dob": "14.07.2013",
|
"dob": "14.07.2013",
|
||||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
|
||||||
"dateOfDeath": "17.04.2016",
|
"dateOfDeath": "01.01.2017",
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kazu von den Kleinen Chaoten",
|
"name": "Kazu von den Kleinen Chaoten",
|
||||||
@@ -162,6 +162,57 @@
|
|||||||
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
||||||
"dateOfDeath": "18.12.2020",
|
"dateOfDeath": "18.12.2020",
|
||||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Max von Privat",
|
||||||
|
"dob": "01.02.2013",
|
||||||
|
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
|
||||||
|
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
|
||||||
|
"dateOfDeath": "01.01.2014",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Isa of Golden Lights",
|
||||||
|
"dob": "24.12.2014",
|
||||||
|
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
|
||||||
|
"dateOfDeath": "21.07.2018",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jack II von den Kleinen Chaoten",
|
||||||
|
"dob": "14.02.2016",
|
||||||
|
"decision": "Sterbedatum von der Züchterin",
|
||||||
|
"dateOfDeath": "06.10.2019",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Milon von den Kleinen Chaoten",
|
||||||
|
"dob": "27.11.2014",
|
||||||
|
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
|
||||||
|
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sunny von PZ Karl",
|
||||||
|
"dob": "10.04.2014",
|
||||||
|
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
|
||||||
|
"dateOfDeath": "30.04.2018",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dakota of sweet little mouse",
|
||||||
|
"dob": "30.01.2015",
|
||||||
|
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
|
||||||
|
"genotype": "Aa CC Dd Ee Gg pp Spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Banjo of Fiomi",
|
||||||
|
"dob": "06.07.2015",
|
||||||
|
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
|
||||||
|
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||||
|
"farbschlag": "Goldfuchs Starkschecke",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user