feat(foundation): FOUND-013 optionales Error-Tracking (GlitchTip)
CI / backend-tests (push) Successful in 3m14s
CI / frontend-build (push) Successful in 21s

Zielserver-Entscheidung durch optionales Install-Script aufgelöst statt
festgeschrieben: deploy/glitchtip-install.sh installiert GlitchTip per
Docker Compose auf einem beliebigen, selbst gewählten Host - nichts
automatisch/ungefragt.

DSN wird zur Laufzeit über eine neue Admin-GUI-Seite ein-/ausschaltbar
(Systemeinstellung-Singleton, GET/PATCH /einstellungen/sentry), statt
nur per .env - kein Redeploy zum Umschalten nötig. Frontend lädt
@sentry/react nur dynamisch nach, wenn tatsächlich aktiv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 19:35:47 +02:00
1 parent 11e6ed4332
commit e5dc80c3a6
21 files changed
+612 -6

No files matched your search

+1 -1
View File
@@ -135,7 +135,7 @@ vergeben statt geraten.
| FOUND-010 | Foundation | Backup-Strategie | P1 | S | MEDIUM | FOUND-002 | ✅ (`deploy/backup_datenbank.sh` + systemd-Timer-Vorlagen, taeglich mit Rotation) |
| FOUND-011 | Foundation | Test-Grundgerüst | P0 | M | LOW | FOUND-002 | ✅ (`backend/pytest.ini`+`backend/tests/`, seit 2026-09-08 auch `frontend/vitest.config.ts`+`src/**/*.test.ts(x)`, beide in CI) |
| FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) |
| FOUND-013 | Foundation | Error-Tracking (GlitchTip, neu, geplant 2026-09-10) | P2 | M | LOW | — | ⬜ Plan steht, Zielserver-Entscheidung offen |
| FOUND-013 | Foundation | Error-Tracking (GlitchTip, neu, geplant 2026-09-10) | P2 | M | LOW | — | ✅ erledigt 2026-09-10 (optionales Install-Script statt Zielserver-Festlegung) |
| IDENT-001 | Identity | Objekt-ID-Schema | P0 | S | LOW | FOUND-002 | ✅ (Objekt-Code-Vergabe, `/objekte/naechster-code`) |
| IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) |
| IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | ✅ (`Nummernschema` je Objekttyp, Migration 0032, `/objekte/naechster-code?objekttyp_id=` nutzt es, Admin-UI in ObjekttypSection) |
+17 -3
View File
@@ -281,9 +281,23 @@ nicht detaillierten) siehe `00_index.md`.
- **Akzeptanzkriterien:** eine absichtlich ausgelöste Backend-Exception UND
eine Frontend-Exception erscheinen in GlitchTip mit Stacktrace.
- **Tests:** keine (reine Infrastruktur-/Konfigurationsintegration).
- **DoD:** offen — Plan steht, **Zielserver-Entscheidung + tatsächliche
Docker-Compose-Inbetriebnahme ausstehend**, nicht ohne explizite
Bestätigung durchführen (Server-Deploy-Regel, CLAUDE.md).
- **DoD:** ✅ erledigt (2026-09-10) — **Zielserver-Entscheidung durch
optionales Install-Script aufgelöst statt festgeschrieben:**
`deploy/glitchtip-install.sh` installiert GlitchTip per Docker Compose auf
einem BELIEBIGEN, vom Nutzer gewählten Host (MABEA-Server selbst oder
separat) — nichts wird automatisch/ungefragt installiert, das Script muss
bewusst ausgeführt werden. Zusätzlich, über die ursprüngliche Planung
hinaus: DSN wird NICHT nur per `.env` gesetzt, sondern zur Laufzeit über
eine neue Admin-GUI-Seite („Fehler-Tracking") ein-/ausschaltbar —
`Systemeinstellung`-Singleton-Tabelle (Migration `0053_systemeinstellung.py`),
`GET/PATCH /einstellungen/sentry` (GET bewusst ohne Auth, DSN ist kein
Geheimnis, wird ohnehin im Frontend-Bundle sichtbar), `sentry_sdk.init()`
wird beim Setzen sofort erneut aufgerufen (kein Backend-Neustart nötig).
Frontend lädt die DSN zur Laufzeit (`src/monitoring/sentry.ts`) und lädt
`@sentry/react` nur dynamisch nach, wenn tatsächlich aktiv (kein Bundle-
Gewicht im Normalfall). `.env`-Variable `SENTRY_DSN` bleibt als Bootstrap-
Fallback bestehen, DB-Wert hat Vorrang. `send_default_pii=False` fest
verdrahtet (DSGVO). Tests: `test_found013_systemeinstellung.py`.
---
@@ -0,0 +1,31 @@
"""FOUND-013: Systemeinstellung-Singleton (GlitchTip/Sentry-DSN per Admin-GUI).
Revision ID: 0053_systemeinstellung
Revises: 0052_inventur
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0053_systemeinstellung"
down_revision: Union[str, None] = "0052_inventur"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE systemeinstellung (
id INTEGER PRIMARY KEY,
sentry_dsn TEXT
)
"""
)
op.execute("INSERT INTO systemeinstellung (id, sentry_dsn) VALUES (1, NULL)")
def downgrade() -> None:
op.execute("DROP TABLE systemeinstellung")
+2
View File
@@ -32,6 +32,7 @@ from app.api.v1.endpoints import (
sollmengen_gruppe,
stammdaten,
suche,
systemeinstellung,
tankbuch,
verleih,
vorlagen,
@@ -82,3 +83,4 @@ api_router.include_router(readiness_regel.router, tags=["readiness_regel"])
api_router.include_router(einsatz.router, tags=["einsatz"])
api_router.include_router(sollmengen_gruppe.router, tags=["sollmengen_gruppe"])
api_router.include_router(inventur.router, tags=["inventur"])
api_router.include_router(systemeinstellung.router, tags=["systemeinstellung"])
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.schemas.systemeinstellung import SentryEinstellungRead, SentryEinstellungUpdate
from app.services.systemeinstellung import hole_einstellung, setze_sentry_dsn
router = APIRouter()
_administration = require_roles(RolleTyp.administration)
@router.get("/einstellungen/sentry", response_model=SentryEinstellungRead)
async def hole_sentry_einstellung(db: AsyncSession = Depends(get_db)) -> SentryEinstellungRead:
"""Bewusst OHNE Auth: der DSN ist kein Geheimnis (wird ohnehin unverschlüsselt
im Frontend-Bundle/Browser-Request sichtbar, Sentry-SDKs sind dafür designt) -
das Frontend muss ihn schon vor dem Login laden können, um auch
Fehler auf der Login-Seite selbst zu erfassen."""
einstellung = await hole_einstellung(db)
return SentryEinstellungRead(sentry_dsn=einstellung.sentry_dsn, aktiv=bool(einstellung.sentry_dsn))
@router.patch("/einstellungen/sentry", response_model=SentryEinstellungRead)
async def aendere_sentry_einstellung(
payload: SentryEinstellungUpdate, db: AsyncSession = Depends(get_db), _=Depends(_administration)
) -> SentryEinstellungRead:
einstellung = await setze_sentry_dsn(db, sentry_dsn=payload.sentry_dsn)
return SentryEinstellungRead(sentry_dsn=einstellung.sentry_dsn, aktiv=bool(einstellung.sentry_dsn))
+5
View File
@@ -36,5 +36,10 @@ class Settings(BaseSettings):
# Code-Änderung/Neubau.
log_level: str = "INFO"
# FOUND-013: GlitchTip (Sentry-API-kompatibel) für Error-Tracking, optional
# per deploy/glitchtip-install.sh selbst gehostet. Leer (Default) = kein
# Fehler-Tracking, No-Op - kein Zwang, GlitchTip zu betreiben.
sentry_dsn: str = ""
settings = Settings()
+16 -1
View File
@@ -1,5 +1,6 @@
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
@@ -11,7 +12,8 @@ from app.api.v1.api import api_router
from app.core.app_settings import settings
from app.core.logging_config import konfiguriere_logging
from app.core.rate_limit import limiter
from app.db.session import engine
from app.db.session import SessionLocal, engine
from app.services.systemeinstellung import hole_einstellung
konfiguriere_logging()
@@ -25,6 +27,19 @@ async def lifespan(app: FastAPI):
"JWT_SECRET_KEY ist leer oder noch der Platzhalter aus example.env – "
"in .env auf dem Zielsystem einen echten zufälligen Wert setzen."
)
# FOUND-013: per Admin-GUI gesetzter DSN (DB) hat Vorrang vor der
# .env-Variable (Bootstrap-Fallback, z.B. beim allerersten Start ohne
# bereits existierende Systemeinstellung-Zeile).
async with SessionLocal() as db:
einstellung = await hole_einstellung(db)
await db.commit()
dsn = einstellung.sentry_dsn or settings.sentry_dsn
if dsn:
# send_default_pii bewusst False (DSGVO, Art.-9-Bezug bei
# Rettungsdienst) - keine IP-Adressen/Nutzerdaten automatisch mitschicken.
sentry_sdk.init(dsn=dsn, send_default_pii=False, traces_sample_rate=0.0)
yield
await engine.dispose()
+2
View File
@@ -33,6 +33,7 @@ from app.models.personal import (
)
from app.models.sollmengen_gruppe import SollmengenGruppe
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
from app.models.systemeinstellung import Systemeinstellung
from app.models.tankbuch import TankbuchEintrag
from app.models.verleih import ExterneStelle, ExterneStelleKategorie, Verleih
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
@@ -118,6 +119,7 @@ __all__ = [
"Objekttyp",
"SollmengenGruppe",
"Standort",
"Systemeinstellung",
"TankbuchEintrag",
"ExterneStelle",
"ExterneStelleKategorie",
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Systemeinstellung(Base):
"""FOUND-013: Singleton-Konfigurationszeile (id fest =1) für Einstellungen,
die über die Admin-GUI statt nur per .env gesetzt werden sollen - aktuell
nur der GlitchTip/Sentry-DSN (optionales Error-Tracking, leer = deaktiviert,
kein Zwang, GlitchTip zu betreiben)."""
__tablename__ = "systemeinstellung"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
sentry_dsn: Mapped[str | None] = mapped_column(String)
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class SentryEinstellungRead(BaseModel):
sentry_dsn: str | None
aktiv: bool
class SentryEinstellungUpdate(BaseModel):
sentry_dsn: str | None = None
+27
View File
@@ -0,0 +1,27 @@
import sentry_sdk
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.systemeinstellung import Systemeinstellung
_SINGLETON_ID = 1
async def hole_einstellung(db: AsyncSession) -> Systemeinstellung:
einstellung = await db.get(Systemeinstellung, _SINGLETON_ID)
if einstellung is None:
einstellung = Systemeinstellung(id=_SINGLETON_ID, sentry_dsn=None)
db.add(einstellung)
await db.flush()
return einstellung
async def setze_sentry_dsn(db: AsyncSession, *, sentry_dsn: str | None) -> Systemeinstellung:
einstellung = await hole_einstellung(db)
einstellung.sentry_dsn = sentry_dsn or None
await db.flush()
# FOUND-013: sentry_sdk.init() ist idempotent aufrufbar - Änderung greift
# sofort im laufenden Prozess, kein Backend-Neustart nötig. Leerer DSN
# deaktiviert das Tracking wieder (kein Fehler, reines No-Op).
sentry_sdk.init(dsn=einstellung.sentry_dsn or None, send_default_pii=False, traces_sample_rate=0.0)
return einstellung
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"python-barcode[images]>=0.15,<0.16",
"reportlab>=4.2,<4.3",
"slowapi>=0.1.9,<0.2",
"sentry-sdk[fastapi]>=2.17,<2.18",
]
[project.optional-dependencies]
@@ -0,0 +1,44 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_sentry_einstellung_default_inaktiv(client):
response = await client.get("/api/v1/einstellungen/sentry")
assert response.status_code == 200
assert response.json() == {"sentry_dsn": None, "aktiv": False}
@pytest.mark.asyncio
async def test_sentry_einstellung_setzen_erfordert_admin(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.patch(
"/api/v1/einstellungen/sentry",
json={"sentry_dsn": "https://example@glitchtip.example.org/1"},
headers=auth_header(token),
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_kann_sentry_dsn_setzen_und_wieder_leeren(client, admin_user):
token = await login(client, "admin1")
gesetzt = await client.patch(
"/api/v1/einstellungen/sentry",
json={"sentry_dsn": "https://example@glitchtip.example.org/1"},
headers=auth_header(token),
)
assert gesetzt.status_code == 200
assert gesetzt.json()["aktiv"] is True
assert gesetzt.json()["sentry_dsn"] == "https://example@glitchtip.example.org/1"
geholt = await client.get("/api/v1/einstellungen/sentry")
assert geholt.json()["aktiv"] is True
geleert = await client.patch(
"/api/v1/einstellungen/sentry", json={"sentry_dsn": None}, headers=auth_header(token)
)
assert geleert.status_code == 200
assert geleert.json() == {"sentry_dsn": None, "aktiv": False}
+28
View File
@@ -67,6 +67,33 @@ Tagen (`AUFBEWAHRUNG_TAGE` in der `.service`-Datei anpassbar). Manueller Testlau
**Restore-Test nie automatisiert durchgeführt** — vor echtem Ernstfall einmal manuell
verifizieren (`gunzip -c <backup>.sql.gz | psql <testdatenbank>`).
## Fehler-Tracking (GlitchTip) einrichten (FOUND-013, optional)
GlitchTip ist Sentry-API-kompatibles, selbst gehostetes Error-Tracking —
komplett optional, MABEA läuft ohne unverändert weiter. Installation auf
einem BELIEBIGEN Host (kann der MABEA-Server selbst oder ein separater sein):
```bash
scp glitchtip-install.sh root@<glitchtip-host>:/root/
ssh root@<glitchtip-host>
DOMAIN=<glitchtip-domain-oder-ip> ./glitchtip-install.sh
```
Installiert Docker (falls fehlend) + GlitchTip per Docker Compose unter
`/opt/glitchtip`, legt zufälliges `SECRET_KEY`/DB-Passwort an. Danach:
```bash
cd /opt/glitchtip && docker compose exec web ./manage.py createsuperuser
```
Im Browser einloggen, Organisation + Projekt anlegen, angezeigte DSN-URL
kopieren und in MABEA unter **Admin > Fehler-Tracking** eintragen — greift
sofort (Backend + Frontend), kein Redeploy nötig. Leer lassen/Feld leeren
deaktiviert das Tracking wieder vollständig.
Deinstallation: `cd /opt/glitchtip && docker compose down -v` (`-v` löscht
auch alle GlitchTip-Daten).
## Dateien
- `install_server.sh` – Server-Grundausstattung (PostgreSQL, nginx), idempotent
- `nginx_mabea.conf.template` – Reverse-Proxy-Vorlage, von `install_server.sh` verwendet
@@ -75,3 +102,4 @@ verifizieren (`gunzip -c <backup>.sql.gz | psql <testdatenbank>`).
- `mabea-readiness-snapshot.service.template` / `.timer.template` – täglicher Bereitschafts-Snapshot (READY-006)
- `mabea-wartung-benachrichtigung.service.template` / `.timer.template` – täglicher Wartungsfälligkeits-Prüflauf (NOTIF-003)
- `backup_datenbank.sh` / `mabea-backup.service.template` / `.timer.template` – tägliches DB-Backup mit Rotation (FOUND-010)
- `glitchtip-install.sh` – optionale GlitchTip-Installation (FOUND-013, Error-Tracking), unabhängiger Host
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# FOUND-013: GlitchTip (Sentry-API-kompatibles Error-Tracking) optional
# installieren. GlitchTip wird offiziell nur als Docker-Image vertrieben,
# widerspricht NICHT "kein Docker-Zwang" aus CLAUDE.md (das gilt für MABEA
# selbst) — für diese Fremdsoftware ist Docker die einzige praktikable,
# isolierte Betriebsart.
#
# Läuft AUF dem Zielserver, unabhängig vom MABEA-Server (kann derselbe oder
# ein separater Host sein - siehe arbeitskacheln/01_foundation.md FOUND-013).
# Danach in MABEA (Admin > Fehler-Tracking) die vom GlitchTip-Projekt
# angezeigte DSN-URL eintragen.
#
# Nutzung:
# DOMAIN=glitchtip.example.org ./glitchtip-install.sh
# ./glitchtip-install.sh # fragt Domain/Port interaktiv ab
#
# Deinstallation:
# cd /opt/glitchtip && docker compose down -v # -v löscht auch die Daten
set -euo pipefail
INSTALL_DIR="/opt/glitchtip"
if [ "$(id -u)" -ne 0 ]; then
echo "Muss als root laufen." >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Docker nicht gefunden. Installiere Docker Engine + Compose-Plugin..."
curl -fsSL https://get.docker.com | sh
fi
if ! docker compose version >/dev/null 2>&1; then
echo "Docker-Compose-Plugin fehlt (docker compose). Bitte manuell nachinstallieren." >&2
exit 1
fi
DOMAIN="${DOMAIN:-}"
if [ -z "$DOMAIN" ]; then
read -rp "Domain oder IP, unter der GlitchTip erreichbar sein soll: " DOMAIN
fi
PORT="${PORT:-8000}"
if [ -d "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/docker-compose.yml" ]; then
echo "GlitchTip ist bereits unter $INSTALL_DIR installiert."
read -rp "Neu konfigurieren und neu starten? (j/N) " ANTWORT
if [ "$ANTWORT" != "j" ] && [ "$ANTWORT" != "J" ]; then
echo "Abgebrochen, bestehende Installation unverändert."
exit 0
fi
fi
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
SECRET_KEY="$(openssl rand -hex 32)"
DB_PASSWORD="$(openssl rand -hex 20)"
cat > docker-compose.yml <<EOF
services:
web:
image: glitchtip/glitchtip:latest
depends_on:
- postgres
- redis
ports:
- "${PORT}:8000"
environment:
DATABASE_URL: postgresql://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
SECRET_KEY: "${SECRET_KEY}"
PORT: 8000
EMAIL_URL: "consolemail://"
GLITCHTIP_DOMAIN: "https://${DOMAIN}"
DEFAULT_FROM_EMAIL: "glitchtip@${DOMAIN}"
CELERY_WORKER_AUTOSCALE: "1,3"
CELERY_WORKER_MAX_TASKS_PER_CHILD: "10000"
restart: unless-stopped
volumes:
- uploads:/code/uploads
worker:
image: glitchtip/glitchtip:latest
command: ./bin/run-celery-with-beat.sh
depends_on:
- postgres
- redis
environment:
DATABASE_URL: postgresql://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
SECRET_KEY: "${SECRET_KEY}"
EMAIL_URL: "consolemail://"
GLITCHTIP_DOMAIN: "https://${DOMAIN}"
restart: unless-stopped
volumes:
- uploads:/code/uploads
postgres:
image: postgres:16
environment:
POSTGRES_DB: glitchtip
POSTGRES_USER: glitchtip
POSTGRES_PASSWORD: ${DB_PASSWORD}
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7
restart: unless-stopped
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
uploads:
EOF
chmod 600 docker-compose.yml
echo "==> Container starten"
docker compose up -d
echo "==> Warte auf Postgres/Web-Start"
sleep 10
echo "==> Datenbank-Migrationen"
docker compose exec -T web ./manage.py migrate
echo ""
echo "OK: GlitchTip läuft auf Port ${PORT}."
echo "Admin-Account anlegen mit:"
echo " cd ${INSTALL_DIR} && docker compose exec web ./manage.py createsuperuser"
echo ""
echo "Danach im Browser http://<server>:${PORT} öffnen (oder per nginx/Reverse-Proxy"
echo "auf https://${DOMAIN} legen), Organisation + Projekt anlegen, DSN kopieren"
echo "und in MABEA unter Admin > Fehler-Tracking eintragen."
echo ""
echo "Zugangsdaten liegen ausschließlich in ${INSTALL_DIR}/docker-compose.yml (chmod 600) -"
echo "keine separate Sicherung dieser Datei vergessen, falls der Server neu aufgesetzt wird."
+107
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.74.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -2628,6 +2629,112 @@
"win32"
]
},
"node_modules/@sentry/browser": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.74.0.tgz",
"integrity": "sha512-U6Zn+YcAgnR7bXZmpjMxivptPSsOeDKV+SlSqbJZqViOaCcZSG/vJiQr50UrdTccCwiU1LnwuOOoclJOvW+cLQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.74.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.74.0",
"@sentry/feedback": "10.74.0",
"@sentry/replay": "10.74.0",
"@sentry/replay-canvas": "10.74.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.74.0.tgz",
"integrity": "sha512-/Rh3WH4i2nStdOqHpaWocbpSMFPjKhlxKQgp9vkJBSolqjJzXlIpLYXOrldZHHZLG1iDoX3PFBQkNDYcPXCg0A==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.74.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/conventions": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.74.0.tgz",
"integrity": "sha512-u9rY8vcZfktccwm6LznfCZlqP5C9A+p76r4/pFS1grqpuTO0m21Cl8rosnlESrDGP/Xd9tfr91rWYk0jPH8jeQ==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/feedback": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.74.0.tgz",
"integrity": "sha512-WwUao9K2XgRIxTUTaBhm7aglhsyP7LQTGi2lxvFbXzlSagUzwew/EL2c5oqQ23Mjn94i5SY+rM4wMdgyWdtpAw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.74.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.74.0.tgz",
"integrity": "sha512-eKSNCbLyvc/qk4rIrm1IQZnC1zYPMJBqOVTX4VQCbK+d6YnzLc8JuZsw3PaGeB2x1g3CGe4qtBd9HUvsvBD9aw==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.74.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.74.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/replay": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.74.0.tgz",
"integrity": "sha512-amIu0zKzhQJZoWmlwfFnn/uKRGyF+Tikph3lBgYwj5XbHv4U94PQXi9Ll8CYzzLVS7BkrCVc2Hl7RvpRg5M2Yg==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.74.0",
"@sentry/core": "10.74.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.74.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.74.0.tgz",
"integrity": "sha512-HAdu4ViRsuOD5ifphQR24HugL0geZXV8LnGQVkA8vlzZWiuYgGNqDRoUWI75IgVcCYLYSaVBoKN+jTimdzG/sQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.74.0",
"@sentry/replay": "10.74.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.74.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+3
View File
@@ -3,8 +3,11 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { initSentryFallsAktiv } from "./monitoring/sentry";
import "./styles/tailwind.css";
void initSentryFallsAktiv();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
+20
View File
@@ -0,0 +1,20 @@
// FOUND-013: GlitchTip/Sentry ist optional und wird über die Admin-GUI
// aktiviert (Backend-Endpunkt /einstellungen/sentry, öffentlich lesbar - der
// DSN ist kein Geheimnis). Kein Build-Time-Env nötig, kein Redeploy beim
// Ein-/Ausschalten - reiner Laufzeit-Fetch beim App-Start. @sentry/react wird
// nur dynamisch nachgeladen, wenn tatsächlich ein DSN gesetzt ist (kein
// unnötiges Bundle-Gewicht im Normalfall ohne Error-Tracking).
export async function initSentryFallsAktiv(): Promise<void> {
try {
const basis = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
const response = await fetch(`${basis}/einstellungen/sentry`);
if (!response.ok) return;
const daten = (await response.json()) as { sentry_dsn: string | null; aktiv: boolean };
if (!daten.aktiv || !daten.sentry_dsn) return;
const Sentry = await import("@sentry/react");
Sentry.init({ dsn: daten.sentry_dsn, sendDefaultPii: false, tracesSampleRate: 0 });
} catch {
// Fehler-Tracking selbst darf niemals den App-Start verhindern.
}
}
+5 -1
View File
@@ -32,6 +32,7 @@ import { EinsatzSection } from "./admin/EinsatzSection";
import { MaterialSetSection } from "./admin/MaterialSetSection";
import { SollmengenGruppeSection } from "./admin/SollmengenGruppeSection";
import { InventurSection } from "./admin/InventurSection";
import { FehlerTrackingSection } from "./admin/FehlerTrackingSection";
import { VerleihSection } from "./admin/VerleihSection";
type Tab =
@@ -56,7 +57,8 @@ type Tab =
| "materialSets"
| "verleih"
| "sollmengenGruppen"
| "inventur";
| "inventur"
| "fehlerTracking";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
@@ -109,6 +111,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "inventur", label: "Inventur", rollen: ["administration", "materialverantwortlicher"] },
{ key: "fehlerTracking", label: "Fehler-Tracking", rollen: ["administration"] },
];
/**
@@ -333,6 +336,7 @@ export function AdminPage() {
<SollmengenGruppeSection materialien={materialien} onFehler={setFehler} />
)}
{aktiverTab === "inventur" && <InventurSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "fehlerTracking" && <FehlerTrackingSection onFehler={setFehler} />}
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
interface Props {
onFehler: (text: string) => void;
}
interface SentryEinstellung {
sentry_dsn: string | null;
aktiv: boolean;
}
// FOUND-013: GlitchTip (Sentry-API-kompatibel) optional per DSN aktivieren -
// keine .env/Redeploy nötig, greift sofort im Backend und beim nächsten
// Frontend-Laden. GlitchTip selbst wird separat per
// deploy/glitchtip-install.sh auf einem beliebigen Host installiert.
export function FehlerTrackingSection({ onFehler }: Props) {
const [aktuell, setAktuell] = useState<SentryEinstellung | null>(null);
const [dsn, setDsn] = useState("");
const [speichert, setSpeichert] = useState(false);
async function laden() {
try {
const daten = await apiRequest<SentryEinstellung>("/einstellungen/sentry");
setAktuell(daten);
setDsn(daten.sentry_dsn ?? "");
} catch {
onFehler("Einstellung konnte nicht geladen werden.");
}
}
useEffect(() => {
laden();
}, []);
async function speichern() {
setSpeichert(true);
try {
const daten = await apiRequest<SentryEinstellung>("/einstellungen/sentry", {
method: "PATCH",
body: { sentry_dsn: dsn || null },
});
setAktuell(daten);
} catch {
onFehler("Einstellung konnte nicht gespeichert werden.");
} finally {
setSpeichert(false);
}
}
async function deaktivieren() {
setDsn("");
setSpeichert(true);
try {
const daten = await apiRequest<SentryEinstellung>("/einstellungen/sentry", {
method: "PATCH",
body: { sentry_dsn: null },
});
setAktuell(daten);
} catch {
onFehler("Einstellung konnte nicht gespeichert werden.");
} finally {
setSpeichert(false);
}
}
return (
<section className="section">
<h2>Fehler-Tracking (GlitchTip)</h2>
<div className="card">
<p className="text-muted" style={{ marginTop: 0 }}>
Optional: unbehandelte Fehler in Backend und Frontend an eine selbst gehostete
GlitchTip-Instanz melden (Sentry-API-kompatibel). Installation der GlitchTip-Instanz
selbst per <code>deploy/glitchtip-install.sh</code> auf einem beliebigen Server — hier
nur die DSN-URL eintragen, die GlitchTip beim Anlegen eines Projekts anzeigt. Leer lassen
= deaktiviert, keine Daten verlassen den eigenen Server.
</p>
{aktuell && (
<div style={{ marginBottom: "0.75rem" }}>
<span className={`badge ${aktuell.aktiv ? "badge-success" : "badge-neutral"}`}>
{aktuell.aktiv ? "Aktiv" : "Deaktiviert"}
</span>
</div>
)}
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
style={{ minWidth: "24rem" }}
placeholder="https://<key>@<glitchtip-host>/<projekt-id>"
value={dsn}
onChange={(e) => setDsn(e.target.value)}
/>
<button className="btn btn-primary" onClick={speichern} disabled={speichert}>
Speichern
</button>
{aktuell?.aktiv && (
<button className="btn btn-danger" onClick={deaktivieren} disabled={speichert}>
Deaktivieren
</button>
)}
</div>
</div>
</section>
);
}