diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index ec661ea..f631c20 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -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) | diff --git a/arbeitskacheln/01_foundation.md b/arbeitskacheln/01_foundation.md index ed52c40..67cf6c8 100644 --- a/arbeitskacheln/01_foundation.md +++ b/arbeitskacheln/01_foundation.md @@ -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`. --- diff --git a/backend/alembic/versions/0053_systemeinstellung.py b/backend/alembic/versions/0053_systemeinstellung.py new file mode 100644 index 0000000..b72977e --- /dev/null +++ b/backend/alembic/versions/0053_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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index f9f3e24..9cd37b8 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/systemeinstellung.py b/backend/app/api/v1/endpoints/systemeinstellung.py new file mode 100644 index 0000000..b053348 --- /dev/null +++ b/backend/app/api/v1/endpoints/systemeinstellung.py @@ -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)) diff --git a/backend/app/core/app_settings.py b/backend/app/core/app_settings.py index 416de89..2676be5 100644 --- a/backend/app/core/app_settings.py +++ b/backend/app/core/app_settings.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index f3dfcaf..3032413 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 668878c..ded726a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/systemeinstellung.py b/backend/app/models/systemeinstellung.py new file mode 100644 index 0000000..030c7cc --- /dev/null +++ b/backend/app/models/systemeinstellung.py @@ -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) diff --git a/backend/app/schemas/systemeinstellung.py b/backend/app/schemas/systemeinstellung.py new file mode 100644 index 0000000..1e144c9 --- /dev/null +++ b/backend/app/schemas/systemeinstellung.py @@ -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 diff --git a/backend/app/services/systemeinstellung.py b/backend/app/services/systemeinstellung.py new file mode 100644 index 0000000..a536313 --- /dev/null +++ b/backend/app/services/systemeinstellung.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 383f97b..a82c50e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/test_found013_systemeinstellung.py b/backend/tests/test_found013_systemeinstellung.py new file mode 100644 index 0000000..41b82cc --- /dev/null +++ b/backend/tests/test_found013_systemeinstellung.py @@ -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} diff --git a/deploy/README.md b/deploy/README.md index 67e6d54..c7eb1b1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 .sql.gz | psql `). +## 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@:/root/ +ssh root@ +DOMAIN= ./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 .sql.gz | psql `). - `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 diff --git a/deploy/glitchtip-install.sh b/deploy/glitchtip-install.sh new file mode 100755 index 0000000..faf0af1 --- /dev/null +++ b/deploy/glitchtip-install.sh @@ -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 < 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://:${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." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f04dcf5..75953d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 26540f0..4c44e32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 323c550..f8b9abf 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/monitoring/sentry.ts b/frontend/src/monitoring/sentry.ts new file mode 100644 index 0000000..ee02351 --- /dev/null +++ b/frontend/src/monitoring/sentry.ts @@ -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 { + 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. + } +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index b9e7453..5945db8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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() { )} {aktiverTab === "inventur" && } + {aktiverTab === "fehlerTracking" && } {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/FehlerTrackingSection.tsx b/frontend/src/pages/admin/FehlerTrackingSection.tsx new file mode 100644 index 0000000..754d5e4 --- /dev/null +++ b/frontend/src/pages/admin/FehlerTrackingSection.tsx @@ -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(null); + const [dsn, setDsn] = useState(""); + const [speichert, setSpeichert] = useState(false); + + async function laden() { + try { + const daten = await apiRequest("/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("/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("/einstellungen/sentry", { + method: "PATCH", + body: { sentry_dsn: null }, + }); + setAktuell(daten); + } catch { + onFehler("Einstellung konnte nicht gespeichert werden."); + } finally { + setSpeichert(false); + } + } + + return ( +
+

Fehler-Tracking (GlitchTip)

+
+

+ Optional: unbehandelte Fehler in Backend und Frontend an eine selbst gehostete + GlitchTip-Instanz melden (Sentry-API-kompatibel). Installation der GlitchTip-Instanz + selbst per deploy/glitchtip-install.sh 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. +

+ + {aktuell && ( +
+ + {aktuell.aktiv ? "Aktiv" : "Deaktiviert"} + +
+ )} + +
+ setDsn(e.target.value)} + /> + + {aktuell?.aktiv && ( + + )} +
+
+
+ ); +}