51 Commits

Author SHA1 Message Date
Benjamin Admin
520a0f401c fix: downgrade to PaddleOCR 2.x for CPU stability
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:13:41 +01:00
Benjamin Admin
6adf1fe1eb fix: force-disable oneDNN for PaddlePaddle 3.x
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:55 +01:00
Benjamin Admin
2ac6559291 fix: disable oneDNN and support PaddleOCR 3.x format
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:54:28 +01:00
Benjamin Admin
52618a0630 fix: add error handling to OCR endpoint
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:37:47 +01:00
Benjamin Admin
e1a84fd568 fix: remove warmup OCR call — causes OOM on 6G container
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:31:25 +01:00
Benjamin Admin
dd0bda05be fix: increase paddleocr memory limit 4G → 6G
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
PaddlePaddle + PP-OCRv5 model + warmup OCR needs more than 4G on
CPU-only servers. Container was OOM-killed during warmup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:08:15 +01:00
Benjamin Admin
4c68666c5c fix: add warmup OCR call to avoid timeout on first request
Some checks failed
Deploy to Coolify / deploy (push) Failing after 6s
PaddleOCR JIT-compiles on the first .ocr() call, which takes minutes
on CPU-only servers. This causes Traefik 504 Gateway Timeout.

Run a dummy OCR during startup so the first real request is fast.
Also simplify Traefik labels on paddleocr-service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:55:07 +01:00
Benjamin Admin
46b1fdc20f fix: use runs-on docker for Gitea runner compatibility
Some checks failed
Deploy to Coolify / deploy (push) Failing after 4s
The Gitea runner on Mac Mini uses label 'docker', not 'ubuntu-latest'.
Also need alpine container with curl installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:51:23 +01:00
Benjamin Admin
445cbc3100 fix: add deploy-coolify.yml workflow to coolify branch
Some checks failed
Deploy to Coolify / deploy (push) Has been cancelled
The deploy workflow was missing from the coolify branch, so pushes
to coolify never triggered a Coolify redeploy via Gitea Actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:45:58 +01:00
Benjamin Admin
8fe4473205 feat: add paddleocr-service directory to coolify branch
The docker-compose.coolify.yml references paddleocr-service/Dockerfile
but the directory only existed on main. Coolify clones the coolify branch
and needs the source files to build the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:40:26 +01:00
Benjamin Admin
07dbd78962 feat: add paddleocr-service to Coolify compose
Add PaddleOCR PP-OCRv5 service with 4G memory limit, model volume,
and health check (5min start period for model loading). Domain routing
(ocr.breakpilot.com) to be configured in Coolify UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:39:58 +01:00
Sharang Parnerkar
e9487a31c6 Replace deploy-hetzner with Coolify webhook deploy in ci.yaml
Some checks failed
CI / go-lint (pull_request) Failing after 2s
CI / python-lint (pull_request) Failing after 10s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 9s
CI / test-bqas (pull_request) Failing after 10s
CI / Deploy (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:42:41 +01:00
Sharang Parnerkar
0fb4a7e359 Remove standalone deploy-coolify.yml — deploy is handled in ci.yaml
Some checks failed
CI / go-lint (pull_request) Failing after 3s
CI / python-lint (pull_request) Failing after 11s
CI / nodejs-lint (pull_request) Failing after 3s
CI / test-go-consent (pull_request) Failing after 3s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 12s
CI / deploy-hetzner (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:26:34 +01:00
Sharang Parnerkar
cf2cabd098 Remove services not needed by SDK from Coolify deployment
Some checks failed
CI / go-lint (pull_request) Failing after 15s
CI / python-lint (pull_request) Failing after 10s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 10s
CI / deploy-hetzner (pull_request) Has been skipped
Deploy to Coolify / deploy (push) Has been cancelled
Remove backend-core, billing-service, night-scheduler, and admin-core
as they are not used by any compliance/SDK service. Update
health-aggregator CHECK_SERVICES to reference consent-service instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
8ee02bd2e4 Add healthchecks to backend-core, consent-service, billing-service, admin-core
Coolify/Traefik requires healthchecks to route traffic to containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
d9687725e5 Remove Traefik labels from coolify compose — Coolify handles routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
6c3911ca47 Fix admin-core build: ensure public directory exists before build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
30807d1ce1 Fix backend-core TARGETARCH: auto-detect instead of hardcoded arm64
The Dockerfile hardcoded TARGETARCH=arm64 for Mac Mini. Coolify server
is x86_64, causing exit code 126 (wrong binary arch). Now uses Docker
BuildKit's auto-detected TARGETARCH with dpkg fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
82c28a2b6e Add QDRANT_API_KEY support to rag-service
- Add QDRANT_API_KEY to config.py (empty string = no auth)
- Pass api_key to QdrantClient constructor (None when empty)
- Add QDRANT_API_KEY to coolify compose and env example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
86624d72dd Sync coolify compose with main: remove voice-service, update rag/embedding
- Remove voice-service (removed in main branch)
- Remove voice_session_data volume
- Add OLLAMA_URL and OLLAMA_EMBED_MODEL to rag-service
- Update embedding-service default model to BAAI/bge-m3, memory 4G→8G
- Update health-aggregator CHECK_SERVICES (remove voice-service)
- Update .env.coolify.example accordingly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
9218664400 fix: use Alpine-compatible addgroup/adduser flags in Dockerfiles
Replace --system/--gid/--uid (Debian syntax) with -S/-g/-u (BusyBox/Alpine).
Coolify ARG injection causes exit code 255 with Debian-style flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
8fa5d9061a refactor(coolify): externalize postgres, qdrant, S3; remove jitsi/synapse
- Remove PostgreSQL, Qdrant, MinIO services (managed separately in Coolify)
- Remove Jitsi stack (web, xmpp, jicofo, jvb) and Synapse/synapse-db
- Add POSTGRES_HOST, QDRANT_URL, S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY env vars
- Remove Traefik labels from internal-only services
- Health aggregator no longer checks external services
- Core now has 10 services: valkey + 9 application services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
84002f5719 feat: add Coolify deployment configuration
Add docker-compose.coolify.yml (17 services), .env.coolify.example,
and Gitea Action workflow for Coolify API deployment. Removes nginx,
vault, gitea, woodpecker, mailpit, and dev-only services. Adds Traefik
labels for *.breakpilot.ai domain routing with Let's Encrypt SSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Benjamin Admin
8b87b90cbb fix(qdrant): Increase ulimits for RocksDB (Too many open files)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
CI / deploy-hetzner (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:31:16 +01:00
Benjamin Admin
be45adb975 fix(rag): Auto-create Qdrant collection on first index
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 31s
CI / deploy-hetzner (push) Successful in 38s
Collections may not exist if init_collections() failed at startup
(e.g. Qdrant not ready). Now index_documents() ensures the
collection exists before upserting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:02:05 +01:00
Benjamin Admin
7c932c441f feat(rag): Add bp_compliance_gesetze + bp_compliance_ce collections
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 35s
CI / test-python-voice (push) Successful in 50s
CI / test-bqas (push) Successful in 33s
CI / deploy-hetzner (push) Successful in 39s
Required for Verbraucherschutz + EU law ingestion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:41:26 +01:00
Benjamin Admin
1eb402b3da fix(ci): Remove Ollama host port binding — port 11434 already in use
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
CI / deploy-hetzner (push) Successful in 1m18s
Host already has Ollama running (LibreChat). Our container only needs
internal docker network access via container name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:04:32 +01:00
Benjamin Admin
963e824328 fix(ci): Use external network + pre-create breakpilot-network
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 30s
CI / deploy-hetzner (push) Failing after 15s
Network already exists from compliance project — use external: true
and pre-create with docker network create before docker compose up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:01:17 +01:00
Benjamin Admin
c0782e0039 fix(ci): Fix backend-core TARGETARCH for amd64 + set -e in deploy
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 33s
CI / deploy-hetzner (push) Failing after 1m17s
- backend-core Dockerfile defaults TARGETARCH=arm64, override with build arg
- Add set -e in helper container to fail fast on build errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:51:19 +01:00
Benjamin Admin
44d66e2d6c feat(ci): Add Hetzner deployment for Core services
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 34s
CI / deploy-hetzner (push) Successful in 3m29s
- docker-compose.hetzner.yml: Override for x86_64 (platform, ports,
  Ollama container for CPU embeddings, mailpit dummy, disabled services)
- CI: deploy-hetzner job using helper-container pattern
- Services: postgres, valkey, qdrant, ollama, backend-core, consent-service,
  rag-service, embedding-service, health-aggregator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:42:41 +01:00
Benjamin Admin
f9b475db8f fix: Ensure public/ dir exists in Docker build for levis-holzbau
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 38s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:06:54 +01:00
Benjamin Admin
0770ff499b feat: Add LEVIS Holzbau — Kinder-Holzwerk-Website (Port 3013)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 39s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 37s
Neue statische Website fuer Kinder (6-12 Jahre) mit 8 Holzprojekten,
SVG-Illustrationen, Sicherheitshinweisen und kindgerechtem Design.
Next.js 15 + Tailwind + Framer Motion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:03:21 +01:00
Benjamin Admin
32aade553d Switch MinIO from local to Hetzner Object Storage
Migrate rag-service S3 config from local MinIO (minio:9000) to
Hetzner Object Storage (nbg1.your-objectstorage.com) with HTTPS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:07:26 +01:00
Benjamin Admin
f467db2ea0 fix(pitch-deck): Waiting-Indicator in ChatFAB (richtiges Komponente)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 25s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 26s
ChatInterface.tsx war falsch — der echte Investor Agent laeuft in
ChatFAB.tsx. Animierte Punkte + firstChunk-Logik dort implementiert.
Session-History laeuft bereits korrekt (FAB permanent gemountet).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:39:19 +01:00
Benjamin Admin
35aad9b169 fix(pitch-deck): Stale-Closure-Bug im Waiting-Indicator behoben
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 25s
isWaiting im async Closure war immer true — lokale Variable
firstChunk ersetzt den State-Check zuverlaessig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:28:12 +01:00
Benjamin Admin
806d3e0b56 feat(pitch-deck): Waiting-Indicator im Investor Agent Chat
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 29s
Drei animierte Punkte (iMessage-Style) erscheinen sofort nach dem
Absenden und verschwinden wenn der erste Token eintrifft.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:16:22 +01:00
Benjamin Admin
9f0e8328e5 fix(pitch-deck): qwen3.5 thinking-mode deaktiviert, num_ctx 8192
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 29s
Qwen3.5 denkt standardmaessig intern durch (think: true) — das
ueberschreitet den 2-Minuten-Timeout des Investor Agents.
think: false + num_ctx 8192 sorgt fuer schnelle direkte Antworten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 08:31:17 +01:00
Benjamin Admin
65184c02c3 chore: LLM qwen3:30b-a3b → qwen3.5:35b-a3b
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 24s
CI / test-bqas (push) Successful in 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:32:35 +01:00
Benjamin Admin
4245e24980 docs: Woodpecker CI aus MkDocs entfernt — Gitea Actions dokumentiert
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:27:00 +01:00
Benjamin Admin
8dc1b4c67f chore: Woodpecker CI entfernt — nur noch Gitea Actions
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 27s
Woodpecker wird nicht mehr verwendet. Wir migrieren vollstaendig
auf Gitea Actions (gitea.meghsakha.com).

Entfernt:
- woodpecker-server + woodpecker-agent Container (docker-compose.yml)
- woodpecker_data Volume
- backend-core/woodpecker_proxy_api.py (SQLite-DB Proxy)
- admin-core/app/api/admin/infrastructure/woodpecker/route.ts
- admin-core/app/api/webhooks/woodpecker/route.ts
- .woodpecker/main.yml (alte CI-Pipeline-Konfiguration)

Bereinigt:
- ci-cd/page.tsx: Woodpecker-Tab + Status-Karte + State entfernt
- types/infrastructure-modules.ts: Woodpecker-Typen + API-Endpunkte
- DevOpsPipelineSidebar.tsx: Textbeschreibungen auf Gitea Actions
- dashboard/page.tsx: Woodpecker aus Service-Health-Liste
- sbom/page.tsx: Woodpecker aus SBOM-Liste
- navigation.ts: Beschreibung aktualisiert
- .env.example: WOODPECKER_* Variablen entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:05:08 +01:00
Benjamin Admin
2801e44d39 feat(pitch-deck): Wettbewerbsanalyse aktualisiert — 761K LOC, 44 Features, 57 Compliance-Module, 9 USPs
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 27s
- EngineeringSlide: 691K→761K LOC, TS 403K→408K, Python 160K→213K, Go 127K→141K
- CompetitionSlide: Security-Features durch Compliance-USPs ersetzt (Self-Hosted, PII-Redaction, IPFS, SDK)
- i18n: Solution Pillar '57 Module', Competition Subtitle, Engineering Subtitle aktualisiert
- DB: 18 neue Features (DSR, Consent, Academy, Whistleblower, Incidents, etc.), Metrics + Competitors aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:59:54 +01:00
Benjamin Admin
62ecb3eb24 refactor: GPU Infrastruktur aus Core Admin entfernt (liegt im Lehrer)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 30s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:30:02 +01:00
Benjamin Admin
fe9a9c2df2 refactor: Entwicklung-Kategorie aus Core Admin entfernt
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 29s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 28s
Screen Flow, Brandbook und Developer Docs waren veraltet und werden nicht mehr benoetigt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:20:03 +01:00
Benjamin Admin
5fe2617857 refactor: Unified Inbox aus Core entfernt (nach Lehrer migriert)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 29s
CI / test-python-voice (push) Successful in 27s
CI / test-bqas (push) Successful in 29s
- Mail-Seite, API-Route, Kommunikation-Kategorie entfernt
- Screen-Flow: Mail-Node und Kommunikation-Legende entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:05:48 +01:00
Benjamin Admin
c8cc8774db refactor: Video Chat, Voice Service, Alerts Seiten aus Core Admin entfernt
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
- Kommunikation-Seiten nach Lehrer migriert
- API-Routes, Health-Check, Navigation bereinigt
- Screen-Flow, SBOM, Tests aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:36:22 +01:00
Benjamin Admin
1527f4ffe7 refactor: Camunda löschen, Jitsi/Matrix/Voice nach Lehrer verschieben
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
Camunda war nie aktiv (nur Frontend-Stub ohne Backend) — komplett entfernt.
Jitsi (5 Services), Synapse (2 Services) und Voice Service werden
ausschließlich vom Lehrer-Stack genutzt und gehören nicht in Core.
Nginx-Container-Namen auf bp-lehrer-jitsi-* aktualisiert (shared Network).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:01:30 +01:00
Benjamin Admin
db1b3c40ed fix: Compliance Dashboard + Katalogverwaltung Kacheln vom Portal entfernt
Beide verlinkten auf /dashboard und waren redundant zum SDK-Einstiegspunkt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:45:11 +01:00
Benjamin Admin
85df14c552 feat: HTTPS-Proxy fuer Compliance MkDocs auf Port 8011
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:23:57 +01:00
Benjamin Admin
72e0f18d08 feat(sbom): OCR- und HTR-Pakete für klausur-service ergänzen
Neue Python-Pakete dokumentiert:
- pyspellchecker 0.8.1+ (MIT) – OCR-Regelkorrektur Step 6
- pytesseract 0.3.10+ (Apache-2.0) – Tesseract OCR Wrapper
- opencv-python-headless 4.8+ (Apache-2.0) – Bildverarbeitung/Inpainting
- rapidocr-onnxruntime (Apache-2.0) – Schnelles OCR ARM64
- onnxruntime (MIT) – ONNX-Inferenz für RapidOCR
- eng-to-ipa (MIT) – IPA-Lautschrift-Lookup
- sentence-transformers 2.2+ (Apache-2.0) – Lokale Embeddings
- torch 2.0+ (BSD-3-Clause) – ML-Framework CPU/MPS
- transformers 4.x (Apache-2.0) – TrOCR/HTR-Modelle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 18:42:53 +01:00
Benjamin Admin
1c8f528c7a feat(nginx): add /rag-originals/ location for QA PDF serving
Serves original regulation PDFs from ~/rag-originals/ on port 3002
for the RAG QA Split-View Chunk-Browser. Adds volume mount to nginx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:46:13 +01:00
Benjamin Admin
403cb5b85d fix: increase RAG service proxy timeout to 600s
- Increase proxy_read_timeout from 300s to 600s for large PDF uploads
- Add proxy_send_timeout 600s (was defaulting to 60s)
- Fixes 504 Gateway Timeout when uploading 7.5MB+ IFRS PDFs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:07:03 +01:00
79 changed files with 4243 additions and 6826 deletions

65
.env.coolify.example Normal file
View File

@@ -0,0 +1,65 @@
# =========================================================
# BreakPilot Core — Coolify Environment Variables
# =========================================================
# Copy these into Coolify's environment variable UI
# for the breakpilot-core Docker Compose resource.
# =========================================================
# --- External PostgreSQL (Coolify-managed) ---
POSTGRES_HOST=<coolify-postgres-hostname>
POSTGRES_PORT=5432
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
POSTGRES_DB=breakpilot_db
# --- Security ---
JWT_SECRET=CHANGE_ME_RANDOM_64_CHARS
JWT_REFRESH_SECRET=CHANGE_ME_ANOTHER_RANDOM_64_CHARS
INTERNAL_API_KEY=CHANGE_ME_INTERNAL_KEY
# --- External S3 Storage ---
S3_ENDPOINT=<s3-endpoint-host:port>
S3_ACCESS_KEY=CHANGE_ME_S3_ACCESS_KEY
S3_SECRET_KEY=CHANGE_ME_S3_SECRET_KEY
S3_BUCKET=breakpilot-rag
S3_SECURE=true
# --- External Qdrant (Coolify-managed) ---
QDRANT_URL=http://<coolify-qdrant-hostname>:6333
QDRANT_API_KEY=
# --- SMTP (Real mail server) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=noreply@breakpilot.ai
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
SMTP_FROM_NAME=BreakPilot
SMTP_FROM_ADDR=noreply@breakpilot.ai
# --- Session ---
SESSION_TTL_HOURS=24
# --- Frontend URLs (build args) ---
NEXT_PUBLIC_CORE_API_URL=https://api-core.breakpilot.ai
FRONTEND_URL=https://www.breakpilot.ai
# --- Stripe (Billing) ---
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY=
BILLING_SUCCESS_URL=https://www.breakpilot.ai/billing/success
BILLING_CANCEL_URL=https://www.breakpilot.ai/billing/cancel
TRIAL_PERIOD_DAYS=14
# --- Embedding Service ---
EMBEDDING_BACKEND=local
LOCAL_EMBEDDING_MODEL=BAAI/bge-m3
LOCAL_RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
PDF_EXTRACTION_BACKEND=pymupdf
OPENAI_API_KEY=
COHERE_API_KEY=
LOG_LEVEL=INFO
# --- Ollama (optional, for RAG embeddings) ---
OLLAMA_URL=
OLLAMA_EMBED_MODEL=bge-m3

View File

@@ -46,11 +46,6 @@ ERPNEXT_DB_ROOT_PASSWORD=erpnext_root
ERPNEXT_DB_PASSWORD=erpnext_secret
ERPNEXT_ADMIN_PASSWORD=admin
# Woodpecker CI
WOODPECKER_HOST=http://macmini:8090
WOODPECKER_ADMIN=pilotadmin
WOODPECKER_AGENT_SECRET=woodpecker-secret
# Gitea Runner
GITEA_RUNNER_TOKEN=

View File

@@ -138,3 +138,22 @@ jobs:
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-asyncio
python -m pytest tests/bqas/ -v --tb=short || true
# ========================================
# Deploy via Coolify (nur main, kein PR)
# ========================================
deploy-coolify:
name: Deploy
runs-on: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-go-consent
container:
image: alpine:latest
steps:
- name: Trigger Coolify deploy
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

View File

@@ -0,0 +1,29 @@
name: Deploy to Coolify
on:
push:
branches:
- coolify
jobs:
deploy:
runs-on: docker
container: alpine:latest
steps:
- name: Deploy via Coolify API
run: |
apk add --no-cache curl
echo "Deploying breakpilot-core to Coolify..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
echo "HTTP Status: $HTTP_STATUS"
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
echo "Deployment failed with status $HTTP_STATUS"
exit 1
fi
echo "Deployment triggered successfully!"

View File

@@ -1,422 +0,0 @@
# Woodpecker CI Main Pipeline
# BreakPilot Core - CI/CD Pipeline
#
# Plattform: ARM64 (Apple Silicon Mac Mini)
#
# Services:
# Go: consent-service
# Python: backend-core, voice-service (+ BQAS), embedding-service, night-scheduler
# Node.js: admin-core
#
# Strategie:
# - Lint bei PRs
# - Tests laufen bei JEDEM Push/PR
# - Test-Ergebnisse werden an Dashboard gesendet
# - Builds/Scans laufen nur bei Tags oder manuell
# - Deployment nur manuell (Sicherheit)
when:
- event: [push, pull_request, manual, tag]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
variables:
- &golang_image golang:1.23-alpine
- &python_image python:3.12-slim
- &nodejs_image node:20-alpine
- &docker_image docker:27-cli
steps:
# ========================================
# STAGE 1: Lint (nur bei PRs)
# ========================================
go-lint:
image: golangci/golangci-lint:v1.55-alpine
commands:
- cd consent-service && golangci-lint run --timeout 5m ./...
when:
event: pull_request
python-lint:
image: *python_image
commands:
- pip install --quiet ruff
- |
for svc in backend-core voice-service night-scheduler embedding-service; do
if [ -d "$svc" ]; then
echo "=== Linting $svc ==="
ruff check "$svc/" --output-format=github || true
fi
done
when:
event: pull_request
nodejs-lint:
image: *nodejs_image
commands:
- |
if [ -d "admin-core" ]; then
cd admin-core
npm ci --silent 2>/dev/null || npm install --silent
npx next lint || true
fi
when:
event: pull_request
# ========================================
# STAGE 2: Unit Tests mit JSON-Ausgabe
# Ergebnisse werden im Workspace gespeichert (.ci-results/)
# ========================================
test-go-consent:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "consent-service" ]; then
echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json
echo "WARNUNG: consent-service Verzeichnis nicht gefunden"
exit 0
fi
cd consent-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json
TEST_EXIT=$?
set -e
JSON_FILE="../.ci-results/test-consent.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json
cat ../.ci-results/results-consent.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-python-voice:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service" ]; then
echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json
echo "WARNUNG: voice-service Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --ignore=tests/bqas --json-report --json-report-file=../.ci-results/test-voice.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-voice.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json
cat ../.ci-results/results-voice.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-bqas-golden:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-golden.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json
cat ../.ci-results/results-bqas-golden.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-bqas-rag:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-rag.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json
cat ../.ci-results/results-bqas-rag.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
# ========================================
# STAGE 3: Test-Ergebnisse an Dashboard senden
# ========================================
report-test-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Test-Ergebnisse an Dashboard ==="
echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}"
ls -la .ci-results/ || echo "Verzeichnis nicht gefunden"
PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}"
for f in .ci-results/results-*.json; do
[ -f "$f" ] || continue
echo "Sending: $f"
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"repo\": \"breakpilot-core\",
\"status\": \"${PIPELINE_STATUS}\",
\"test_results\": $(cat "$f")
}" || echo "WARNUNG: Konnte $f nicht senden"
done
echo "=== Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- test-go-consent
- test-python-voice
- test-bqas-golden
- test-bqas-rag
# ========================================
# STAGE 4: Build & Security (nur Tags/manuell)
# ========================================
build-consent-service:
image: *docker_image
commands:
- |
if [ -d ./consent-service ]; then
docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service
docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest
echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}"
else
echo "consent-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-backend-core:
image: *docker_image
commands:
- |
if [ -d ./backend-core ]; then
docker build -t breakpilot/backend-core:${CI_COMMIT_SHA:0:8} ./backend-core
docker tag breakpilot/backend-core:${CI_COMMIT_SHA:0:8} breakpilot/backend-core:latest
echo "Built breakpilot/backend-core:${CI_COMMIT_SHA:0:8}"
else
echo "backend-core Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-admin-core:
image: *docker_image
commands:
- |
if [ -d ./admin-core ]; then
docker build -t breakpilot/admin-core:${CI_COMMIT_SHA:0:8} ./admin-core
docker tag breakpilot/admin-core:${CI_COMMIT_SHA:0:8} breakpilot/admin-core:latest
echo "Built breakpilot/admin-core:${CI_COMMIT_SHA:0:8}"
else
echo "admin-core Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-voice-service:
image: *docker_image
commands:
- |
if [ -d ./voice-service ]; then
docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service
docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest
echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}"
else
echo "voice-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-embedding-service:
image: *docker_image
commands:
- |
if [ -d ./embedding-service ]; then
docker build -t breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} ./embedding-service
docker tag breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} breakpilot/embedding-service:latest
echo "Built breakpilot/embedding-service:${CI_COMMIT_SHA:0:8}"
else
echo "embedding-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-night-scheduler:
image: *docker_image
commands:
- |
if [ -d ./night-scheduler ]; then
docker build -t breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} ./night-scheduler
docker tag breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} breakpilot/night-scheduler:latest
echo "Built breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8}"
else
echo "night-scheduler Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
generate-sbom:
image: *golang_image
commands:
- |
echo "Installing syft for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
for svc in consent-service backend-core voice-service embedding-service night-scheduler; do
if [ -d "./$svc" ]; then
syft dir:./$svc -o cyclonedx-json > sbom-$svc.json
echo "SBOM generated for $svc"
fi
done
when:
- event: tag
- event: manual
vulnerability-scan:
image: *golang_image
commands:
- |
echo "Installing grype for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
for f in sbom-*.json; do
[ -f "$f" ] || continue
echo "=== Scanning $f ==="
grype sbom:"$f" -o table --fail-on critical || true
done
when:
- event: tag
- event: manual
depends_on:
- generate-sbom
# ========================================
# STAGE 5: Deploy (nur manuell)
# ========================================
deploy-production:
image: *docker_image
commands:
- echo "Deploying breakpilot-core to production..."
- docker compose -f docker-compose.yml pull || true
- docker compose -f docker-compose.yml up -d --remove-orphans || true
when:
event: manual
depends_on:
- build-consent-service
- build-backend-core
- build-admin-core
- build-voice-service
- build-embedding-service
- build-night-scheduler

View File

@@ -18,6 +18,9 @@ ARG NEXT_PUBLIC_API_URL
# Set environment variables for build
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Ensure public directory exists
RUN mkdir -p public
# Build the application
RUN npm run build
@@ -30,8 +33,8 @@ WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup -S -g 1001 nodejs
RUN adduser -S -u 1001 -G nodejs nextjs
# Copy built assets
COPY --from=builder /app/public ./public

View File

@@ -1,912 +0,0 @@
'use client'
/**
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
*
* Google Alerts & Feed-Ueberwachung Dashboard
* Provides inbox management, topic configuration, rule builder, and relevance profiles
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface AlertItem {
id: string
title: string
url: string
snippet: string
topic_name: string
relevance_score: number | null
relevance_decision: string | null
status: string
fetched_at: string
published_at: string | null
matched_rule: string | null
tags: string[]
}
interface Topic {
id: string
name: string
feed_url: string
feed_type: string
is_active: boolean
fetch_interval_minutes: number
last_fetched_at: string | null
alert_count: number
}
interface Rule {
id: string
name: string
topic_id: string | null
conditions: Array<{
field: string
operator: string
value: string | number
}>
action_type: string
action_config: Record<string, unknown>
priority: number
is_active: boolean
}
interface Profile {
priorities: string[]
exclusions: string[]
positive_examples: Array<{ title: string; url: string }>
negative_examples: Array<{ title: string; url: string }>
policies: {
keep_threshold: number
drop_threshold: number
}
}
interface Stats {
total_alerts: number
new_alerts: number
kept_alerts: number
review_alerts: number
dropped_alerts: number
total_topics: number
active_topics: number
total_rules: number
}
// Tab type
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
export default function AlertsPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [stats, setStats] = useState<Stats | null>(null)
const [alerts, setAlerts] = useState<AlertItem[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inboxFilter, setInboxFilter] = useState<string>('all')
const API_BASE = '/api/alerts'
const fetchData = useCallback(async () => {
try {
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
fetch(`${API_BASE}/stats`),
fetch(`${API_BASE}/inbox?limit=50`),
fetch(`${API_BASE}/topics`),
fetch(`${API_BASE}/rules`),
fetch(`${API_BASE}/profile`),
])
if (statsRes.ok) setStats(await statsRes.json())
if (alertsRes.ok) {
const data = await alertsRes.json()
setAlerts(data.items || [])
}
if (topicsRes.ok) {
const data = await topicsRes.json()
setTopics(data.topics || data.items || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.rules || data.items || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
// Set demo data
setStats({
total_alerts: 147,
new_alerts: 23,
kept_alerts: 89,
review_alerts: 12,
dropped_alerts: 23,
total_topics: 5,
active_topics: 4,
total_rules: 8,
})
setAlerts([
{
id: 'demo_1',
title: 'Neue Studie zur digitalen Bildung an Schulen',
url: 'https://example.com/artikel1',
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
topic_name: 'Digitale Bildung',
relevance_score: 0.85,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date().toISOString(),
published_at: null,
matched_rule: null,
tags: ['bildung', 'digital'],
},
{
id: 'demo_2',
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
url: 'https://example.com/artikel2',
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
topic_name: 'Inklusion',
relevance_score: 0.72,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date(Date.now() - 3600000).toISOString(),
published_at: null,
matched_rule: null,
tags: ['inklusion'],
},
])
setTopics([
{
id: 'topic_1',
name: 'Digitale Bildung',
feed_url: 'https://google.com/alerts/feeds/123',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date().toISOString(),
alert_count: 47,
},
{
id: 'topic_2',
name: 'Inklusion',
feed_url: 'https://google.com/alerts/feeds/456',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
alert_count: 32,
},
])
setRules([
{
id: 'rule_1',
name: 'Stellenanzeigen ausschliessen',
topic_id: null,
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
action_type: 'drop',
action_config: {},
priority: 10,
is_active: true,
},
])
setProfile({
priorities: ['Inklusion', 'digitale Bildung'],
exclusions: ['Stellenanzeigen', 'Werbung'],
positive_examples: [],
negative_examples: [],
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const formatTimeAgo = (dateStr: string | null) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
const getScoreBadge = (score: number | null) => {
if (score === null) return null
const pct = Math.round(score * 100)
let cls = 'bg-slate-100 text-slate-600'
if (pct >= 70) cls = 'bg-green-100 text-green-800'
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
else cls = 'bg-red-100 text-red-800'
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
}
const getDecisionBadge = (decision: string | null) => {
if (!decision) return null
const styles: Record<string, string> = {
KEEP: 'bg-green-100 text-green-800',
REVIEW: 'bg-amber-100 text-amber-800',
DROP: 'bg-red-100 text-red-800',
}
return (
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
{decision}
</span>
)
}
const filteredAlerts = alerts.filter((alert) => {
if (inboxFilter === 'all') return true
if (inboxFilter === 'new') return alert.status === 'new'
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
return true
})
const tabs: { id: TabId; label: string; badge?: number }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
{ id: 'topics', label: 'Topics' },
{ id: 'rules', label: 'Regeln' },
{ id: 'profile', label: 'Profil' },
{ id: 'audit', label: 'Audit' },
{ id: 'documentation', label: 'Dokumentation' },
]
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
</div>
)
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Alerts Monitoring"
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
audience={['Marketing', 'Admins', 'DSB']}
architecture={{
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
databases: ['PostgreSQL', 'Valkey Cache'],
}}
relatedPages={[
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
<div className="text-sm text-slate-500">Alerts gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
<div className="text-sm text-slate-500">Neue Alerts</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
<div className="text-sm text-slate-500">Relevant</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
<div className="text-sm text-slate-500">Zur Pruefung</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-slate-200 px-4">
<nav className="flex gap-4 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
activeTab === tab.id
? 'border-green-600 text-green-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
{tab.badge}
</span>
)}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Dashboard Tab */}
{activeTab === 'dashboard' && (
<div className="space-y-6">
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
<div className="space-y-3">
{topics.slice(0, 5).map((topic) => (
<div key={topic.id} className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200">
<div>
<div className="font-medium text-slate-900">{topic.name}</div>
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
))}
{topics.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
)}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
<div className="space-y-3">
{alerts.slice(0, 5).map((alert) => (
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{alert.topic_name}</span>
{getScoreBadge(alert.relevance_score)}
</div>
</div>
))}
{alerts.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
)}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)}
{/* Inbox Tab */}
{activeTab === 'inbox' && (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'new', 'keep', 'review'].map((filter) => (
<button
key={filter}
onClick={() => setInboxFilter(filter)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
inboxFilter === filter
? 'bg-green-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' && 'Alle'}
{filter === 'new' && 'Neu'}
{filter === 'keep' && 'Relevant'}
{filter === 'review' && 'Pruefung'}
</button>
))}
</div>
{/* Alerts Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-slate-50">
<td className="p-4">
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-green-600">
{alert.title}
</a>
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
</td>
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
))}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
Keine Alerts gefunden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Topics Tab */}
{activeTab === 'topics' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
+ Topic hinzufuegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex justify-between items-start mb-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
<div className="text-sm">
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
<span className="text-slate-500"> Alerts</span>
</div>
<div className="text-xs text-slate-500">
{formatTimeAgo(topic.last_fetched_at)}
</div>
</div>
</div>
))}
{topics.length === 0 && (
<div className="col-span-full text-center py-8 text-slate-500">
Keine Topics konfiguriert
</div>
)}
</div>
</div>
)}
{/* Rules Tab */}
{activeTab === 'rules' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
/>
</div>
</div>
))}
{rules.length === 0 && (
<div className="p-8 text-center text-slate-500">
Keine Regeln konfiguriert
</div>
)}
</div>
</div>
)}
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="max-w-2xl space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Prioritaeten (wichtige Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
rows={4}
defaultValue={profile?.priorities?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ausschluesse (unerwuenschte Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
rows={4}
defaultValue={profile?.exclusions?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert KEEP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
defaultValue={profile?.policies?.keep_threshold || 0.7}
>
<option value={0.8}>80% (sehr streng)</option>
<option value={0.7}>70% (empfohlen)</option>
<option value={0.6}>60% (weniger streng)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert DROP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
defaultValue={profile?.policies?.drop_threshold || 0.3}
>
<option value={0.4}>40% (strenger)</option>
<option value={0.3}>30% (empfohlen)</option>
<option value={0.2}>20% (lockerer)</option>
</select>
</div>
</div>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
Profil speichern
</button>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="space-y-6">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Database Info */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
Datenbank
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Tabellen</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Indizes</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Backups</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
</div>
</div>
</div>
{/* API Security */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
API Sicherheit
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Authentifizierung</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Rate Limiting</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Input Validation</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
</div>
</div>
</div>
{/* Logging */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Logging & Monitoring
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Structured Logging</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Metriken</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Health Checks</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
</div>
</div>
</div>
</div>
{/* Privacy Notes */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
<ul className="space-y-1">
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
DSGVO-konforme Datenverarbeitung
</li>
</ul>
</div>
</div>
)}
{/* Documentation Tab */}
{activeTab === 'documentation' && (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
{/* Header */}
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
</div>
{/* Audit Box */}
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
<p className="text-sm text-blue-800">
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
</p>
</div>
{/* Ziel des Systems */}
<h2>Ziel des Alert-Systems</h2>
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
<ul>
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
</ul>
{/* Architecture Diagram */}
<h2>Systemarchitektur</h2>
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-xs">{`
┌─────────────────────────────────────────────────────────────────────┐
│ BreakPilot Alerts Frontend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
└───────────────────────────────┬─────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Ingestion Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ └───────────────────┼───────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Deduplication (URL-Hash + SimHash) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Rule Engine │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LLM Relevance Scorer │ │
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Action Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`}</pre>
</div>
{/* API Endpoints */}
<h2>API Endpoints</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
</tbody>
</table>
</div>
{/* Rule Engine */}
<h2>Rule Engine - Operatoren</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains &quot;Inklusion&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains &quot;Werbung&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals &quot;new&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex &quot;\d&#123;4&#125;&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
</tbody>
</table>
</div>
{/* Scoring */}
<h2>LLM Relevanz-Scoring</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
</tbody>
</table>
</div>
{/* Contact */}
<h2>Kontakt & Support</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
</tbody>
</table>
</div>
{/* Footer */}
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,946 +0,0 @@
'use client'
/**
* Unified Inbox Mail Admin Page
* Migrated from website/admin/mail to admin-v2/communication/mail
*
* Admin interface for managing email accounts, viewing system status,
* and configuring AI analysis settings.
*/
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
// API Base URL for backend operations (accounts, sync, etc.)
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://macmini:8086'
// Types
interface EmailAccount {
id: string
email: string
displayName: string
imapHost: string
imapPort: number
smtpHost: string
smtpPort: number
status: 'active' | 'inactive' | 'error' | 'syncing'
lastSync: string | null
emailCount: number
unreadCount: number
createdAt: string
}
interface MailStats {
totalAccounts: number
activeAccounts: number
totalEmails: number
unreadEmails: number
totalTasks: number
pendingTasks: number
overdueTasks: number
aiAnalyzedCount: number
lastSyncTime: string | null
}
interface SyncStatus {
running: boolean
accountsInProgress: string[]
lastCompleted: string | null
errors: string[]
}
// Tab definitions
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
const tabs: { id: TabId; name: string }[] = [
{ id: 'overview', name: 'Uebersicht' },
{ id: 'accounts', name: 'Konten' },
{ id: 'ai-settings', name: 'KI-Einstellungen' },
{ id: 'templates', name: 'Vorlagen' },
{ id: 'logs', name: 'Audit-Log' },
]
// Main Component
export default function MailAdminPage() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [stats, setStats] = useState<MailStats | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
setLoading(true)
// Fetch stats via our proxy API (avoids CORS/mixed-content issues)
const response = await fetch('/api/admin/mail')
if (response.ok) {
const data = await response.json()
setStats(data.stats)
setAccounts(data.accounts)
setSyncStatus(data.syncStatus)
setError(null)
} else {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.details || `API returned ${response.status}`)
}
} catch (err) {
console.error('Failed to fetch mail data:', err)
setError('Verbindung zum Mail-Service (Mailpit) fehlgeschlagen. Laeuft Mailpit auf Port 8025?')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
// Refresh every 10 seconds if syncing
const interval = setInterval(() => {
if (syncStatus?.running) {
fetchData()
}
}, 10000)
return () => clearInterval(interval)
}, [fetchData, syncStatus?.running])
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Unified Inbox"
purpose="Verwalten Sie E-Mail-Konten, synchronisieren Sie Postfaecher und konfigurieren Sie die KI-gestuetzte E-Mail-Analyse fuer automatische Kategorisierung und Aufgabenerkennung."
audience={['Admins', 'Schulleitung']}
architecture={{
services: ['Mailpit (Dev Mail Catcher)', 'IMAP/SMTP Server (Prod)'],
databases: ['PostgreSQL', 'Vault (Credentials)'],
}}
relatedPages={[
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Quick Link to Wizard */}
<div className="mb-6">
<Link
href="/communication/mail/wizard"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Mail Wizard starten
</Link>
</div>
{/* Error Banner */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-700">{error}</span>
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
Erneut versuchen
</button>
</div>
)}
{/* Tab Navigation */}
<div className="border-b border-slate-200 mb-6">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}
`}
>
{tab.name}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<OverviewTab
stats={stats}
syncStatus={syncStatus}
loading={loading}
onRefresh={fetchData}
/>
)}
{activeTab === 'accounts' && (
<AccountsTab
accounts={accounts}
loading={loading}
onRefresh={fetchData}
/>
)}
{activeTab === 'ai-settings' && (
<AISettingsTab />
)}
{activeTab === 'templates' && (
<TemplatesTab />
)}
{activeTab === 'logs' && (
<AuditLogTab />
)}
</div>
)
}
// ============================================================================
// Overview Tab
// ============================================================================
function OverviewTab({
stats,
syncStatus,
loading,
onRefresh
}: {
stats: MailStats | null
syncStatus: SyncStatus | null
loading: boolean
onRefresh: () => void
}) {
const triggerSync = async () => {
try {
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
method: 'POST',
})
onRefresh()
} catch (err) {
console.error('Failed to trigger sync:', err)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">System-Uebersicht</h2>
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={triggerSync}
disabled={syncStatus?.running}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Stats Grid */}
{!loading && stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="E-Mail-Konten"
value={stats.totalAccounts}
subtitle={`${stats.activeAccounts} aktiv`}
color="blue"
/>
<StatCard
title="E-Mails gesamt"
value={stats.totalEmails}
subtitle={`${stats.unreadEmails} ungelesen`}
color="green"
/>
<StatCard
title="Aufgaben"
value={stats.totalTasks}
subtitle={`${stats.pendingTasks} offen`}
color="yellow"
/>
<StatCard
title="Ueberfaellig"
value={stats.overdueTasks}
color={stats.overdueTasks > 0 ? 'red' : 'green'}
/>
</div>
{/* Sync Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
<div className="flex items-center gap-4">
{syncStatus?.running ? (
<>
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-slate-600">
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
</span>
</>
) : (
<>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-slate-600">Bereit</span>
</>
)}
{stats.lastSyncTime && (
<span className="text-sm text-slate-500 ml-auto">
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
</span>
)}
</div>
{syncStatus?.errors && syncStatus.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{syncStatus.errors.slice(0, 3).map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
{/* AI Stats */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
<p className="text-2xl font-bold text-slate-900">
{stats.totalEmails > 0
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
: '0%'}
</p>
</div>
</div>
</div>
</>
)}
</div>
)
}
function StatCard({
title,
value,
subtitle,
color = 'blue'
}: {
title: string
value: number
subtitle?: string
color?: 'blue' | 'green' | 'yellow' | 'red'
}) {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
</div>
)
}
// ============================================================================
// Accounts Tab
// ============================================================================
function AccountsTab({
accounts,
loading,
onRefresh
}: {
accounts: EmailAccount[]
loading: boolean
onRefresh: () => void
}) {
const [showAddModal, setShowAddModal] = useState(false)
const testConnection = async (accountId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
method: 'POST',
})
if (res.ok) {
alert('Verbindung erfolgreich!')
} else {
alert('Verbindungsfehler')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
const statusColors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
syncing: 'bg-yellow-100 text-yellow-800',
}
const statusLabels = {
active: 'Aktiv',
inactive: 'Inaktiv',
error: 'Fehler',
syncing: 'Synchronisiert...',
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Konto hinzufuegen
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Accounts Grid */}
{!loading && (
<div className="grid gap-4">
{accounts.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
<p className="text-slate-500 mb-4">Fuegen Sie Ihr erstes E-Mail-Konto hinzu.</p>
</div>
) : (
accounts.map((account) => (
<div
key={account.id}
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{account.displayName || account.email}
</h3>
<p className="text-sm text-slate-500">{account.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
{statusLabels[account.status]}
</span>
<button
onClick={() => testConnection(account.id)}
className="p-2 text-slate-400 hover:text-slate-600"
title="Verbindung testen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
<p className="text-sm text-slate-700">
{account.lastSync
? new Date(account.lastSync).toLocaleString('de-DE')
: 'Nie'}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
{/* Add Account Modal */}
{showAddModal && (
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
)}
</div>
)
}
function AddAccountModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
email: '',
displayName: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
username: '',
password: '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
display_name: formData.displayName,
imap_host: formData.imapHost,
imap_port: formData.imapPort,
smtp_host: formData.smtpHost,
smtp_port: formData.smtpPort,
username: formData.username,
password: formData.password,
}),
})
if (res.ok) {
onSuccess()
} else {
const data = await res.json()
setError(data.detail || 'Fehler beim Hinzufuegen des Kontos')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufuegen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="schulleitung@grundschule-xy.de"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Schulleitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
<input
type="text"
required
value={formData.imapHost}
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="imap.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
<input
type="number"
required
value={formData.imapPort}
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
<input
type="text"
required
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
<input
type="number"
required
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Das Passwort wird verschluesselt in Vault gespeichert.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Speichern...' : 'Konto hinzufuegen'}
</button>
</div>
</form>
</div>
</div>
)
}
// ============================================================================
// AI Settings Tab
// ============================================================================
function AISettingsTab() {
const [settings, setSettings] = useState({
autoAnalyze: true,
autoCreateTasks: true,
analysisModel: 'breakpilot-teacher-8b',
confidenceThreshold: 0.7,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* Auto-Analyze */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoAnalyze ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Auto-Create Tasks */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoCreateTasks ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
<select
value={settings.analysisModel}
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
</select>
</div>
{/* Confidence Threshold */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
</label>
<input
type="range"
min="0.5"
max="0.95"
step="0.05"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full md:w-64"
/>
<p className="text-xs text-slate-500 mt-1">
Mindest-Konfidenz fuer automatische Aufgabenerstellung
</p>
</div>
</div>
{/* Sender Classification */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehoerde', priority: 'Hoch' },
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
{ domain: '@schultraeger.de', type: 'Schultraeger', priority: 'Mittel' },
].map((sender) => (
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
<p className="text-xs text-slate-500">{sender.type}</p>
<span className={`text-xs px-2 py-0.5 rounded ${
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{sender.priority}
</span>
</div>
))}
</div>
</div>
</div>
)
}
// ============================================================================
// Templates Tab
// ============================================================================
function TemplatesTab() {
const [templates] = useState([
{ id: '1', name: 'Eingangsbestaetigung', category: 'Standard', usageCount: 45 },
{ id: '2', name: 'Terminbestaetigung', category: 'Termine', usageCount: 23 },
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorlage erstellen
</button>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">Bearbeiten</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ============================================================================
// Audit Log Tab
// ============================================================================
function AuditLogTab() {
const [logs] = useState([
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefuegt' },
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
])
const actionLabels: Record<string, string> = {
account_created: 'Konto erstellt',
email_analyzed: 'E-Mail analysiert',
task_created: 'Aufgabe erstellt',
sync_completed: 'Sync abgeschlossen',
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm text-slate-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
{actionLabels[log.action] || log.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,594 +0,0 @@
'use client'
/**
* Voice Service Admin Page (migrated from website/admin/voice)
*
* Displays:
* - Voice-First Architecture Overview
* - Developer Guide Content
* - Live Voice Demo (embedded from studio-v2)
* - Task State Machine Documentation
* - DSGVO Compliance Information
*/
import { useState } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
// Task State Machine data
const TASK_STATES = [
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
]
// Intent Types (22 types organized by group)
const INTENT_GROUPS = [
{
group: 'Notizen',
color: 'bg-blue-50 border-blue-200',
intents: [
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
]
},
{
group: 'Content-Generierung',
color: 'bg-green-50 border-green-200',
intents: [
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
]
},
{
group: 'Kommunikation',
color: 'bg-yellow-50 border-yellow-200',
intents: [
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
]
},
{
group: 'Canvas-Editor',
color: 'bg-purple-50 border-purple-200',
intents: [
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
]
},
{
group: 'RAG & Korrektur',
color: 'bg-pink-50 border-pink-200',
intents: [
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
]
},
{
group: 'Follow-up (TaskOrchestrator)',
color: 'bg-teal-50 border-teal-200',
intents: [
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
]
},
]
// DSGVO Data Categories
const DSGVO_CATEGORIES = [
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
]
// API Endpoints
const API_ENDPOINTS = [
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
{ method: 'GET', path: '/health', description: 'Health Check' },
]
export default function VoiceMatrixPage() {
const [activeTab, setActiveTab] = useState<TabType>('overview')
const [demoLoaded, setDemoLoaded] = useState(false)
const tabs = [
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
{ id: 'tasks', name: 'Task States', icon: '📋' },
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
{ id: 'api', name: 'API', icon: '🔌' },
]
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Voice Service"
purpose="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator. Konfigurieren und testen Sie den Voice-Service fuer Lehrer-Interaktionen per Sprache."
audience={['Entwickler', 'Admins']}
architecture={{
services: ['voice-service (Python, Port 8091)', 'studio-v2 (Next.js)', 'valkey (Cache)'],
databases: ['PostgreSQL', 'Valkey Cache'],
}}
relatedPages={[
{ name: 'Matrix & Jitsi', href: '/communication/matrix', description: 'Kommunikation Monitoring' },
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider vergleichen' },
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice-Service' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Quick Links */}
<div className="mb-6 flex flex-wrap gap-3">
<a
href="https://macmini:3001/voice-test"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
Voice Test (Studio)
</a>
<a
href="https://macmini:8091/health"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Health Check
</a>
<Link
href="/development/docs"
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Developer Docs
</Link>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-teal-600">8091</div>
<div className="text-sm text-slate-500">Port</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-blue-600">22</div>
<div className="text-sm text-slate-500">Task Types</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-purple-600">9</div>
<div className="text-sm text-slate-500">Task States</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-green-600">24kHz</div>
<div className="text-sm text-slate-500">Audio Rate</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-orange-600">80ms</div>
<div className="text-sm text-slate-500">Frame Size</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-red-600">0</div>
<div className="text-sm text-slate-500">Audio Persist</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-slate-200 px-4">
<div className="flex gap-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
activeTab === tab.id
? 'border-teal-600 text-teal-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</div>
</div>
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
{/* Architecture Diagram */}
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
<pre className="text-slate-700">{`
┌──────────────────────────────────────────────────────────────────┐
│ LEHRERGERAET (PWA / App) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
│ WebSocket (wss://)
┌──────────────────────────────────────────────────────────────────┐
│ VOICE SERVICE (Port 8091) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
`}</pre>
</div>
{/* Technology Stack */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
<p className="text-sm text-green-700">TaskOrchestrator</p>
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
<p className="text-xs text-purple-500">Lizenz: MIT</p>
</div>
</div>
{/* Key Files */}
<div>
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Demo Tab */}
{activeTab === 'demo' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
<a
href="https://macmini:3001/voice-test"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
>
In neuem Tab oeffnen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
</div>
{/* Embedded Demo */}
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
{!demoLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={() => setDemoLoaded(true)}
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Voice Demo laden
</button>
</div>
)}
{demoLoaded && (
<iframe
src="https://macmini:3001/voice-test?embed=true"
className="w-full h-full border-0"
title="Voice Demo"
allow="microphone"
/>
)}
</div>
</div>
)}
{/* Task States Tab */}
{activeTab === 'tasks' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
{/* State Diagram */}
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
<pre className="text-slate-700">{`
DRAFT → QUEUED → RUNNING → READY
┌───────────┴───────────┐
│ │
APPROVED REJECTED
│ │
COMPLETED DRAFT (revision)
Any State → EXPIRED (TTL)
Any State → PAUSED (User Interrupt)
`}</pre>
</div>
{/* States Table */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{TASK_STATES.map((state) => (
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
<div className="font-semibold text-lg">{state.state}</div>
<p className="text-sm mt-1">{state.description}</p>
{state.next.length > 0 && (
<div className="mt-2 text-xs">
<span className="opacity-75">Naechste:</span>{' '}
{state.next.join(', ')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Intents Tab */}
{activeTab === 'intents' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
{INTENT_GROUPS.map((group) => (
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
<div className="space-y-2">
{group.intents.map((intent) => (
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
<div className="flex items-start justify-between">
<div>
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
{intent.type}
</code>
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
</div>
</div>
<div className="mt-2 text-xs text-slate-500 italic">
Beispiel: &quot;{intent.example}&quot;
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* DSGVO Tab */}
{activeTab === 'dsgvo' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
{/* Key Principles */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
</ul>
</div>
{/* Data Categories Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{DSGVO_CATEGORIES.map((cat) => (
<tr key={cat.category}>
<td className="px-4 py-3">
<span className="mr-2">{cat.icon}</span>
<span className="font-medium">{cat.category}</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{cat.risk.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Audit Log Info */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">Erlaubt:</span>
<ul className="list-disc list-inside text-slate-600 mt-1">
<li>ref_id (truncated)</li>
<li>content_type</li>
<li>size_bytes</li>
<li>ttl_hours</li>
</ul>
</div>
<div>
<span className="text-red-600 font-medium">Verboten:</span>
<ul className="list-disc list-inside text-slate-600 mt-1">
<li>user_name</li>
<li>content / transcript</li>
<li>email</li>
<li>student_name</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* API Tab */}
{activeTab === 'api' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
{/* REST Endpoints */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{API_ENDPOINTS.map((ep, idx) => (
<tr key={idx}>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{ep.method}
</span>
</td>
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* WebSocket Protocol */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="bg-white rounded-lg p-3 border border-slate-200">
<div className="font-medium text-slate-700 mb-2">Client Server</div>
<ul className="list-disc list-inside text-slate-600 space-y-1">
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
</ul>
</div>
<div className="bg-white rounded-lg p-3 border border-slate-200">
<div className="font-medium text-slate-700 mb-2">Server Client</div>
<ul className="list-disc list-inside text-slate-600 space-y-1">
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
</ul>
</div>
</div>
</div>
{/* Example curl commands */}
<div className="bg-slate-900 rounded-lg p-4 text-sm">
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
-H "Content-Type: application/json" \\
-d '{
"namespace_id": "ns-12345678abcdef12345678abcdef12",
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
"device_type": "pwa"
}'`}</pre>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,635 +0,0 @@
'use client'
/**
* Video & Chat Admin Page
*
* Matrix & Jitsi Monitoring Dashboard
* Provides system statistics, active calls, user metrics, and service health
* Migrated from website/app/admin/communication
*/
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
interface MatrixStats {
total_users: number
active_users: number
total_rooms: number
active_rooms: number
messages_today: number
messages_this_week: number
status: 'online' | 'offline' | 'degraded'
}
interface JitsiStats {
active_meetings: number
total_participants: number
meetings_today: number
average_duration_minutes: number
peak_concurrent_users: number
total_minutes_today: number
status: 'online' | 'offline' | 'degraded'
}
interface TrafficStats {
matrix: {
bandwidth_in_mb: number
bandwidth_out_mb: number
messages_per_minute: number
media_uploads_today: number
media_size_mb: number
}
jitsi: {
bandwidth_in_mb: number
bandwidth_out_mb: number
video_streams_active: number
audio_streams_active: number
estimated_hourly_gb: number
}
total: {
bandwidth_in_mb: number
bandwidth_out_mb: number
estimated_monthly_gb: number
}
}
interface CommunicationStats {
matrix: MatrixStats
jitsi: JitsiStats
traffic?: TrafficStats
last_updated: string
}
interface ActiveMeeting {
room_name: string
display_name: string
participants: number
started_at: string
duration_minutes: number
}
interface RecentRoom {
room_id: string
name: string
member_count: number
last_activity: string
room_type: 'class' | 'parent' | 'staff' | 'general'
}
export default function VideoChatPage() {
const [stats, setStats] = useState<CommunicationStats | null>(null)
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const moduleInfo = getModuleByHref('/communication/video-chat')
// Use local API proxy
const fetchStats = useCallback(async () => {
try {
const response = await fetch('/api/admin/communication/stats')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setStats(data)
setActiveMeetings(data.active_meetings || [])
setRecentRooms(data.recent_rooms || [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
// Set mock data for display purposes when API unavailable
setStats({
matrix: {
total_users: 0,
active_users: 0,
total_rooms: 0,
active_rooms: 0,
messages_today: 0,
messages_this_week: 0,
status: 'offline'
},
jitsi: {
active_meetings: 0,
total_participants: 0,
meetings_today: 0,
average_duration_minutes: 0,
peak_concurrent_users: 0,
total_minutes_today: 0,
status: 'offline'
},
last_updated: new Date().toISOString()
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStats()
}, [fetchStats])
// Auto-refresh every 15 seconds
useEffect(() => {
const interval = setInterval(fetchStats, 15000)
return () => clearInterval(interval)
}, [fetchStats])
const getStatusBadge = (status: string) => {
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
switch (status) {
case 'online':
return `${baseClasses} bg-green-100 text-green-800`
case 'degraded':
return `${baseClasses} bg-yellow-100 text-yellow-800`
case 'offline':
return `${baseClasses} bg-red-100 text-red-800`
default:
return `${baseClasses} bg-slate-100 text-slate-600`
}
}
const getRoomTypeBadge = (type: string) => {
const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium'
switch (type) {
case 'class':
return `${baseClasses} bg-blue-100 text-blue-700`
case 'parent':
return `${baseClasses} bg-purple-100 text-purple-700`
case 'staff':
return `${baseClasses} bg-orange-100 text-orange-700`
default:
return `${baseClasses} bg-slate-100 text-slate-600`
}
}
const formatDuration = (minutes: number) => {
if (minutes < 60) return `${Math.round(minutes)} Min.`
const hours = Math.floor(minutes / 60)
const mins = Math.round(minutes % 60)
return `${hours}h ${mins}m`
}
const formatTimeAgo = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
// Traffic estimation helpers for SysEleven planning
const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => {
const messages = stats?.matrix?.messages_today || 0
const callMinutes = stats?.jitsi?.total_minutes_today || 0
const participants = stats?.jitsi?.total_participants || 0
const messageTrafficMB = messages * 0.002
const videoTrafficMB = callMinutes * participants * 0.011
if (direction === 'in') {
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
}
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
}
const calculateHourlyEstimate = (): number => {
const activeParticipants = stats?.jitsi?.total_participants || 0
return activeParticipants * 0.675
}
const calculateMonthlyEstimate = (): number => {
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
const monthlyMinutes = dailyCallMinutes * 22
return (monthlyMinutes * avgParticipants * 11) / 1024
}
const getResourceRecommendation = (): string => {
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
const monthlyGB = calculateMonthlyEstimate()
if (monthlyGB < 10 || peakUsers < 5) {
return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)'
} else if (monthlyGB < 50 || peakUsers < 20) {
return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)'
} else if (monthlyGB < 200 || peakUsers < 50) {
return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)'
} else {
return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)'
}
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title={moduleInfo?.module.name || 'Video & Chat'}
purpose={moduleInfo?.module.purpose || 'Matrix & Jitsi Monitoring Dashboard'}
audience={moduleInfo?.module.audience || ['Admins', 'DevOps']}
architecture={{
services: ['synapse (Matrix)', 'jitsi-meet', 'prosody', 'jvb'],
databases: ['PostgreSQL', 'synapse-db'],
}}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Actions */}
<div className="flex gap-3 mb-6">
<Link
href="/communication/video-chat/wizard"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
>
Test Wizard starten
</Link>
<button
onClick={fetchStats}
disabled={loading}
className="px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 text-sm"
>
{loading ? 'Lade...' : 'Aktualisieren'}
</button>
</div>
{/* Service Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Matrix Status Card */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
<p className="text-sm text-slate-500">E2EE Messaging</p>
</div>
</div>
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
{stats?.matrix.status || 'offline'}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
<div className="text-xs text-slate-500">Benutzer</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
<div className="text-xs text-slate-500">Aktiv</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
<div className="text-xs text-slate-500">Raeume</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Nachrichten heute</span>
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-slate-500">Diese Woche</span>
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
</div>
</div>
</div>
{/* Jitsi Status Card */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
<p className="text-sm text-slate-500">Videokonferenzen</p>
</div>
</div>
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
{stats?.jitsi.status || 'offline'}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
<div className="text-xs text-slate-500">Live Calls</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
<div className="text-xs text-slate-500">Teilnehmer</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
<div className="text-xs text-slate-500">Calls heute</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Durchschnittliche Dauer</span>
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-slate-500">Peak gleichzeitig</span>
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
</div>
</div>
</div>
</div>
{/* Traffic & Bandwidth Statistics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
</div>
</div>
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
Live
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
<div className="text-2xl font-bold text-slate-900">
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
<div className="text-2xl font-bold text-slate-900">
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Stunde</div>
<div className="text-2xl font-bold text-blue-600">
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Monat</div>
<div className="text-2xl font-bold text-emerald-600">
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Matrix Traffic */}
<div className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Nachrichten/Min</span>
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Media Uploads heute</span>
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Media Groesse</span>
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
</div>
</div>
</div>
{/* Jitsi Traffic */}
<div className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Video Streams aktiv</span>
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Audio Streams aktiv</span>
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Bitrate geschaetzt</span>
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
</div>
</div>
</div>
</div>
{/* SysEleven Recommendation */}
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
<div className="text-sm text-emerald-700">
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
<p className="mt-1 text-xs text-emerald-600">
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
Durchschnittliche Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
Calls heute: {stats?.jitsi?.meetings_today || 0}
</p>
</div>
</div>
</div>
{/* Active Meetings */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
</div>
{activeMeetings.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p>Keine aktiven Meetings</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
<th className="pb-3 pr-4">Meeting</th>
<th className="pb-3 pr-4">Teilnehmer</th>
<th className="pb-3 pr-4">Gestartet</th>
<th className="pb-3">Dauer</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{activeMeetings.map((meeting, idx) => (
<tr key={idx} className="text-sm">
<td className="py-3 pr-4">
<div className="font-medium text-slate-900">{meeting.display_name}</div>
<div className="text-xs text-slate-500">{meeting.room_name}</div>
</td>
<td className="py-3 pr-4">
<span className="inline-flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
{meeting.participants}
</span>
</td>
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Recent Chat Rooms & Usage Stats */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Raeume</h3>
{recentRooms.length === 0 ? (
<div className="text-center py-6 text-slate-500">
<p>Keine aktiven Raeume</p>
</div>
) : (
<div className="space-y-3">
{recentRooms.slice(0, 5).map((room, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Usage Statistics */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-600">Call-Minuten heute</span>
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-600">Aktive Chat-Raeume</span>
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-600">Aktive Nutzer</span>
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all"
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
/>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="mt-6 pt-4 border-t border-slate-100">
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
<div className="flex flex-wrap gap-2">
<a
href="http://localhost:8448/_synapse/admin"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
>
Synapse Admin
</a>
<a
href="http://localhost:8443"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
>
Jitsi Meet
</a>
</div>
</div>
</div>
</div>
{/* Connection Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-blue-900">Service Konfiguration</h4>
<p className="text-sm text-blue-800 mt-1">
<strong>Matrix Homeserver:</strong> http://localhost:8448 (Synapse)<br />
<strong>Jitsi Meet:</strong> http://localhost:8443<br />
<strong>Auto-Refresh:</strong> Alle 15 Sekunden
</p>
{error && (
<p className="text-sm text-red-600 mt-2">
<strong>Fehler:</strong> {error} - Backend nicht erreichbar
</p>
)}
{stats?.last_updated && (
<p className="text-xs text-blue-600 mt-2">
Letzte Aktualisierung: {new Date(stats.last_updated).toLocaleString('de-DE')}
</p>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -27,7 +27,6 @@ export default function DashboardPage() {
{ name: 'Jitsi Meet', status: 'unknown' },
{ name: 'Mailpit', status: 'unknown' },
{ name: 'Gitea', status: 'unknown' },
{ name: 'Woodpecker CI', status: 'unknown' },
{ name: 'Backend Core', status: 'unknown' },
]

View File

@@ -1,318 +0,0 @@
'use client'
import { useState } from 'react'
type Tab = 'colors' | 'typography' | 'components' | 'logos' | 'voice'
const tabs: { id: Tab; label: string }[] = [
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logos', label: 'Logos' },
{ id: 'voice', label: 'Voice & Tone' },
]
const primaryColors = [
{ name: 'Primary 50', hex: '#f0f9ff', class: 'bg-primary-50' },
{ name: 'Primary 100', hex: '#e0f2fe', class: 'bg-primary-100' },
{ name: 'Primary 200', hex: '#bae6fd', class: 'bg-primary-200' },
{ name: 'Primary 300', hex: '#7dd3fc', class: 'bg-primary-300' },
{ name: 'Primary 400', hex: '#38bdf8', class: 'bg-primary-400' },
{ name: 'Primary 500', hex: '#0ea5e9', class: 'bg-primary-500' },
{ name: 'Primary 600', hex: '#0284c7', class: 'bg-primary-600' },
{ name: 'Primary 700', hex: '#0369a1', class: 'bg-primary-700' },
{ name: 'Primary 800', hex: '#075985', class: 'bg-primary-800' },
{ name: 'Primary 900', hex: '#0c4a6e', class: 'bg-primary-900' },
]
const categoryColorSets = [
{
name: 'Kommunikation',
baseHex: '#22c55e',
swatches: [
{ name: '100', hex: '#dcfce7' },
{ name: '300', hex: '#86efac' },
{ name: '500', hex: '#22c55e' },
{ name: '700', hex: '#15803d' },
],
},
{
name: 'Infrastruktur',
baseHex: '#f97316',
swatches: [
{ name: '100', hex: '#ffedd5' },
{ name: '300', hex: '#fdba74' },
{ name: '500', hex: '#f97316' },
{ name: '700', hex: '#c2410c' },
],
},
{
name: 'Entwicklung',
baseHex: '#64748b',
swatches: [
{ name: '100', hex: '#f1f5f9' },
{ name: '300', hex: '#cbd5e1' },
{ name: '500', hex: '#64748b' },
{ name: '700', hex: '#334155' },
],
},
]
export default function BrandbookPage() {
const [activeTab, setActiveTab] = useState<Tab>('colors')
return (
<div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white rounded-xl border border-slate-200 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-primary-600 text-white'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Colors Tab */}
{activeTab === 'colors' && (
<div className="space-y-8">
{/* Primary */}
<div>
<h2 className="text-lg font-semibold text-slate-900 mb-4">Primary: Sky Blue</h2>
<div className="grid grid-cols-5 md:grid-cols-10 gap-2">
{primaryColors.map((color) => (
<div key={color.hex} className="text-center">
<div
className="w-full aspect-square rounded-lg border border-slate-200 mb-1"
style={{ backgroundColor: color.hex }}
/>
<div className="text-xs text-slate-500">{color.name.split(' ')[1]}</div>
<div className="text-xs text-slate-400 font-mono">{color.hex}</div>
</div>
))}
</div>
</div>
{/* Category Colors */}
<div>
<h2 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Farben</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{categoryColorSets.map((set) => (
<div key={set.name} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: set.baseHex }}
/>
<h3 className="font-medium text-slate-900">{set.name}</h3>
<span className="text-xs text-slate-400 font-mono">{set.baseHex}</span>
</div>
<div className="grid grid-cols-4 gap-2">
{set.swatches.map((swatch) => (
<div key={swatch.hex} className="text-center">
<div
className="w-full aspect-square rounded-lg border border-slate-200 mb-1"
style={{ backgroundColor: swatch.hex }}
/>
<div className="text-xs text-slate-400 font-mono">{swatch.name}</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Semantic Colors */}
<div>
<h2 className="text-lg font-semibold text-slate-900 mb-4">Semantische Farben</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ name: 'Success', hex: '#22c55e', bg: '#dcfce7' },
{ name: 'Warning', hex: '#f59e0b', bg: '#fef3c7' },
{ name: 'Error', hex: '#ef4444', bg: '#fee2e2' },
{ name: 'Info', hex: '#3b82f6', bg: '#dbeafe' },
].map((color) => (
<div key={color.name} className="p-4 rounded-xl border border-slate-200" style={{ backgroundColor: color.bg }}>
<div className="w-8 h-8 rounded-lg mb-2" style={{ backgroundColor: color.hex }} />
<div className="font-medium" style={{ color: color.hex }}>{color.name}</div>
<div className="text-xs text-slate-500 font-mono">{color.hex}</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Typography Tab */}
{activeTab === 'typography' && (
<div className="space-y-8">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h2>
<p className="text-slate-500 mb-6">
Inter ist eine Open-Source-Schriftart (OFL), optimiert fuer Bildschirme.
</p>
<div className="space-y-6">
{[
{ name: 'Heading 1', class: 'text-4xl font-bold', size: '36px / 2.25rem' },
{ name: 'Heading 2', class: 'text-2xl font-semibold', size: '24px / 1.5rem' },
{ name: 'Heading 3', class: 'text-xl font-semibold', size: '20px / 1.25rem' },
{ name: 'Body Large', class: 'text-lg', size: '18px / 1.125rem' },
{ name: 'Body', class: 'text-base', size: '16px / 1rem' },
{ name: 'Body Small', class: 'text-sm', size: '14px / 0.875rem' },
{ name: 'Caption', class: 'text-xs', size: '12px / 0.75rem' },
].map((item) => (
<div key={item.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-4">
<div className="w-32 text-sm text-slate-500">{item.name}</div>
<div className={`flex-1 text-slate-900 ${item.class}`}>
BreakPilot Core Admin
</div>
<div className="text-xs text-slate-400 font-mono">{item.size}</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Components Tab */}
{activeTab === 'components' && (
<div className="space-y-8">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h2>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Primary</button>
<button className="px-4 py-2 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50">Secondary</button>
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">Danger</button>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Success</button>
<button className="px-4 py-2 text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg">Ghost</button>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Cards</h2>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-white rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-medium text-slate-900">Default Card</h3>
<p className="text-sm text-slate-500 mt-1">Standard-Karte mit Rand</p>
</div>
<div className="p-4 bg-primary-50 rounded-xl border border-primary-200">
<h3 className="font-medium text-primary-900">Active Card</h3>
<p className="text-sm text-primary-600 mt-1">Hervorgehobene Karte</p>
</div>
<div className="p-4 bg-white rounded-xl border border-slate-200 shadow-md hover:shadow-lg transition-shadow">
<h3 className="font-medium text-slate-900">Hover Card</h3>
<p className="text-sm text-slate-500 mt-1">Karte mit Hover-Effekt</p>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Badges / Status</h2>
<div className="flex flex-wrap gap-3">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">Healthy</span>
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">Error</span>
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">Warning</span>
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">Info</span>
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium">Default</span>
</div>
</div>
</div>
)}
{/* Logos Tab */}
{activeTab === 'logos' && (
<div className="space-y-8">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h2>
<div className="grid grid-cols-2 gap-6">
<div className="p-8 bg-white rounded-xl border border-slate-200 flex items-center justify-center">
<div className="text-center">
<div className="text-3xl font-bold text-primary-600 mb-1">BreakPilot</div>
<div className="text-sm text-slate-500">Core Admin</div>
</div>
</div>
<div className="p-8 bg-slate-900 rounded-xl flex items-center justify-center">
<div className="text-center">
<div className="text-3xl font-bold text-white mb-1">BreakPilot</div>
<div className="text-sm text-slate-400">Core Admin</div>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h2>
<p className="text-sm text-slate-500">
Um das Logo herum muss mindestens der Abstand der Buchstabenhoehe "B" als Freiraum gelassen werden.
</p>
</div>
</div>
)}
{/* Voice & Tone Tab */}
{activeTab === 'voice' && (
<div className="space-y-8">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Sprachstil</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-green-600 mb-2">So schreiben wir</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">+</span>
<span>Klar und direkt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">+</span>
<span>Technisch praezise, aber verstaendlich</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">+</span>
<span>Handlungsorientiert</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">+</span>
<span>Deutsch als Hauptsprache</span>
</li>
</ul>
</div>
<div>
<h3 className="font-medium text-red-600 mb-2">Das vermeiden wir</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">-</span>
<span>Unnoetige Anglizismen</span>
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">-</span>
<span>Marketing-Sprache</span>
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">-</span>
<span>Passive Formulierungen</span>
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">-</span>
<span>Abkuerzungen ohne Erklaerung</span>
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,77 +0,0 @@
'use client'
import { useState } from 'react'
const quickLinks = [
{ name: 'Backend Core API', url: 'https://macmini:8000/docs', description: 'FastAPI Swagger Docs' },
{ name: 'Gitea', url: 'http://macmini:3003', description: 'Git Server' },
{ name: 'Woodpecker CI', url: 'http://macmini:8090', description: 'CI/CD Pipelines' },
{ name: 'MkDocs', url: 'http://macmini:8009', description: 'Projekt-Dokumentation' },
]
export default function DocsPage() {
const [iframeUrl, setIframeUrl] = useState('http://macmini:8009')
const [isLoading, setIsLoading] = useState(true)
return (
<div>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{quickLinks.map((link) => (
<button
key={link.name}
onClick={() => {
setIframeUrl(link.url)
setIsLoading(true)
}}
className={`p-4 rounded-xl border text-left transition-all hover:shadow-md ${
iframeUrl === link.url
? 'bg-primary-50 border-primary-300'
: 'bg-white border-slate-200 hover:border-primary-300'
}`}
>
<h3 className="font-medium text-slate-900">{link.name}</h3>
<p className="text-sm text-slate-500">{link.description}</p>
</button>
))}
</div>
{/* Iframe Viewer */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-slate-50 border-b border-slate-200">
<span className="text-sm text-slate-600 truncate">{iframeUrl}</span>
<a
href={iframeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700"
>
In neuem Tab oeffnen
</a>
</div>
<div className="relative" style={{ height: '70vh' }}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
<iframe
src={iframeUrl}
className="w-full h-full border-0"
onLoad={() => setIsLoading(false)}
title="Documentation Viewer"
/>
</div>
</div>
{/* Info */}
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<h3 className="font-medium text-blue-900 mb-1">Dokumentation bearbeiten</h3>
<p className="text-sm text-blue-700">
Die MkDocs-Dokumentation liegt unter <code className="px-1 py-0.5 bg-blue-100 rounded">/docs-src/</code>.
Aenderungen werden automatisch beim naechsten Build sichtbar.
</p>
</div>
</div>
)
}

View File

@@ -1,178 +0,0 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
MarkerType,
} from 'reactflow'
import 'reactflow/dist/style.css'
type CategoryFilter = 'all' | 'communication' | 'infrastructure' | 'development' | 'meta'
const categoryColors: Record<string, string> = {
communication: '#22c55e',
infrastructure: '#f97316',
development: '#64748b',
meta: '#0ea5e9',
}
const initialNodes: Node[] = [
// Meta
{ id: 'role-select', position: { x: 400, y: 0 }, data: { label: 'Rollenauswahl', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'dashboard', position: { x: 400, y: 100 }, data: { label: 'Dashboard', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
// Communication (Green)
{ id: 'video-chat', position: { x: 50, y: 250 }, data: { label: 'Video & Chat', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'voice-service', position: { x: 50, y: 350 }, data: { label: 'Voice Service', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'mail', position: { x: 50, y: 450 }, data: { label: 'Unified Inbox', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'alerts', position: { x: 50, y: 550 }, data: { label: 'Alerts Monitoring', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
// Infrastructure (Orange)
{ id: 'gpu', position: { x: 300, y: 250 }, data: { label: 'GPU Infrastruktur', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'middleware', position: { x: 300, y: 350 }, data: { label: 'Middleware', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'security', position: { x: 300, y: 450 }, data: { label: 'Security Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'sbom', position: { x: 300, y: 550 }, data: { label: 'SBOM', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'ci-cd', position: { x: 500, y: 250 }, data: { label: 'CI/CD Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'tests', position: { x: 500, y: 350 }, data: { label: 'Test Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
// Development (Slate)
{ id: 'docs', position: { x: 700, y: 250 }, data: { label: 'Developer Docs', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'screen-flow', position: { x: 700, y: 350 }, data: { label: 'Screen Flow', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
{ id: 'brandbook', position: { x: 700, y: 450 }, data: { label: 'Brandbook', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
]
const initialEdges: Edge[] = [
// Meta flow
{ id: 'e-role-dash', source: 'role-select', target: 'dashboard', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#0ea5e9' } },
// Dashboard to categories
{ id: 'e-dash-vc', source: 'dashboard', target: 'video-chat', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
{ id: 'e-dash-gpu', source: 'dashboard', target: 'gpu', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
{ id: 'e-dash-cicd', source: 'dashboard', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
{ id: 'e-dash-docs', source: 'dashboard', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#64748b' } },
// Communication internal
{ id: 'e-vc-voice', source: 'video-chat', target: 'voice-service', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
{ id: 'e-voice-mail', source: 'voice-service', target: 'mail', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
{ id: 'e-mail-alerts', source: 'mail', target: 'alerts', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
// Infrastructure internal
{ id: 'e-gpu-mw', source: 'gpu', target: 'middleware', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
{ id: 'e-mw-sec', source: 'middleware', target: 'security', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
{ id: 'e-sec-sbom', source: 'security', target: 'sbom', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
{ id: 'e-cicd-tests', source: 'ci-cd', target: 'tests', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
// Cross-category
{ id: 'e-sec-cicd', source: 'security', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#94a3b8', strokeDasharray: '5,5' } },
{ id: 'e-tests-docs', source: 'tests', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#94a3b8', strokeDasharray: '5,5' } },
]
export default function ScreenFlowPage() {
const [filter, setFilter] = useState<CategoryFilter>('all')
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const filteredNodes = useMemo(() => {
if (filter === 'all') return nodes
return nodes.filter(n => n.data.category === filter || n.data.category === 'meta')
}, [nodes, filter])
const filteredEdges = useMemo(() => {
const nodeIds = new Set(filteredNodes.map(n => n.id))
return edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
}, [edges, filteredNodes])
const filters: { id: CategoryFilter; label: string; color: string }[] = [
{ id: 'all', label: 'Alle', color: '#0ea5e9' },
{ id: 'communication', label: 'Kommunikation', color: '#22c55e' },
{ id: 'infrastructure', label: 'Infrastruktur', color: '#f97316' },
{ id: 'development', label: 'Entwicklung', color: '#64748b' },
]
return (
<div>
{/* Filter */}
<div className="flex items-center gap-2 mb-4">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f.id
? 'text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'
}`}
style={filter === f.id ? { backgroundColor: f.color } : undefined}
>
{f.label}
</button>
))}
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
<div className="text-2xl font-bold text-slate-900">{filteredNodes.length}</div>
<div className="text-xs text-slate-500">Screens</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
<div className="text-2xl font-bold text-slate-900">{filteredEdges.length}</div>
<div className="text-xs text-slate-500">Verbindungen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
<div className="text-2xl font-bold text-slate-900">3</div>
<div className="text-xs text-slate-500">Kategorien</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
<div className="text-2xl font-bold text-slate-900">13</div>
<div className="text-xs text-slate-500">Module</div>
</div>
</div>
{/* Flow */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm" style={{ height: '65vh' }}>
<ReactFlow
nodes={filteredNodes}
edges={filteredEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
attributionPosition="bottom-left"
>
<Controls />
<Background />
<MiniMap
nodeColor={(node) => categoryColors[node.data?.category] || '#94a3b8'}
maskColor="rgba(0,0,0,0.1)"
/>
</ReactFlow>
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-6 text-sm text-slate-500">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-100 border-2 border-green-500" />
<span>Kommunikation (4)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-orange-100 border-2 border-orange-500" />
<span>Infrastruktur (6)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-slate-100 border-2 border-slate-500" />
<span>Entwicklung (3)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-sky-100 border-2 border-sky-500" />
<span>Meta</span>
</div>
</div>
</div>
)
}

View File

@@ -85,38 +85,7 @@ interface DockerStats {
stopped_containers: number
}
type TabType = 'overview' | 'woodpecker' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
// Woodpecker Types
interface WoodpeckerStep {
name: string
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
exit_code: number
error?: string
}
interface WoodpeckerPipeline {
id: number
number: number
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
event: string
branch: string
commit: string
message: string
author: string
created: number
started: number
finished: number
steps: WoodpeckerStep[]
errors?: string[]
}
interface WoodpeckerStatus {
status: 'online' | 'offline'
pipelines: WoodpeckerPipeline[]
lastUpdate: string
error?: string
}
type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
// ============================================================================
// Helper Components
@@ -168,10 +137,6 @@ export default function CICDPage() {
const [containerFilter, setContainerFilter] = useState<'all' | 'running' | 'stopped'>('all')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Woodpecker State
const [woodpeckerStatus, setWoodpeckerStatus] = useState<WoodpeckerStatus | null>(null)
const [triggeringWoodpecker, setTriggeringWoodpecker] = useState(false)
// General State
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -214,54 +179,12 @@ export default function CICDPage() {
}
}, [])
const loadWoodpeckerData = useCallback(async () => {
try {
const response = await fetch('/api/admin/infrastructure/woodpecker?limit=10')
if (response.ok) {
const data = await response.json()
setWoodpeckerStatus(data)
}
} catch (err) {
console.error('Failed to load Woodpecker data:', err)
setWoodpeckerStatus({
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: 'Verbindung fehlgeschlagen'
})
}
}, [])
const triggerWoodpeckerPipeline = async () => {
setTriggeringWoodpecker(true)
setMessage(null)
try {
const response = await fetch('/api/admin/infrastructure/woodpecker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: 'main' })
})
if (response.ok) {
const result = await response.json()
setMessage(`Woodpecker Pipeline #${result.pipeline?.number || '?'} gestartet!`)
setTimeout(loadWoodpeckerData, 2000)
setTimeout(loadWoodpeckerData, 5000)
} else {
setError('Pipeline-Start fehlgeschlagen')
}
} catch (err) {
setError('Pipeline konnte nicht gestartet werden')
} finally {
setTriggeringWoodpecker(false)
}
}
const loadAllData = useCallback(async () => {
setLoading(true)
setError(null)
await Promise.all([loadPipelineData(), loadContainerData(), loadWoodpeckerData()])
await Promise.all([loadPipelineData(), loadContainerData()])
setLoading(false)
}, [loadPipelineData, loadContainerData, loadWoodpeckerData])
}, [loadPipelineData, loadContainerData])
useEffect(() => {
loadAllData()
@@ -402,11 +325,6 @@ export default function CICDPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)},
{ id: 'woodpecker', name: 'Woodpecker CI', icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)},
{ id: 'pipelines', name: 'Gitea Pipelines', icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
@@ -458,95 +376,6 @@ export default function CICDPage() {
{/* ================================================================ */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Woodpecker CI Status - Prominent */}
<div className={`p-4 rounded-xl border-2 ${
woodpeckerStatus?.status === 'online'
? woodpeckerStatus.pipelines?.[0]?.status === 'success'
? 'border-green-300 bg-green-50'
: woodpeckerStatus.pipelines?.[0]?.status === 'failure' || woodpeckerStatus.pipelines?.[0]?.status === 'error'
? 'border-red-300 bg-red-50'
: woodpeckerStatus.pipelines?.[0]?.status === 'running'
? 'border-blue-300 bg-blue-50'
: 'border-slate-300 bg-slate-50'
: 'border-red-300 bg-red-50'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg ${
woodpeckerStatus?.status === 'online'
? woodpeckerStatus.pipelines?.[0]?.status === 'success'
? 'bg-green-100'
: woodpeckerStatus.pipelines?.[0]?.status === 'failure' || woodpeckerStatus.pipelines?.[0]?.status === 'error'
? 'bg-red-100'
: 'bg-blue-100'
: 'bg-red-100'
}`}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-900">Woodpecker CI</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
woodpeckerStatus?.status === 'online' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
</span>
</div>
{woodpeckerStatus?.pipelines?.[0] && (
<p className="text-sm text-slate-600 mt-1">
Pipeline #{woodpeckerStatus.pipelines[0].number}: {' '}
<span className={`font-medium ${
woodpeckerStatus.pipelines[0].status === 'success' ? 'text-green-600' :
woodpeckerStatus.pipelines[0].status === 'failure' || woodpeckerStatus.pipelines[0].status === 'error' ? 'text-red-600' :
woodpeckerStatus.pipelines[0].status === 'running' ? 'text-blue-600' : 'text-slate-600'
}`}>
{woodpeckerStatus.pipelines[0].status}
</span>
{' '}auf {woodpeckerStatus.pipelines[0].branch}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('woodpecker')}
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-white"
>
Details
</button>
<button
onClick={triggerWoodpeckerPipeline}
disabled={triggeringWoodpecker}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{triggeringWoodpecker ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
)}
Starten
</button>
</div>
</div>
{/* Failed steps preview */}
{woodpeckerStatus?.pipelines?.[0]?.steps?.some(s => s.state === 'failure') && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-700 mb-2">Fehlgeschlagene Steps:</p>
<div className="flex flex-wrap gap-2">
{woodpeckerStatus.pipelines[0].steps.filter(s => s.state === 'failure').map((step, i) => (
<span key={i} className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
{step.name}
</span>
))}
</div>
</div>
)}
</div>
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
@@ -679,299 +508,6 @@ export default function CICDPage() {
</div>
)}
{/* ================================================================ */}
{/* Woodpecker Tab */}
{/* ================================================================ */}
{activeTab === 'woodpecker' && (
<div className="space-y-6">
{/* Woodpecker Status Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-slate-800">Woodpecker CI Pipeline</h3>
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
woodpeckerStatus?.status === 'online'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
<span className={`w-2 h-2 rounded-full ${
woodpeckerStatus?.status === 'online' ? 'bg-green-500' : 'bg-red-500'
}`} />
{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex items-center gap-2">
<a
href="http://macmini:8090"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Woodpecker UI
</a>
<button
onClick={triggerWoodpeckerPipeline}
disabled={triggeringWoodpecker}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{triggeringWoodpecker ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Startet...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Pipeline starten
</>
)}
</button>
</div>
</div>
{/* Pipeline Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm font-medium">Gesamt</span>
</div>
<p className="text-2xl font-bold text-blue-700">{woodpeckerStatus?.pipelines?.length || 0}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium">Erfolgreich</span>
</div>
<p className="text-2xl font-bold text-green-700">
{woodpeckerStatus?.pipelines?.filter(p => p.status === 'success').length || 0}
</p>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-sm font-medium">Fehlgeschlagen</span>
</div>
<p className="text-2xl font-bold text-red-700">
{woodpeckerStatus?.pipelines?.filter(p => p.status === 'failure' || p.status === 'error').length || 0}
</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">Laufend</span>
</div>
<p className="text-2xl font-bold text-yellow-700">
{woodpeckerStatus?.pipelines?.filter(p => p.status === 'running' || p.status === 'pending').length || 0}
</p>
</div>
</div>
{/* Pipeline List */}
{woodpeckerStatus?.pipelines && woodpeckerStatus.pipelines.length > 0 ? (
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
<div className="space-y-3">
{woodpeckerStatus.pipelines.map((pipeline) => (
<div
key={pipeline.id}
className={`border rounded-xl p-4 transition-colors ${
pipeline.status === 'success'
? 'border-green-200 bg-green-50/30'
: pipeline.status === 'failure' || pipeline.status === 'error'
? 'border-red-200 bg-red-50/30'
: pipeline.status === 'running'
? 'border-blue-200 bg-blue-50/30'
: 'border-slate-200 bg-white'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`w-3 h-3 rounded-full ${
pipeline.status === 'success' ? 'bg-green-500' :
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-500' :
pipeline.status === 'running' ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'
}`} />
<span className="font-semibold text-slate-900">Pipeline #{pipeline.number}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
pipeline.status === 'success' ? 'bg-green-100 text-green-800' :
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-100 text-red-800' :
pipeline.status === 'running' ? 'bg-blue-100 text-blue-800' :
'bg-slate-100 text-slate-600'
}`}>
{pipeline.status}
</span>
</div>
<div className="text-sm text-slate-600 mb-2">
<span className="font-mono">{pipeline.branch}</span>
<span className="mx-2 text-slate-400"></span>
<span className="font-mono text-slate-500">{pipeline.commit}</span>
<span className="mx-2 text-slate-400"></span>
<span>{pipeline.event}</span>
</div>
{pipeline.message && (
<p className="text-sm text-slate-500 mb-2 truncate max-w-xl">{pipeline.message}</p>
)}
{/* Steps Progress */}
{pipeline.steps && pipeline.steps.length > 0 && (
<div className="mt-3">
<div className="flex gap-1 mb-2">
{pipeline.steps.map((step, i) => (
<div
key={i}
className={`h-2 flex-1 rounded-full ${
step.state === 'success' ? 'bg-green-500' :
step.state === 'failure' ? 'bg-red-500' :
step.state === 'running' ? 'bg-blue-500 animate-pulse' :
step.state === 'skipped' ? 'bg-slate-200' : 'bg-slate-300'
}`}
title={`${step.name}: ${step.state}`}
/>
))}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{pipeline.steps.map((step, i) => (
<span
key={i}
className={`px-2 py-1 rounded ${
step.state === 'success' ? 'bg-green-100 text-green-700' :
step.state === 'failure' ? 'bg-red-100 text-red-700' :
step.state === 'running' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-600'
}`}
>
{step.name}
</span>
))}
</div>
</div>
)}
{/* Errors */}
{pipeline.errors && pipeline.errors.length > 0 && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<h5 className="text-sm font-medium text-red-800 mb-1">Fehler:</h5>
<ul className="text-xs text-red-700 space-y-1">
{pipeline.errors.map((err, i) => (
<li key={i} className="font-mono">{err}</li>
))}
</ul>
</div>
)}
</div>
<div className="text-right text-sm text-slate-500">
<p>{new Date(pipeline.created * 1000).toLocaleDateString('de-DE')}</p>
<p className="text-xs">{new Date(pipeline.created * 1000).toLocaleTimeString('de-DE')}</p>
{pipeline.started && pipeline.finished && (
<p className="text-xs mt-1">
Dauer: {Math.round((pipeline.finished - pipeline.started) / 60)}m
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p className="text-slate-500">Keine Pipelines gefunden</p>
<p className="text-sm text-slate-400 mt-1">Starte eine neue Pipeline oder pruefe die Woodpecker-Konfiguration</p>
</div>
)}
{/* Pipeline Configuration Info */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-3">Pipeline Konfiguration</h4>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`Woodpecker CI Pipeline (.woodpecker/main.yml)
├── 1. go-lint → Go Linting (PR only)
├── 2. python-lint → Python Linting (PR only)
├── 3. secrets-scan → GitLeaks Secrets Scan
├── 4. test-go-consent → Go Unit Tests
├── 5. test-go-billing → Billing Service Tests
├── 6. test-go-school → School Service Tests
├── 7. test-python → Python Backend Tests
├── 8. build-images → Docker Image Build
├── 9. generate-sbom → SBOM Generation (Syft)
├── 10. vuln-scan → Vulnerability Scan (Grype)
├── 11. container-scan → Container Scan (Trivy)
├── 12. sign-images → Cosign Image Signing
├── 13. attest-sbom → SBOM Attestation
├── 14. provenance → SLSA Provenance
└── 15. deploy-prod → Production Deployment`}
</pre>
</div>
{/* Workflow Anleitung */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Workflow-Anleitung
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<h5 className="font-medium text-blue-700 mb-2">🤖 Automatisch (bei jedem Push/PR):</h5>
<ul className="space-y-1 text-blue-600">
<li> <strong>Linting</strong> - Code-Qualitaet pruefen (nur PRs)</li>
<li> <strong>Unit Tests</strong> - Go & Python Tests</li>
<li> <strong>Test-Dashboard</strong> - Ergebnisse werden gesendet</li>
<li> <strong>Backlog</strong> - Fehlgeschlagene Tests werden erfasst</li>
</ul>
</div>
<div>
<h5 className="font-medium text-blue-700 mb-2">👆 Manuell (Button oder Tag):</h5>
<ul className="space-y-1 text-blue-600">
<li> <strong>Docker Builds</strong> - Container erstellen</li>
<li> <strong>SBOM/Scans</strong> - Sicherheitsanalyse</li>
<li> <strong>Deployment</strong> - In Produktion deployen</li>
<li> <strong>Pipeline starten</strong> - Diesen Button verwenden</li>
</ul>
</div>
</div>
<div className="mt-4 pt-3 border-t border-blue-200">
<h5 className="font-medium text-blue-700 mb-2"> Setup: API Token konfigurieren</h5>
<p className="text-blue-600 text-sm">
Um Pipelines ueber das Dashboard zu starten, muss ein <strong>WOODPECKER_TOKEN</strong> konfiguriert werden:
</p>
<ol className="mt-2 space-y-1 text-blue-600 text-sm list-decimal list-inside">
<li>Woodpecker UI oeffnen: <a href="http://macmini:8090" target="_blank" rel="noopener noreferrer" className="underline hover:text-blue-800">http://macmini:8090</a></li>
<li>Mit Gitea-Account einloggen</li>
<li>Klick auf Profil <strong>User Settings</strong> <strong>Personal Access Tokens</strong></li>
<li>Neues Token erstellen und in <code className="bg-blue-100 px-1 rounded">.env</code> eintragen: <code className="bg-blue-100 px-1 rounded">WOODPECKER_TOKEN=...</code></li>
<li>Container neu starten: <code className="bg-blue-100 px-1 rounded">docker compose up -d admin-v2</code></li>
</ol>
</div>
</div>
</div>
)}
{/* ================================================================ */}
{/* Pipelines Tab */}
{/* ================================================================ */}

View File

@@ -1,391 +0,0 @@
'use client'
/**
* GPU Infrastructure Admin Page
*
* vast.ai GPU Management for LLM Processing
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface VastStatus {
instance_id: number | null
status: string
gpu_name: string | null
dph_total: number | null
endpoint_base_url: string | null
last_activity: string | null
auto_shutdown_in_minutes: number | null
total_runtime_hours: number | null
total_cost_usd: number | null
account_credit: number | null
account_total_spend: number | null
session_runtime_minutes: number | null
session_cost_usd: number | null
message: string | null
error?: string
}
export default function GPUInfrastructurePage() {
const [status, setStatus] = useState<VastStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const API_PROXY = '/api/admin/gpu'
const fetchStatus = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(API_PROXY)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`)
}
setStatus(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStatus({
instance_id: null,
status: 'error',
gpu_name: null,
dph_total: null,
endpoint_base_url: null,
last_activity: null,
auto_shutdown_in_minutes: null,
total_runtime_hours: null,
total_cost_usd: null,
account_credit: null,
account_total_spend: null,
session_runtime_minutes: null,
session_cost_usd: null,
message: 'Verbindung fehlgeschlagen'
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
useEffect(() => {
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [fetchStatus])
const powerOn = async () => {
setActionLoading('on')
setError(null)
setMessage(null)
try {
const response = await fetch(API_PROXY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'on' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
}
setMessage('Start angefordert')
setTimeout(fetchStatus, 3000)
setTimeout(fetchStatus, 10000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
fetchStatus()
} finally {
setActionLoading(null)
}
}
const powerOff = async () => {
setActionLoading('off')
setError(null)
setMessage(null)
try {
const response = await fetch(API_PROXY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'off' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
}
setMessage('Stop angefordert')
setTimeout(fetchStatus, 3000)
setTimeout(fetchStatus, 10000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
fetchStatus()
} finally {
setActionLoading(null)
}
}
const getStatusBadge = (s: string) => {
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
switch (s) {
case 'running':
return `${baseClasses} bg-green-100 text-green-800`
case 'stopped':
case 'exited':
return `${baseClasses} bg-red-100 text-red-800`
case 'loading':
case 'scheduling':
case 'creating':
case 'starting...':
case 'stopping...':
return `${baseClasses} bg-yellow-100 text-yellow-800`
default:
return `${baseClasses} bg-slate-100 text-slate-600`
}
}
const getCreditColor = (credit: number | null) => {
if (credit === null) return 'text-slate-500'
if (credit < 5) return 'text-red-600'
if (credit < 15) return 'text-yellow-600'
return 'text-green-600'
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="GPU Infrastruktur"
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
audience={['DevOps', 'Entwickler', 'System-Admins']}
architecture={{
services: ['vast.ai API', 'Ollama', 'VLLM'],
databases: ['PostgreSQL (Logs)'],
}}
relatedPages={[
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Builds', href: '/infrastructure/builds', description: 'CI/CD Pipeline' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Status Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div>
<div className="text-sm text-slate-500 mb-2">Status</div>
{loading ? (
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
Laden...
</span>
) : (
<span className={getStatusBadge(
actionLoading === 'on' ? 'starting...' :
actionLoading === 'off' ? 'stopping...' :
status?.status || 'unknown'
)}>
{actionLoading === 'on' ? 'starting...' :
actionLoading === 'off' ? 'stopping...' :
status?.status || 'unbekannt'}
</span>
)}
</div>
<div>
<div className="text-sm text-slate-500 mb-2">GPU</div>
<div className="font-semibold text-slate-900">
{status?.gpu_name || '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
<div className="font-semibold text-slate-900">
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
<div className="font-semibold text-slate-900">
{status && status.auto_shutdown_in_minutes !== null
? `${status.auto_shutdown_in_minutes} min`
: '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Budget</div>
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
{status && status.account_credit !== null
? `$${status.account_credit.toFixed(2)}`
: '-'}
</div>
</div>
<div>
<div className="text-sm text-slate-500 mb-2">Session</div>
<div className="font-semibold text-slate-900">
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
: '-'}
</div>
</div>
</div>
{/* Buttons */}
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
<button
onClick={powerOn}
disabled={actionLoading !== null || status?.status === 'running'}
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Starten
</button>
<button
onClick={powerOff}
disabled={actionLoading !== null || status?.status !== 'running'}
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Stoppen
</button>
<button
onClick={fetchStatus}
disabled={loading}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
>
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
</button>
{message && (
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
)}
{error && (
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
)}
</div>
</div>
{/* Extended Stats */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600">Session Laufzeit</span>
<span className="font-semibold">
{status && status.session_runtime_minutes !== null
? `${Math.round(status.session_runtime_minutes)} Minuten`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Session Kosten</span>
<span className="font-semibold">
{status && status.session_cost_usd !== null
? `$${status.session_cost_usd.toFixed(4)}`
: '-'}
</span>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
<span className="text-slate-600">Gesamtlaufzeit</span>
<span className="font-semibold">
{status && status.total_runtime_hours !== null
? `${status.total_runtime_hours.toFixed(1)} Stunden`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Gesamtkosten</span>
<span className="font-semibold">
{status && status.total_cost_usd !== null
? `$${status.total_cost_usd.toFixed(2)}`
: '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">vast.ai Ausgaben</span>
<span className="font-semibold">
{status && status.account_total_spend !== null
? `$${status.account_total_spend.toFixed(2)}`
: '-'}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600">Instanz ID</span>
<span className="font-mono text-sm">
{status?.instance_id || '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">GPU</span>
<span className="font-semibold">
{status?.gpu_name || '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Stundensatz</span>
<span className="font-semibold">
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Letzte Aktivitaet</span>
<span className="text-sm">
{status?.last_activity
? new Date(status.last_activity).toLocaleString('de-DE')
: '-'}
</span>
</div>
{status?.endpoint_base_url && status.status === 'running' && (
<div className="pt-4 border-t border-slate-100">
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
{status.endpoint_base_url}
</code>
</div>
)}
</div>
</div>
</div>
{/* Info */}
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-orange-900">Auto-Shutdown</h4>
<p className="text-sm text-orange-800 mt-1">
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
Der Status wird alle 30 Sekunden automatisch aktualisiert.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -110,8 +110,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
// ===== CI/CD & VERSION CONTROL =====
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
// ===== DEVELOPMENT =====
@@ -120,11 +119,6 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== GAME (Breakpilot Drive) =====
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
// ===== VOICE SERVICE =====
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
// ===== BQAS (Quality Assurance) =====
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
@@ -193,6 +187,15 @@ const PYTHON_PACKAGES: Component[] = [
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
{ type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' },
{ type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' },
{ type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' },
{ type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' },
{ type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz für RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' },
{ type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' },
{ type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' },
{ type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' },
{ type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' },
]
// Key Go modules (from go.mod files)

View File

@@ -639,7 +639,7 @@ Tests bleiben wo sie sind:
<div className="mt-4 pt-4 border-t border-blue-200">
<p className="text-sm text-blue-600">
<strong>Daten-Fluss:</strong> Woodpecker CI POST /api/tests/ci-result PostgreSQL Test Dashboard
<strong>Daten-Fluss:</strong> Gitea Actions POST /api/tests/ci-result PostgreSQL Test Dashboard
</p>
</div>
</div>
@@ -1185,9 +1185,6 @@ export default function TestDashboardPage() {
const DEMO_SERVICES: ServiceTestInfo[] = [
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'voice-service', display_name: 'Voice Service', port: 8091, language: 'python', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 68.9, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'bqas-golden', display_name: 'BQAS Golden Suite', port: 8091, language: 'python', total_tests: 97, passed_tests: 89, failed_tests: 8, skipped_tests: 0, pass_rate: 91.7, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'bqas-rag', display_name: 'BQAS RAG Tests', port: 8091, language: 'python', total_tests: 20, passed_tests: 18, failed_tests: 2, skipped_tests: 0, pass_rate: 90.0, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },

View File

@@ -1,210 +0,0 @@
/**
* Communication Admin API Route - Stats Proxy
*
* Proxies requests to Matrix/Jitsi admin endpoints via backend
* Aggregates statistics from both services
*/
import { NextRequest, NextResponse } from 'next/server'
// Service URLs
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const CONSENT_SERVICE_URL = process.env.CONSENT_SERVICE_URL || 'http://localhost:8081'
const MATRIX_ADMIN_URL = process.env.MATRIX_ADMIN_URL || 'http://localhost:8448'
const JITSI_URL = process.env.JITSI_URL || 'http://localhost:8443'
// Matrix Admin Token (for Synapse Admin API)
const MATRIX_ADMIN_TOKEN = process.env.MATRIX_ADMIN_TOKEN || ''
interface MatrixStats {
total_users: number
active_users: number
total_rooms: number
active_rooms: number
messages_today: number
messages_this_week: number
status: 'online' | 'offline' | 'degraded'
}
interface JitsiStats {
active_meetings: number
total_participants: number
meetings_today: number
average_duration_minutes: number
peak_concurrent_users: number
total_minutes_today: number
status: 'online' | 'offline' | 'degraded'
}
async function fetchFromBackend(): Promise<{
matrix: MatrixStats
jitsi: JitsiStats
active_meetings: unknown[]
recent_rooms: unknown[]
} | null> {
try {
const response = await fetch(`${BACKEND_URL}/api/v1/communication/admin/stats`, {
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(5000),
})
if (response.ok) {
return await response.json()
}
} catch (error) {
console.log('Backend not reachable, trying consent service:', error)
}
return null
}
async function fetchFromConsentService(): Promise<{
matrix: MatrixStats
jitsi: JitsiStats
active_meetings: unknown[]
recent_rooms: unknown[]
} | null> {
try {
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`, {
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(5000),
})
if (response.ok) {
return await response.json()
}
} catch (error) {
console.log('Consent service not reachable:', error)
}
return null
}
async function fetchMatrixStats(): Promise<MatrixStats> {
try {
// Check if Matrix is reachable
const healthCheck = await fetch(`${MATRIX_ADMIN_URL}/_matrix/client/versions`, {
signal: AbortSignal.timeout(5000)
})
if (healthCheck.ok) {
// Try to get user count from admin API
if (MATRIX_ADMIN_TOKEN) {
try {
const usersResponse = await fetch(`${MATRIX_ADMIN_URL}/_synapse/admin/v2/users?limit=1`, {
headers: { 'Authorization': `Bearer ${MATRIX_ADMIN_TOKEN}` },
signal: AbortSignal.timeout(5000),
})
if (usersResponse.ok) {
const data = await usersResponse.json()
return {
total_users: data.total || 0,
active_users: 0,
total_rooms: 0,
active_rooms: 0,
messages_today: 0,
messages_this_week: 0,
status: 'online'
}
}
} catch {
// Admin API not available
}
}
return {
total_users: 0,
active_users: 0,
total_rooms: 0,
active_rooms: 0,
messages_today: 0,
messages_this_week: 0,
status: 'degraded' // Server reachable but no admin access
}
}
} catch (error) {
console.error('Matrix stats fetch error:', error)
}
return {
total_users: 0,
active_users: 0,
total_rooms: 0,
active_rooms: 0,
messages_today: 0,
messages_this_week: 0,
status: 'offline'
}
}
async function fetchJitsiStats(): Promise<JitsiStats> {
try {
// Check if Jitsi is reachable
const healthCheck = await fetch(`${JITSI_URL}/http-bind`, {
method: 'HEAD',
signal: AbortSignal.timeout(5000)
})
return {
active_meetings: 0,
total_participants: 0,
meetings_today: 0,
average_duration_minutes: 0,
peak_concurrent_users: 0,
total_minutes_today: 0,
status: healthCheck.ok ? 'online' : 'offline'
}
} catch (error) {
console.error('Jitsi stats fetch error:', error)
return {
active_meetings: 0,
total_participants: 0,
meetings_today: 0,
average_duration_minutes: 0,
peak_concurrent_users: 0,
total_minutes_today: 0,
status: 'offline'
}
}
}
export async function GET(request: NextRequest) {
try {
// Try backend first
let data = await fetchFromBackend()
// Fallback to consent service
if (!data) {
data = await fetchFromConsentService()
}
// If both fail, try direct service checks
if (!data) {
const [matrixStats, jitsiStats] = await Promise.all([
fetchMatrixStats(),
fetchJitsiStats()
])
data = {
matrix: matrixStats,
jitsi: jitsiStats,
active_meetings: [],
recent_rooms: []
}
}
return NextResponse.json({
...data,
last_updated: new Date().toISOString()
})
} catch (error) {
console.error('Communication stats error:', error)
return NextResponse.json(
{
error: 'Fehler beim Abrufen der Statistiken',
matrix: { status: 'offline', total_users: 0, active_users: 0, total_rooms: 0, active_rooms: 0, messages_today: 0, messages_this_week: 0 },
jitsi: { status: 'offline', active_meetings: 0, total_participants: 0, meetings_today: 0, average_duration_minutes: 0, peak_concurrent_users: 0, total_minutes_today: 0 },
active_meetings: [],
recent_rooms: [],
last_updated: new Date().toISOString()
},
{ status: 503 }
)
}
}

View File

@@ -16,7 +16,6 @@ const SERVICES: ServiceConfig[] = [
// Core Services
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/health', category: 'core' },
{ name: 'Voice Service', port: 8091, endpoint: '/health', category: 'core' },
{ name: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
{ name: 'Edu Search', port: 8088, endpoint: '/health', category: 'core' },
@@ -41,7 +40,6 @@ const getInternalHost = (port: number): string => {
const serviceMap: Record<number, string> = {
8000: 'backend',
8081: 'consent-service',
8091: 'voice-service',
8086: 'klausur-service',
8025: 'mailpit',
8088: 'edu-search-service',

View File

@@ -1,271 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
// Woodpecker API configuration
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-core:8000'
export interface PipelineStep {
name: string
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
exit_code: number
error?: string
}
export interface Pipeline {
id: number
number: number
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
event: string
branch: string
commit: string
message: string
author: string
created: number
started: number
finished: number
steps: PipelineStep[]
errors?: string[]
repo_name?: string
}
export interface WoodpeckerStatusResponse {
status: 'online' | 'offline'
pipelines: Pipeline[]
lastUpdate: string
error?: string
}
async function fetchFromBackendProxy(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
// Use backend-core proxy that reads Woodpecker sqlite DB directly
const url = `${BACKEND_URL}/api/v1/woodpecker/pipelines?repo=${repoId}&limit=${limit}`
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) {
return {
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: `Backend Woodpecker Proxy Fehler (${response.status})`
}
}
const data = await response.json()
return {
status: data.status || 'online',
pipelines: (data.pipelines || []).map((p: any) => ({
id: p.id,
number: p.number,
status: p.status,
event: p.event,
branch: p.branch || 'main',
commit: p.commit || '',
message: p.message || '',
author: p.author || '',
created: p.created,
started: p.started,
finished: p.finished,
repo_name: p.repo_name,
steps: (p.steps || []).map((s: any) => ({
name: s.name,
state: s.state,
exit_code: s.exit_code || 0,
error: s.error
})),
})),
lastUpdate: data.lastUpdate || new Date().toISOString(),
}
}
async function fetchFromWoodpeckerAPI(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines?per_page=${limit}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
}
)
if (!response.ok) {
return {
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: `Woodpecker API nicht erreichbar (${response.status})`
}
}
const rawPipelines = await response.json()
const pipelines: Pipeline[] = rawPipelines.map((p: any) => {
const errors: string[] = []
const steps: PipelineStep[] = []
if (p.workflows) {
for (const workflow of p.workflows) {
if (workflow.children) {
for (const child of workflow.children) {
steps.push({
name: child.name,
state: child.state,
exit_code: child.exit_code,
error: child.error
})
if (child.state === 'failure' && child.error) {
errors.push(`${child.name}: ${child.error}`)
}
}
}
}
}
return {
id: p.id,
number: p.number,
status: p.status,
event: p.event,
branch: p.branch,
commit: p.commit?.substring(0, 7) || '',
message: p.message || '',
author: p.author,
created: p.created,
started: p.started,
finished: p.finished,
steps,
errors: errors.length > 0 ? errors : undefined
}
})
return {
status: 'online',
pipelines,
lastUpdate: new Date().toISOString()
}
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const repoId = searchParams.get('repo') || '0'
const limit = parseInt(searchParams.get('limit') || '10')
try {
// If WOODPECKER_TOKEN is set, use the Woodpecker API directly
// Otherwise, use the backend proxy that reads the sqlite DB
if (WOODPECKER_TOKEN) {
return NextResponse.json(await fetchFromWoodpeckerAPI(repoId, limit))
} else {
return NextResponse.json(await fetchFromBackendProxy(repoId, limit))
}
} catch (error) {
console.error('Woodpecker API error:', error)
return NextResponse.json({
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: 'Fehler beim Abrufen des Woodpecker Status'
} as WoodpeckerStatusResponse)
}
}
// Trigger a new pipeline
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { repoId = '1', branch = 'main' } = body
if (!WOODPECKER_TOKEN) {
return NextResponse.json(
{ error: 'WOODPECKER_TOKEN nicht konfiguriert - Pipeline-Start nicht moeglich' },
{ status: 503 }
)
}
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ branch }),
}
)
if (!response.ok) {
return NextResponse.json(
{ error: 'Pipeline konnte nicht gestartet werden' },
{ status: 500 }
)
}
const pipeline = await response.json()
return NextResponse.json({
success: true,
pipeline: {
id: pipeline.id,
number: pipeline.number,
status: pipeline.status
}
})
} catch (error) {
console.error('Pipeline trigger error:', error)
return NextResponse.json(
{ error: 'Fehler beim Starten der Pipeline' },
{ status: 500 }
)
}
}
// Get pipeline logs
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { repoId = '1', pipelineNumber, stepId } = body
if (!pipelineNumber || !stepId) {
return NextResponse.json(
{ error: 'pipelineNumber und stepId erforderlich' },
{ status: 400 }
)
}
if (!WOODPECKER_TOKEN) {
return NextResponse.json(
{ error: 'WOODPECKER_TOKEN nicht konfiguriert' },
{ status: 503 }
)
}
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines/${pipelineNumber}/logs/${stepId}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
return NextResponse.json(
{ error: 'Logs nicht verfuegbar' },
{ status: response.status }
)
}
const logs = await response.json()
return NextResponse.json({ logs })
} catch (error) {
console.error('Pipeline logs error:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Logs' },
{ status: 500 }
)
}
}

View File

@@ -1,81 +0,0 @@
import { NextResponse } from 'next/server'
/**
* Server-side proxy for Mailpit API
* Avoids CORS and mixed-content issues by fetching from server
*/
// Use internal Docker hostname when running in container
const getMailpitHost = (): string => {
return process.env.BACKEND_URL ? 'mailpit' : 'localhost'
}
export async function GET() {
const host = getMailpitHost()
const mailpitUrl = `http://${host}:8025/api/v1/info`
try {
const response = await fetch(mailpitUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
})
if (!response.ok) {
return NextResponse.json(
{ error: 'Mailpit API error', status: response.status },
{ status: response.status }
)
}
const data = await response.json()
// Transform Mailpit response to our expected format
return NextResponse.json({
stats: {
totalAccounts: 1,
activeAccounts: 1,
totalEmails: data.Messages || 0,
unreadEmails: data.Unread || 0,
totalTasks: 0,
pendingTasks: 0,
overdueTasks: 0,
aiAnalyzedCount: 0,
lastSyncTime: new Date().toISOString(),
},
accounts: [{
id: 'mailpit-dev',
email: 'dev@mailpit.local',
displayName: 'Mailpit (Development)',
imapHost: 'mailpit',
imapPort: 1143,
smtpHost: 'mailpit',
smtpPort: 1025,
status: 'active' as const,
lastSync: new Date().toISOString(),
emailCount: data.Messages || 0,
unreadCount: data.Unread || 0,
createdAt: new Date().toISOString(),
}],
syncStatus: {
running: false,
accountsInProgress: [],
lastCompleted: new Date().toISOString(),
errors: [],
},
mailpitInfo: {
version: data.Version,
databaseSize: data.DatabaseSize,
uptime: data.RuntimeStats?.Uptime,
}
})
} catch (error) {
console.error('Failed to fetch from Mailpit:', error)
return NextResponse.json(
{
error: 'Failed to connect to Mailpit',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 503 }
)
}
}

View File

@@ -1,172 +0,0 @@
/**
* Alerts API Proxy - Catch-all route
* Proxies all /api/alerts/* requests to backend
* Supports: inbox, topics, rules, profile, stats, etc.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
function getForwardHeaders(request: NextRequest): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward cookie for session auth
const cookie = request.headers.get('cookie')
if (cookie) {
headers['Cookie'] = cookie
}
// Forward authorization header if present
const auth = request.headers.get('authorization')
if (auth) {
headers['Authorization'] = auth
}
return headers
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
const pathStr = path.join('/')
const searchParams = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/alerts/${pathStr}${searchParams ? `?${searchParams}` : ''}`
try {
const response = await fetch(url, {
method: 'GET',
headers: getForwardHeaders(request),
signal: AbortSignal.timeout(30000)
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Alerts API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
const pathStr = path.join('/')
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
try {
const body = await request.json()
const response = await fetch(url, {
method: 'POST',
headers: getForwardHeaders(request),
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000)
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Alerts API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
const pathStr = path.join('/')
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
try {
const body = await request.json()
const response = await fetch(url, {
method: 'PUT',
headers: getForwardHeaders(request),
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000)
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Alerts API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
const pathStr = path.join('/')
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
try {
const response = await fetch(url, {
method: 'DELETE',
headers: getForwardHeaders(request),
signal: AbortSignal.timeout(30000)
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Alerts API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}

View File

@@ -1,273 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import type { WoodpeckerWebhookPayload, ExtractedError, BacklogSource } from '@/types/infrastructure-modules'
// =============================================================================
// Configuration
// =============================================================================
// Webhook secret for verification (optional but recommended)
const WEBHOOK_SECRET = process.env.WOODPECKER_WEBHOOK_SECRET || ''
// Internal API URL for log extraction
const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
? `${process.env.NEXT_PUBLIC_APP_URL}/api/infrastructure/log-extract/extract`
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
// Test service API URL for backlog insertion
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Verify webhook signature (if secret is configured)
*/
function verifySignature(request: NextRequest, body: string): boolean {
if (!WEBHOOK_SECRET) return true // Skip verification if no secret configured
const signature = request.headers.get('X-Woodpecker-Signature')
if (!signature) return false
// Simple HMAC verification (Woodpecker uses SHA256)
const crypto = require('crypto')
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex')
return signature === `sha256=${expectedSignature}`
}
/**
* Map error category to backlog priority
*/
function categoryToPriority(category: string): 'critical' | 'high' | 'medium' | 'low' {
switch (category) {
case 'security_warning':
return 'critical'
case 'build_error':
return 'high'
case 'license_violation':
return 'high'
case 'test_failure':
return 'medium'
case 'dependency_issue':
return 'low'
default:
return 'medium'
}
}
/**
* Map error category to error_type for backlog
*/
function categoryToErrorType(category: string): string {
switch (category) {
case 'security_warning':
return 'security'
case 'build_error':
return 'build'
case 'license_violation':
return 'license'
case 'test_failure':
return 'test'
case 'dependency_issue':
return 'dependency'
default:
return 'unknown'
}
}
/**
* Insert extracted errors into backlog
*/
async function insertIntoBacklog(
errors: ExtractedError[],
pipelineNumber: number,
source: BacklogSource
): Promise<{ inserted: number; failed: number }> {
let inserted = 0
let failed = 0
for (const error of errors) {
try {
// Create backlog item
const backlogItem = {
test_name: error.message.substring(0, 200), // Truncate long messages
test_file: error.file_path || null,
service: error.service || 'unknown',
framework: `ci_cd_pipeline_${pipelineNumber}`,
error_message: error.message,
error_type: categoryToErrorType(error.category),
status: 'open',
priority: categoryToPriority(error.category),
fix_suggestion: error.suggested_fix || null,
notes: `Auto-generated from pipeline #${pipelineNumber}, step: ${error.step}, line: ${error.line}`,
source, // Custom field to track origin
}
// Try to insert into test service backlog
const response = await fetch(`${TEST_SERVICE_URL}/api/v1/backlog`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(backlogItem),
})
if (response.ok) {
inserted++
} else {
console.warn(`Failed to insert backlog item: ${response.status}`)
failed++
}
} catch (insertError) {
console.error('Backlog insertion error:', insertError)
failed++
}
}
return { inserted, failed }
}
// =============================================================================
// API Handler
// =============================================================================
/**
* POST /api/webhooks/woodpecker
*
* Webhook endpoint fuer Woodpecker CI/CD Events.
*
* Bei Pipeline-Failure:
* 1. Extrahiert Logs mit /api/infrastructure/logs/extract
* 2. Parsed Fehler nach Kategorie
* 3. Traegt automatisch in Backlog ein
*
* Request Body (Woodpecker Webhook Format):
* - event: 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
* - repo_id: number
* - pipeline_number: number
* - branch?: string
* - commit?: string
* - author?: string
* - message?: string
*/
export async function POST(request: NextRequest) {
try {
const bodyText = await request.text()
// Verify webhook signature
if (!verifySignature(request, bodyText)) {
return NextResponse.json(
{ error: 'Invalid webhook signature' },
{ status: 401 }
)
}
const payload: WoodpeckerWebhookPayload = JSON.parse(bodyText)
// Log all events for debugging
console.log(`Woodpecker webhook: ${payload.event} for pipeline #${payload.pipeline_number}`)
// Only process pipeline_failure events
if (payload.event !== 'pipeline_failure') {
return NextResponse.json({
status: 'ignored',
message: `Event ${payload.event} wird nicht verarbeitet`,
pipeline_number: payload.pipeline_number,
})
}
// 1. Extract logs from failed pipeline
console.log(`Extracting logs for failed pipeline #${payload.pipeline_number}`)
const extractResponse = await fetch(LOG_EXTRACT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pipeline_number: payload.pipeline_number,
repo_id: String(payload.repo_id),
}),
})
if (!extractResponse.ok) {
const errorText = await extractResponse.text()
console.error('Log extraction failed:', errorText)
return NextResponse.json({
status: 'error',
message: 'Log-Extraktion fehlgeschlagen',
pipeline_number: payload.pipeline_number,
}, { status: 500 })
}
const extractionResult = await extractResponse.json()
const errors: ExtractedError[] = extractionResult.errors || []
console.log(`Extracted ${errors.length} errors from pipeline #${payload.pipeline_number}`)
// 2. Insert errors into backlog
if (errors.length > 0) {
const backlogResult = await insertIntoBacklog(
errors,
payload.pipeline_number,
'ci_cd'
)
console.log(`Backlog: ${backlogResult.inserted} inserted, ${backlogResult.failed} failed`)
return NextResponse.json({
status: 'processed',
pipeline_number: payload.pipeline_number,
branch: payload.branch,
commit: payload.commit,
errors_found: errors.length,
backlog_inserted: backlogResult.inserted,
backlog_failed: backlogResult.failed,
categories: {
test_failure: errors.filter(e => e.category === 'test_failure').length,
build_error: errors.filter(e => e.category === 'build_error').length,
security_warning: errors.filter(e => e.category === 'security_warning').length,
license_violation: errors.filter(e => e.category === 'license_violation').length,
dependency_issue: errors.filter(e => e.category === 'dependency_issue').length,
},
})
}
return NextResponse.json({
status: 'processed',
pipeline_number: payload.pipeline_number,
message: 'Keine Fehler extrahiert',
errors_found: 0,
})
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json(
{ error: 'Webhook-Verarbeitung fehlgeschlagen' },
{ status: 500 }
)
}
}
/**
* GET /api/webhooks/woodpecker
*
* Health check endpoint
*/
export async function GET() {
return NextResponse.json({
status: 'ready',
endpoint: '/api/webhooks/woodpecker',
events: ['pipeline_failure'],
description: 'Woodpecker CI/CD Webhook Handler',
configured: {
webhook_secret: WEBHOOK_SECRET ? 'yes' : 'no',
log_extract_url: LOG_EXTRACT_URL,
test_service_url: TEST_SERVICE_URL,
},
})
}

View File

@@ -92,25 +92,7 @@ function usePipelineLiveStatus(): PipelineLiveStatus | null {
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
useEffect(() => {
// Optional: Fetch live status from API
// For now, return null and display static content
// Uncomment below to enable live status fetching
/*
const fetchStatus = async () => {
try {
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
if (response.ok) {
const data = await response.json()
setStatus(data)
}
} catch (error) {
console.error('Failed to fetch pipeline status:', error)
}
}
fetchStatus()
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
return () => clearInterval(interval)
*/
// Live status fetching not yet implemented
}, [])
return status
@@ -246,7 +228,7 @@ export function DevOpsPipelineSidebar({
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'ci-cd' && (
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
<span>Verwalten Sie Gitea Actions Pipelines und Deployments</span>
)}
{currentTool === 'tests' && (
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
@@ -458,7 +440,7 @@ export function DevOpsPipelineSidebarResponsive({
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentTool === 'ci-cd' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Gitea Actions Pipelines und Deployments verwalten
</>
)}
{currentTool === 'tests' && (

View File

@@ -4,7 +4,7 @@
* 3 Categories: Communication, Infrastructure, Development
*/
export type CategoryId = 'communication' | 'infrastructure' | 'development'
export type CategoryId = 'infrastructure'
export interface NavModule {
id: string
@@ -27,51 +27,6 @@ export interface NavCategory {
}
export const navigation: NavCategory[] = [
// =========================================================================
// Kommunikation & Alerts (Green)
// =========================================================================
{
id: 'communication',
name: 'Kommunikation',
icon: 'message-circle',
color: '#22c55e',
colorClass: 'communication',
description: 'Matrix, Jitsi, E-Mail & Alerts',
modules: [
{
id: 'video-chat',
name: 'Video & Chat',
href: '/communication/video-chat',
description: 'Matrix & Jitsi Monitoring',
purpose: 'Dashboard fuer Matrix Synapse und Jitsi Meet. Service-Status, aktive Meetings, Traffic.',
audience: ['Admins', 'DevOps'],
},
{
id: 'matrix',
name: 'Voice Service',
href: '/communication/matrix',
description: 'PersonaPlex-7B & TaskOrchestrator',
purpose: 'Voice-First Interface Konfiguration und Architektur-Dokumentation.',
audience: ['Entwickler', 'Admins'],
},
{
id: 'mail',
name: 'Unified Inbox',
href: '/communication/mail',
description: 'E-Mail-Konten & KI-Analyse',
purpose: 'E-Mail-Konten verwalten und KI-Kategorisierung nutzen.',
audience: ['Support', 'Admins'],
},
{
id: 'alerts',
name: 'Alerts Monitoring',
href: '/communication/alerts',
description: 'Google Alerts & Feed-Ueberwachung',
purpose: 'Google Alerts und RSS-Feeds fuer relevante Neuigkeiten ueberwachen.',
audience: ['Marketing', 'Admins'],
},
],
},
// =========================================================================
// Infrastruktur & DevOps (Orange)
// =========================================================================
@@ -83,15 +38,6 @@ export const navigation: NavCategory[] = [
colorClass: 'infrastructure',
description: 'GPU, Security, CI/CD & Monitoring',
modules: [
{
id: 'gpu',
name: 'GPU Infrastruktur',
href: '/infrastructure/gpu',
description: 'vast.ai GPU Management',
purpose: 'GPU-Instanzen auf vast.ai fuer ML-Training und Inferenz verwalten.',
audience: ['DevOps', 'Entwickler'],
subgroup: 'Compute',
},
{
id: 'middleware',
name: 'Middleware',
@@ -123,7 +69,7 @@ export const navigation: NavCategory[] = [
id: 'ci-cd',
name: 'CI/CD Dashboard',
href: '/infrastructure/ci-cd',
description: 'Gitea & Woodpecker Pipelines',
description: 'Gitea Actions Pipelines',
purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.',
audience: ['DevOps', 'Entwickler'],
subgroup: 'DevOps Pipeline',
@@ -139,43 +85,6 @@ export const navigation: NavCategory[] = [
},
],
},
// =========================================================================
// Entwicklung (Slate)
// =========================================================================
{
id: 'development',
name: 'Entwicklung',
icon: 'code',
color: '#64748b',
colorClass: 'development',
description: 'Docs, Screen Flow & Brandbook',
modules: [
{
id: 'docs',
name: 'Developer Docs',
href: '/development/docs',
description: 'MkDocs Dokumentation',
purpose: 'API-Dokumentation und Architektur-Diagramme durchsuchen.',
audience: ['Entwickler'],
},
{
id: 'screen-flow',
name: 'Screen Flow',
href: '/development/screen-flow',
description: 'UI Screen-Verbindungen',
purpose: 'Navigation und Screen-Verbindungen der Core-App visualisieren.',
audience: ['Designer', 'Entwickler'],
},
{
id: 'brandbook',
name: 'Brandbook',
href: '/development/brandbook',
description: 'Corporate Design',
purpose: 'Referenz fuer Logos, Farben, Typografie und Design-Richtlinien.',
audience: ['Designer', 'Marketing'],
},
],
},
]
// Meta modules (always visible)

View File

@@ -2,7 +2,7 @@
* Shared Types & Constants for Infrastructure/DevOps Modules
*
* Diese Datei enthaelt gemeinsame Typen und Konstanten fuer die DevOps-Pipeline:
* - CI/CD: Woodpecker Pipelines & Deployments
* - CI/CD: Gitea Actions Pipelines & Deployments
* - Tests: Test Dashboard & Backlog
* - SBOM: Software Bill of Materials & Lizenz-Checks
* - Security: DevSecOps Scans & Vulnerabilities
@@ -230,24 +230,6 @@ export interface LogExtractionResponse {
// Webhook Types
// =============================================================================
/**
* Woodpecker Webhook Event Types
*/
export type WoodpeckerEventType = 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
/**
* Woodpecker Webhook Payload
*/
export interface WoodpeckerWebhookPayload {
event: WoodpeckerEventType
repo_id: number
pipeline_number: number
branch?: string
commit?: string
author?: string
message?: string
}
// =============================================================================
// LLM Integration Types
// =============================================================================
@@ -346,18 +328,14 @@ export interface PipelineLiveStatus {
export const INFRASTRUCTURE_API_ENDPOINTS = {
/** CI/CD Endpoints */
CI_CD: {
PIPELINES: '/api/admin/infrastructure/woodpecker',
TRIGGER: '/api/admin/infrastructure/woodpecker/trigger',
LOGS: '/api/admin/infrastructure/woodpecker/logs',
PIPELINES: '/api/v1/security/sbom/pipeline/history',
STATUS: '/api/v1/security/sbom/pipeline/status',
TRIGGER: '/api/v1/security/sbom/pipeline/trigger',
},
/** Log Extraction Endpoints */
LOG_EXTRACT: {
EXTRACT: '/api/infrastructure/log-extract/extract',
},
/** Webhook Endpoints */
WEBHOOKS: {
WOODPECKER: '/api/webhooks/woodpecker',
},
/** LLM Endpoints */
LLM: {
ANALYZE: '/api/ai/analyze',
@@ -375,7 +353,6 @@ export const INFRASTRUCTURE_API_ENDPOINTS = {
*/
export const DEVOPS_ARCHITECTURE = {
services: [
{ name: 'Woodpecker CI', port: 8000, description: 'CI/CD Pipeline Server' },
{ name: 'Gitea', port: 3003, description: 'Git Repository Server' },
{ name: 'Syft', type: 'CLI', description: 'SBOM Generator' },
{ name: 'Grype', type: 'CLI', description: 'Vulnerability Scanner' },

View File

@@ -43,11 +43,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Install DevSecOps tools (gitleaks, trivy, grype, syft)
ARG TARGETARCH=arm64
ARG TARGETARCH
RUN set -eux; \
ARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
# Gitleaks
GITLEAKS_VERSION=8.21.2; \
if [ "$TARGETARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \
if [ "$ARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz" \
| tar xz -C /usr/local/bin gitleaks; \
# Trivy

View File

@@ -25,8 +25,6 @@ from email_template_api import (
)
from system_api import router as system_router
from security_api import router as security_router
from woodpecker_proxy_api import router as woodpecker_router
# ---------------------------------------------------------------------------
# Middleware imports
# ---------------------------------------------------------------------------
@@ -106,7 +104,6 @@ app.include_router(system_router) # already has paths defined in r
# Security / DevSecOps dashboard
app.include_router(security_router, prefix="/api")
app.include_router(woodpecker_router, prefix="/api")
# ---------------------------------------------------------------------------
# Startup / Shutdown events

View File

@@ -1,133 +0,0 @@
"""
Woodpecker CI Proxy API
Liest Pipeline-Daten direkt aus der Woodpecker SQLite-Datenbank.
Wird als Fallback verwendet, wenn kein WOODPECKER_TOKEN konfiguriert ist.
"""
import sqlite3
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Query
router = APIRouter(prefix="/v1/woodpecker", tags=["Woodpecker CI"])
WOODPECKER_DB = Path("/woodpecker-data/woodpecker.sqlite")
def get_db():
if not WOODPECKER_DB.exists():
return None
conn = sqlite3.connect(f"file:{WOODPECKER_DB}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
@router.get("/status")
async def get_status():
conn = get_db()
if not conn:
return {"status": "offline", "error": "Woodpecker DB nicht gefunden"}
try:
repos = [dict(r) for r in conn.execute(
"SELECT id, name, full_name, active FROM repos ORDER BY id"
).fetchall()]
total_pipelines = conn.execute("SELECT COUNT(*) FROM pipelines").fetchone()[0]
success = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='success'").fetchone()[0]
failure = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='failure'").fetchone()[0]
latest = conn.execute("SELECT MAX(created) FROM pipelines").fetchone()[0]
return {
"status": "online",
"repos": repos,
"stats": {
"total_pipelines": total_pipelines,
"success": success,
"failure": failure,
"success_rate": round(success / total_pipelines * 100, 1) if total_pipelines > 0 else 0,
},
"last_activity": datetime.fromtimestamp(latest).isoformat() if latest else None,
}
finally:
conn.close()
@router.get("/pipelines")
async def get_pipelines(
repo: int = Query(default=0, description="Repo ID (0 = alle)"),
limit: int = Query(default=10, ge=1, le=100),
):
conn = get_db()
if not conn:
return {"status": "offline", "pipelines": [], "lastUpdate": datetime.now().isoformat()}
try:
base_sql = """SELECT p.id, p.repo_id, p.number, p.status, p.event, p.branch,
p."commit", p.message, p.author, p.created, p.started, p.finished,
r.name as repo_name
FROM pipelines p
JOIN repos r ON r.id = p.repo_id"""
if repo > 0:
rows = conn.execute(
base_sql + " WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?",
(repo, limit)
).fetchall()
else:
rows = conn.execute(
base_sql + " ORDER BY p.id DESC LIMIT ?",
(limit,)
).fetchall()
pipelines = []
for r in rows:
p = dict(r)
# Get steps directly (steps.pipeline_id links to pipelines.id)
steps = [dict(s) for s in conn.execute(
"""SELECT s.name, s.state, s.exit_code, s.error
FROM steps s
WHERE s.pipeline_id = ?
ORDER BY s.pid""",
(p["id"],)
).fetchall()]
p["steps"] = steps
p["commit"] = (p.get("commit") or "")[:7]
msg = p.get("message") or ""
p["message"] = msg.split("\n")[0][:100]
pipelines.append(p)
return {
"status": "online",
"pipelines": pipelines,
"lastUpdate": datetime.now().isoformat(),
}
finally:
conn.close()
@router.get("/repos")
async def get_repos():
conn = get_db()
if not conn:
return []
try:
repos = []
for r in conn.execute("SELECT id, name, full_name, active FROM repos ORDER BY id").fetchall():
repo = dict(r)
latest = conn.execute(
'SELECT status, created FROM pipelines WHERE repo_id = ? ORDER BY id DESC LIMIT 1',
(repo["id"],)
).fetchone()
if latest:
repo["last_status"] = latest["status"]
repo["last_activity"] = datetime.fromtimestamp(latest["created"]).isoformat()
repos.append(repo)
return repos
finally:
conn.close()

197
docker-compose.coolify.yml Normal file
View File

@@ -0,0 +1,197 @@
# =========================================================
# BreakPilot Core — Shared Infrastructure (Coolify)
# =========================================================
# Deployed via Coolify. SSL termination handled by Traefik.
# External services (managed separately in Coolify):
# - PostgreSQL (PostGIS), Qdrant, S3-compatible storage
# Network: breakpilot-network (shared across all 3 repos)
# =========================================================
networks:
breakpilot-network:
name: breakpilot-network
driver: bridge
volumes:
valkey_data:
embedding_models:
paddleocr_models:
services:
# =========================================================
# CACHE
# =========================================================
valkey:
image: valkey/valkey:8-alpine
container_name: bp-core-valkey
volumes:
- valkey_data:/data
command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# SHARED SERVICES
# =========================================================
consent-service:
build:
context: ./consent-service
dockerfile: Dockerfile
container_name: bp-core-consent-service
expose:
- "8081"
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
PORT: 8081
ENVIRONMENT: production
ALLOWED_ORIGINS: "*"
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.ai}
FRONTEND_URL: ${FRONTEND_URL:-https://www.breakpilot.ai}
depends_on:
valkey:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8081/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# RAG & EMBEDDING SERVICES
# =========================================================
rag-service:
build:
context: ./rag-service
dockerfile: Dockerfile
container_name: bp-core-rag-service
expose:
- "8097"
environment:
PORT: 8097
QDRANT_URL: ${QDRANT_URL}
QDRANT_API_KEY: ${QDRANT_API_KEY:-}
MINIO_ENDPOINT: ${S3_ENDPOINT}
MINIO_ACCESS_KEY: ${S3_ACCESS_KEY}
MINIO_SECRET_KEY: ${S3_SECRET_KEY}
MINIO_BUCKET: ${S3_BUCKET:-breakpilot-rag}
MINIO_SECURE: ${S3_SECURE:-true}
EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: ${OLLAMA_URL:-}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
JWT_SECRET: ${JWT_SECRET}
ENVIRONMENT: production
depends_on:
embedding-service:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8097/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
networks:
- breakpilot-network
embedding-service:
build:
context: ./embedding-service
dockerfile: Dockerfile
container_name: bp-core-embedding-service
volumes:
- embedding_models:/root/.cache/huggingface
environment:
EMBEDDING_BACKEND: ${EMBEDDING_BACKEND:-local}
LOCAL_EMBEDDING_MODEL: ${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3}
LOCAL_RERANKER_MODEL: ${LOCAL_RERANKER_MODEL:-cross-encoder/ms-marco-MiniLM-L-6-v2}
PDF_EXTRACTION_BACKEND: ${PDF_EXTRACTION_BACKEND:-pymupdf}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
COHERE_API_KEY: ${COHERE_API_KEY:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
deploy:
resources:
limits:
memory: 8G
healthcheck:
test: ["CMD", "python", "-c", "import httpx; r=httpx.get('http://127.0.0.1:8087/health'); r.raise_for_status()"]
interval: 30s
timeout: 10s
start_period: 120s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# OCR SERVICE (PaddleOCR PP-OCRv5)
# =========================================================
paddleocr-service:
build:
context: ./paddleocr-service
dockerfile: Dockerfile
container_name: bp-core-paddleocr
expose:
- "8095"
environment:
PADDLEOCR_API_KEY: ${PADDLEOCR_API_KEY:-}
FLAGS_use_mkldnn: "0"
volumes:
- paddleocr_models:/root/.paddleocr
labels:
- "traefik.http.services.paddleocr.loadbalancer.server.port=8095"
deploy:
resources:
limits:
memory: 6G
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8095/health"]
interval: 30s
timeout: 10s
start_period: 300s
retries: 5
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# HEALTH AGGREGATOR
# =========================================================
health-aggregator:
build:
context: ./scripts
dockerfile: Dockerfile.health
container_name: bp-core-health
expose:
- "8099"
environment:
PORT: 8099
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network

175
docker-compose.hetzner.yml Normal file
View File

@@ -0,0 +1,175 @@
# =========================================================
# BreakPilot Core — Hetzner Override (x86_64)
# =========================================================
# Verwendung:
# docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d \
# postgres valkey qdrant ollama embedding-service rag-service \
# backend-core consent-service health-aggregator
#
# Aenderungen gegenueber Basis (docker-compose.yml):
# - platform: linux/amd64 (statt arm64)
# - Ollama Container fuer CPU-Embeddings (bge-m3)
# - Mailpit ersetzt durch Dummy (kein Mail-Dev-Server noetig)
# - Vault, Nginx, Gitea etc. deaktiviert via Profile
# - Netzwerk: auto-create (nicht external)
# =========================================================
networks:
breakpilot-network:
external: true
name: breakpilot-network
services:
# =========================================================
# NEUE SERVICES
# =========================================================
# Ollama fuer Embeddings (CPU-only, bge-m3)
ollama:
image: ollama/ollama:latest
container_name: bp-core-ollama
platform: linux/amd64
volumes:
- ollama_models:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:11434/api/tags || exit 1"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# PLATFORM OVERRIDES (arm64 → amd64)
# =========================================================
backend-core:
platform: linux/amd64
build:
context: ./backend-core
dockerfile: Dockerfile
args:
TARGETARCH: amd64
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcore,public
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
ENVIRONMENT: ${ENVIRONMENT:-production}
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
CONSENT_SERVICE_URL: http://consent-service:8081
USE_VAULT_SECRETS: "false"
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.app}
consent-service:
platform: linux/amd64
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-your-refresh-secret}
PORT: 8081
ENVIRONMENT: ${ENVIRONMENT:-production}
ALLOWED_ORIGINS: "*"
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.app}
FRONTEND_URL: ${FRONTEND_URL:-https://admin-dev.breakpilot.ai}
billing-service:
platform: linux/amd64
rag-service:
platform: linux/amd64
ports:
- "8097:8097"
environment:
PORT: 8097
QDRANT_URL: http://qdrant:6333
MINIO_ENDPOINT: nbg1.your-objectstorage.com
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-T18RGFVXXG2ZHQ5404TP}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-KOUU4WO6wh07cQjNgh0IZHkeKQrVfBz6hnIGpNss}
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag}
MINIO_SECURE: "true"
EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: http://ollama:11434
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
ENVIRONMENT: ${ENVIRONMENT:-production}
embedding-service:
platform: linux/amd64
ports:
- "8087:8087"
health-aggregator:
platform: linux/amd64
environment:
PORT: 8099
CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,backend-core:8000,rag-service:8097,embedding-service:8087"
# =========================================================
# DUMMY-ERSATZ FUER ABHAENGIGKEITEN
# =========================================================
# backend-core + consent-service haengen von mailpit ab
# (depends_on merged bei compose override, kann nicht entfernt werden)
# → Mailpit durch leichtgewichtigen Dummy ersetzen
mailpit:
image: alpine:3.19
entrypoint: ["sh", "-c", "echo 'Mailpit dummy on Hetzner' && tail -f /dev/null"]
volumes: []
ports: []
environment: {}
# Qdrant: RocksDB braucht mehr open files
qdrant:
ulimits:
nofile:
soft: 65536
hard: 65536
# minio: rag-service haengt davon ab (depends_on)
# Lokal laufen lassen, aber rag-service nutzt externe Hetzner Object Storage
# minio bleibt unveraendert (klein, ~50MB RAM)
# =========================================================
# DEAKTIVIERTE SERVICES (via profiles)
# =========================================================
nginx:
profiles: ["disabled"]
vault:
profiles: ["disabled"]
vault-init:
profiles: ["disabled"]
vault-agent:
profiles: ["disabled"]
gitea:
profiles: ["disabled"]
gitea-runner:
profiles: ["disabled"]
night-scheduler:
profiles: ["disabled"]
admin-core:
profiles: ["disabled"]
pitch-deck:
profiles: ["disabled"]
levis-holzbau:
profiles: ["disabled"]
volumes:
ollama_models:

View File

@@ -19,22 +19,10 @@ volumes:
valkey_data:
qdrant_data:
minio_data:
# Communication
synapse_data:
synapse_db_data:
jitsi_web_config:
jitsi_web_crontabs:
jitsi_transcripts:
jitsi_prosody_config:
jitsi_prosody_plugins:
jitsi_jicofo_config:
jitsi_jvb_config:
jibri_recordings:
# CI/CD
gitea_data:
gitea_config:
gitea_runner_data:
woodpecker_data:
# ERP
erpnext_db_data:
erpnext_redis_queue_data:
@@ -42,7 +30,6 @@ volumes:
erpnext_sites:
erpnext_logs:
# Services
voice_session_data:
embedding_models:
services:
@@ -72,10 +59,12 @@ services:
- "8443:8443" # Jitsi Meet
- "3008:3008" # Admin Core
- "3010:3010" # Portal Dashboard
- "8011:8011" # Compliance Docs (MkDocs)
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- vault_certs:/etc/nginx/certs:ro
- ./nginx/html:/usr/share/nginx/html/portal:ro
- /Users/benjaminadmin/rag-originals:/data/rag-originals:ro
depends_on:
vault-agent:
condition: service_started
@@ -193,26 +182,6 @@ services:
networks:
- breakpilot-network
synapse-db:
image: postgres:16-alpine
container_name: bp-core-synapse-db
profiles: [chat]
environment:
POSTGRES_USER: synapse
POSTGRES_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse_secret}
POSTGRES_DB: synapse
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- synapse_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# VECTOR DB & OBJECT STORAGE
# =========================================================
@@ -268,7 +237,6 @@ services:
expose:
- "8000"
volumes:
- woodpecker_data:/woodpecker-data:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcore,public
@@ -379,11 +347,11 @@ services:
environment:
PORT: 8097
QDRANT_URL: http://qdrant:6333
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-breakpilot}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-breakpilot123}
MINIO_ENDPOINT: nbg1.your-objectstorage.com
MINIO_ACCESS_KEY: T18RGFVXXG2ZHQ5404TP
MINIO_SECRET_KEY: KOUU4WO6wh07cQjNgh0IZHkeKQrVfBz6hnIGpNss
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag}
MINIO_SECURE: "false"
MINIO_SECURE: "true"
EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
@@ -451,7 +419,7 @@ services:
- "8099:8099"
environment:
PORT: 8099
CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,minio:9000,backend-core:8000,rag-service:8097,embedding-service:8087,voice-service:8091"
CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,minio:9000,backend-core:8000,rag-service:8097,embedding-service:8087"
depends_on:
postgres:
condition: service_healthy
@@ -464,199 +432,6 @@ services:
networks:
- breakpilot-network
# =========================================================
# COMMUNICATION
# =========================================================
synapse:
image: matrixdotorg/synapse:latest
container_name: bp-core-synapse
profiles: [chat]
ports:
- "8008:8008"
- "8448:8448"
volumes:
- synapse_data:/data
environment:
SYNAPSE_SERVER_NAME: ${SYNAPSE_SERVER_NAME:-macmini}
SYNAPSE_REPORT_STATS: "no"
SYNAPSE_NO_TLS: "true"
SYNAPSE_ENABLE_REGISTRATION: ${SYNAPSE_ENABLE_REGISTRATION:-true}
SYNAPSE_LOG_LEVEL: ${SYNAPSE_LOG_LEVEL:-WARNING}
UID: "1000"
GID: "1000"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8008/health"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
depends_on:
synapse-db:
condition: service_healthy
restart: unless-stopped
networks:
- breakpilot-network
jitsi-web:
image: jitsi/web:stable-9823
container_name: bp-core-jitsi-web
expose:
- "80"
volumes:
- jitsi_web_config:/config
- jitsi_web_crontabs:/var/spool/cron/crontabs
- jitsi_transcripts:/usr/share/jitsi-meet/transcripts
environment:
ENABLE_XMPP_WEBSOCKET: "true"
ENABLE_COLIBRI_WEBSOCKET: "true"
XMPP_DOMAIN: ${XMPP_DOMAIN:-meet.jitsi}
XMPP_BOSH_URL_BASE: http://jitsi-xmpp:5280
XMPP_MUC_DOMAIN: ${XMPP_MUC_DOMAIN:-muc.meet.jitsi}
XMPP_GUEST_DOMAIN: ${XMPP_GUEST_DOMAIN:-guest.meet.jitsi}
TZ: ${TZ:-Europe/Berlin}
PUBLIC_URL: ${JITSI_PUBLIC_URL:-https://macmini:8443}
JICOFO_AUTH_USER: focus
ENABLE_AUTH: ${JITSI_ENABLE_AUTH:-false}
ENABLE_GUESTS: "true"
ENABLE_RECORDING: "true"
ENABLE_LIVESTREAMING: "false"
DISABLE_HTTPS: "true"
APP_NAME: "BreakPilot Meet"
NATIVE_APP_NAME: "BreakPilot Meet"
PROVIDER_NAME: "BreakPilot"
depends_on:
- jitsi-xmpp
networks:
breakpilot-network:
aliases:
- meet.jitsi
jitsi-xmpp:
image: jitsi/prosody:stable-9823
container_name: bp-core-jitsi-xmpp
volumes:
- jitsi_prosody_config:/config
- jitsi_prosody_plugins:/prosody-plugins-custom
environment:
XMPP_DOMAIN: ${XMPP_DOMAIN:-meet.jitsi}
XMPP_AUTH_DOMAIN: ${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}
XMPP_MUC_DOMAIN: ${XMPP_MUC_DOMAIN:-muc.meet.jitsi}
XMPP_INTERNAL_MUC_DOMAIN: ${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}
XMPP_GUEST_DOMAIN: ${XMPP_GUEST_DOMAIN:-guest.meet.jitsi}
XMPP_RECORDER_DOMAIN: ${XMPP_RECORDER_DOMAIN:-recorder.meet.jitsi}
XMPP_CROSS_DOMAIN: "true"
TZ: ${TZ:-Europe/Berlin}
JICOFO_AUTH_USER: focus
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-jicofo_secret}
JVB_AUTH_USER: jvb
JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-jvb_secret}
JIBRI_XMPP_USER: jibri
JIBRI_XMPP_PASSWORD: ${JIBRI_XMPP_PASSWORD:-jibri_secret}
JIBRI_RECORDER_USER: recorder
JIBRI_RECORDER_PASSWORD: ${JIBRI_RECORDER_PASSWORD:-recorder_secret}
LOG_LEVEL: ${XMPP_LOG_LEVEL:-warn}
PUBLIC_URL: ${JITSI_PUBLIC_URL:-https://macmini:8443}
ENABLE_AUTH: ${JITSI_ENABLE_AUTH:-false}
ENABLE_GUESTS: "true"
restart: unless-stopped
networks:
breakpilot-network:
aliases:
- xmpp.meet.jitsi
jitsi-jicofo:
image: jitsi/jicofo:stable-9823
container_name: bp-core-jitsi-jicofo
volumes:
- jitsi_jicofo_config:/config
environment:
XMPP_DOMAIN: ${XMPP_DOMAIN:-meet.jitsi}
XMPP_AUTH_DOMAIN: ${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}
XMPP_MUC_DOMAIN: ${XMPP_MUC_DOMAIN:-muc.meet.jitsi}
XMPP_INTERNAL_MUC_DOMAIN: ${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}
XMPP_SERVER: jitsi-xmpp
JICOFO_AUTH_USER: focus
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-jicofo_secret}
TZ: ${TZ:-Europe/Berlin}
ENABLE_AUTH: ${JITSI_ENABLE_AUTH:-false}
AUTH_TYPE: internal
ENABLE_AUTO_OWNER: "true"
depends_on:
- jitsi-xmpp
restart: unless-stopped
networks:
- breakpilot-network
jitsi-jvb:
image: jitsi/jvb:stable-9823
container_name: bp-core-jitsi-jvb
ports:
- "10000:10000/udp"
- "8080:8080"
volumes:
- jitsi_jvb_config:/config
environment:
XMPP_DOMAIN: ${XMPP_DOMAIN:-meet.jitsi}
XMPP_AUTH_DOMAIN: ${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}
XMPP_INTERNAL_MUC_DOMAIN: ${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}
XMPP_SERVER: jitsi-xmpp
JVB_AUTH_USER: jvb
JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-jvb_secret}
JVB_PORT: 10000
JVB_STUN_SERVERS: ${JVB_STUN_SERVERS:-stun.l.google.com:19302}
TZ: ${TZ:-Europe/Berlin}
PUBLIC_URL: ${JITSI_PUBLIC_URL:-https://macmini:8443}
COLIBRI_REST_ENABLED: "true"
ENABLE_COLIBRI_WEBSOCKET: "true"
depends_on:
- jitsi-xmpp
restart: unless-stopped
networks:
- breakpilot-network
jibri:
build:
context: ./docker/jibri
dockerfile: Dockerfile
container_name: bp-core-jibri
volumes:
- jibri_recordings:/recordings
- /dev/shm:/dev/shm
shm_size: 2gb
cap_add:
- SYS_ADMIN
- NET_BIND_SERVICE
environment:
XMPP_DOMAIN: ${XMPP_DOMAIN:-meet.jitsi}
XMPP_AUTH_DOMAIN: ${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}
XMPP_INTERNAL_MUC_DOMAIN: ${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}
XMPP_RECORDER_DOMAIN: ${XMPP_RECORDER_DOMAIN:-recorder.meet.jitsi}
XMPP_SERVER: jitsi-xmpp
XMPP_MUC_DOMAIN: ${XMPP_MUC_DOMAIN:-muc.meet.jitsi}
JIBRI_XMPP_USER: jibri
JIBRI_XMPP_PASSWORD: ${JIBRI_XMPP_PASSWORD:-jibri_secret}
JIBRI_RECORDER_USER: recorder
JIBRI_RECORDER_PASSWORD: ${JIBRI_RECORDER_PASSWORD:-recorder_secret}
JIBRI_BREWERY_MUC: JibriBrewery
JIBRI_RECORDING_DIR: /recordings
JIBRI_FINALIZE_SCRIPT: /finalize.sh
TZ: ${TZ:-Europe/Berlin}
DISPLAY: ":0"
RESOLUTION: "1920x1080"
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-breakpilot}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-breakpilot123}
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-recordings}
BACKEND_WEBHOOK_URL: http://backend-core:8000/api/recordings/webhook
depends_on:
- jitsi-xmpp
- minio
profiles:
- recording
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# DEVOPS & CI/CD
# =========================================================
@@ -728,88 +503,6 @@ services:
networks:
- breakpilot-network
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
container_name: bp-core-woodpecker-server
ports:
- "8090:8000"
volumes:
- woodpecker_data:/var/lib/woodpecker
environment:
WOODPECKER_OPEN: "true"
WOODPECKER_HOST: ${WOODPECKER_HOST:-http://macmini:8090}
WOODPECKER_ADMIN: ${WOODPECKER_ADMIN:-pilotadmin}
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: http://macmini:3003
WOODPECKER_GITEA_CLIENT: ${WOODPECKER_GITEA_CLIENT:-}
WOODPECKER_GITEA_SECRET: ${WOODPECKER_GITEA_SECRET:-}
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-woodpecker-secret}
WOODPECKER_DATABASE_DRIVER: sqlite3
WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
WOODPECKER_LOG_LEVEL: warn
WOODPECKER_PLUGINS_PRIVILEGED: "plugins/docker"
WOODPECKER_PLUGINS_TRUSTED_CLONE: "true"
extra_hosts:
- "macmini:192.168.178.100"
depends_on:
gitea:
condition: service_healthy
restart: unless-stopped
networks:
- breakpilot-network
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
container_name: bp-core-woodpecker-agent
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WOODPECKER_SERVER: woodpecker-server:9000
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-woodpecker-secret}
WOODPECKER_MAX_WORKFLOWS: "2"
WOODPECKER_LOG_LEVEL: warn
WOODPECKER_BACKEND: docker
DOCKER_HOST: unix:///var/run/docker.sock
WOODPECKER_BACKEND_DOCKER_EXTRA_HOSTS: "macmini:192.168.178.100"
WOODPECKER_BACKEND_DOCKER_NETWORK: breakpilot-network
depends_on:
- woodpecker-server
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# WORKFLOW ENGINE
# =========================================================
camunda:
image: camunda/camunda-bpm-platform:7.21.0
container_name: bp-core-camunda
ports:
- "8089:8080"
environment:
DB_DRIVER: org.postgresql.Driver
DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-breakpilot_db}
DB_USERNAME: ${POSTGRES_USER:-breakpilot}
DB_PASSWORD: ${POSTGRES_PASSWORD:-breakpilot123}
DB_VALIDATE_ON_BORROW: "true"
WAIT_FOR: postgres:5432
CAMUNDA_BPM_ADMIN_USER_ID: ${CAMUNDA_ADMIN_USER:-admin}
CAMUNDA_BPM_ADMIN_USER_PASSWORD: ${CAMUNDA_ADMIN_PASSWORD:-admin}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/camunda/api/engine"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 5
profiles:
- bpmn
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# DOCUMENTATION & UTILITIES
# =========================================================
@@ -844,45 +537,6 @@ services:
networks:
- breakpilot-network
# =========================================================
# VOICE SERVICE
# =========================================================
voice-service:
build:
context: ./voice-service
dockerfile: Dockerfile
container_name: bp-core-voice-service
platform: linux/arm64
expose:
- "8091"
volumes:
- voice_session_data:/app/data/sessions
environment:
PORT: 8091
DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
VALKEY_URL: redis://valkey:6379/0
KLAUSUR_SERVICE_URL: http://bp-lehrer-klausur-service:8086
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
OLLAMA_VOICE_MODEL: ${OLLAMA_VOICE_MODEL:-llama3.2}
ENVIRONMENT: ${ENVIRONMENT:-development}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8091/health"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# NIGHT SCHEDULER
# =========================================================
@@ -926,8 +580,6 @@ services:
environment:
NODE_ENV: production
BACKEND_URL: http://backend-core:8000
WOODPECKER_URL: http://bp-core-woodpecker-server:8000
WOODPECKER_TOKEN: ${WOODPECKER_TOKEN:-}
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -1182,7 +834,7 @@ services:
NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:30b-a3b}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:35b-a3b}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
@@ -1191,3 +843,20 @@ services:
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# LEVIS HOLZBAU - Kinder-Holzwerk-Website
# =========================================================
levis-holzbau:
build:
context: ./levis-holzbau
dockerfile: Dockerfile
container_name: bp-core-levis-holzbau
platform: linux/arm64
ports:
- "3013:3000"
environment:
NODE_ENV: production
restart: unless-stopped
networks:
- breakpilot-network

View File

@@ -38,7 +38,7 @@ BreakPilot ist eine modulare Bildungs- und Compliance-Plattform, aufgeteilt in d
│ Jitsi (5x) │ │ BreakPilot Drive│ │ │
│ Night Scheduler │ │ │ │ │
│ Health Agg. │ │ │ │ │
│ Gitea/Woodpecker│ │ │ │ │
│ Gitea Actions │ │ │ │ │
│ ERP (optional) │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
@@ -67,7 +67,7 @@ Stellt gemeinsam genutzte Infrastruktur bereit. Beide Teams (Lehrer + Compliance
| Frontend | Admin Core (Next.js, Port 3008) |
| Networking | Nginx (Reverse Proxy + TLS) |
| Monitoring | Health Aggregator |
| DevOps | Gitea, Woodpecker CI/CD, Night Scheduler, Mailpit |
| DevOps | Gitea, Gitea Actions (act_runner), Night Scheduler, Mailpit |
| Kommunikation | Jitsi Meet (5 Container), Synapse (Matrix Chat) |
| ERP | ERPNext (optional, 9 Container) |

View File

@@ -17,39 +17,32 @@
┌─────────────────────────────────────────────────────────────────┐
│ Entwickler-MacBook │
│ │
│ breakpilot-pwa/
│ ├── studio-v2/ (Next.js Frontend)
│ ├── admin-v2/ (Next.js Admin)
│ ├── backend/ (Python FastAPI)
│ ├── consent-service/ (Go Service)
│ ├── klausur-service/ (Python FastAPI) │
│ ├── voice-service/ (Python FastAPI) │
│ ├── ai-compliance-sdk/ (Go Service) │
│ breakpilot-core/
│ ├── admin-core/ (Next.js Admin, Port 3008)
│ ├── backend-core/ (Python FastAPI, Port 8000)
│ ├── consent-service/ (Go Service, Port 8081)
│ ├── billing-service/ (Go Service, Port 8083)
│ └── docs-src/ (MkDocs) │
│ │
$ ./sync-and-deploy.sh
git push → Gitea Actions (automatisch)
│ oder manuell: git push && ssh macmini docker compose build │
└───────────────────────────────┬─────────────────────────────────┘
rsync + SSH
git push origin main
┌─────────────────────────────────────────────────────────────────┐
│ Mac Mini Server
│ Mac Mini Server (bp-core-*)
│ │
│ Docker Compose │
│ ├── website (Port 3000)
│ ├── studio-v2 (Port 3001)
│ ├── admin-v2 (Port 3002) │
│ ├── backend (Port 8000) │
│ ├── admin-core (Port 3008)
│ ├── backend-core (Port 8000)
│ ├── consent-service (Port 8081) │
│ ├── klausur-service (Port 8086) │
│ ├── voice-service (Port 8082)
│ ├── ai-compliance-sdk (Port 8090)
│ ├── docs (Port 8009)
── postgres
│ ├── valkey (Redis) │
│ ├── qdrant │
│ └── minio │
│ ├── billing-service (Port 8083) │
│ ├── gitea (Port 3003) + gitea-runner (Gitea Actions)
│ ├── docs (Port 8011)
│ ├── postgres, valkey, qdrant, minio
── vault, nginx, night-scheduler, health
│ │
└─────────────────────────────────────────────────────────────────┘
```
@@ -67,8 +60,8 @@ rsync -avz --delete \
--exclude '__pycache__' \
--exclude 'venv' \
--exclude '.pytest_cache' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/
/Users/benjaminadmin/Projekte/breakpilot-core/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-core/
```
### 2. Container bauen
@@ -76,7 +69,7 @@ rsync -avz --delete \
```bash
# Einzelnen Service bauen
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
build --no-cache <service-name>"
# Beispiele:
@@ -88,7 +81,7 @@ ssh macmini "/usr/local/bin/docker compose \
```bash
# Container neu starten
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d <service-name>"
```
@@ -97,7 +90,7 @@ ssh macmini "/usr/local/bin/docker compose \
```bash
# Container-Logs anzeigen
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
logs -f <service-name>"
```
@@ -109,15 +102,15 @@ ssh macmini "/usr/local/bin/docker compose \
# 1. Sync
rsync -avz --delete \
--exclude 'node_modules' --exclude '.next' --exclude '.git' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/
/Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/
# 2. Build & Deploy
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
build --no-cache studio-v2 && \
/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d studio-v2"
```
@@ -126,10 +119,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash
# Build mit requirements.txt
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
build klausur-service && \
/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d klausur-service"
```
@@ -138,10 +131,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash
# Multi-stage Build (Go → Alpine)
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
build --no-cache consent-service && \
/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d consent-service"
```
@@ -150,10 +143,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash
# Build & Deploy
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
build --no-cache docs && \
/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d docs"
# Verfügbar unter: http://macmini:8009
@@ -178,10 +171,10 @@ curl -s http://macmini:8090/health
```bash
# Letzte 100 Zeilen
ssh macmini "docker logs --tail 100 breakpilot-pwa-backend-1"
ssh macmini "docker logs --tail 100 breakpilot-core-backend-1"
# Live-Logs folgen
ssh macmini "docker logs -f breakpilot-pwa-backend-1"
ssh macmini "docker logs -f breakpilot-core-backend-1"
```
## Rollback
@@ -190,15 +183,15 @@ ssh macmini "docker logs -f breakpilot-pwa-backend-1"
```bash
# 1. Aktuelles Image taggen
ssh macmini "docker tag breakpilot-pwa-backend:latest breakpilot-pwa-backend:backup"
ssh macmini "docker tag breakpilot-core-backend:latest breakpilot-core-backend:backup"
# 2. Altes Image deployen
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d backend"
# 3. Bei Problemen: Backup wiederherstellen
ssh macmini "docker tag breakpilot-pwa-backend:backup breakpilot-pwa-backend:latest"
ssh macmini "docker tag breakpilot-core-backend:backup breakpilot-core-backend:latest"
```
## Troubleshooting
@@ -207,13 +200,13 @@ ssh macmini "docker tag breakpilot-pwa-backend:backup breakpilot-pwa-backend:lat
```bash
# 1. Logs prüfen
ssh macmini "docker logs breakpilot-pwa-<service>-1"
ssh macmini "docker logs breakpilot-core-<service>-1"
# 2. Container manuell starten für Debug-Output
ssh macmini "docker compose -f .../docker-compose.yml run --rm <service>"
# 3. In Container einloggen
ssh macmini "docker exec -it breakpilot-pwa-<service>-1 /bin/sh"
ssh macmini "docker exec -it breakpilot-core-<service>-1 /bin/sh"
```
### Port bereits belegt
@@ -276,127 +269,57 @@ services:
- `.env` Datei auf dem Server pflegen
- Secrets über HashiCorp Vault (siehe unten)
## Woodpecker CI - Automatisierte OAuth Integration
## Gitea Actions
### Überblick
Die OAuth-Integration zwischen Woodpecker CI und Gitea ist **vollständig automatisiert**. Credentials werden in HashiCorp Vault gespeichert und bei Bedarf automatisch regeneriert.
BreakPilot Core nutzt **Gitea Actions** (GitHub Actions-kompatibel) als CI/CD-System. Der `act_runner` läuft als Container auf dem Mac Mini und führt Pipelines direkt bei Code-Push aus.
!!! info "Warum automatisiert?"
Diese Automatisierung ist eine DevSecOps Best Practice:
| Komponente | Container | Beschreibung |
|------------|-----------|--------------|
| Gitea | `bp-core-gitea` (Port 3003) | Git-Server + Actions-Trigger |
| Gitea Runner | `bp-core-gitea-runner` | Führt Actions-Workflows aus |
- **Infrastructure-as-Code**: Alles ist reproduzierbar
- **Disaster Recovery**: Verlorene Credentials können automatisch regeneriert werden
- **Security**: Secrets werden zentral in Vault verwaltet
- **Onboarding**: Neue Entwickler müssen nichts manuell konfigurieren
### Pipeline-Konfiguration
### Architektur
Workflows liegen im Repo unter `.gitea/workflows/`:
```
┌─────────────────────────────────────────────────────────────────┐
│ Mac Mini Server │
│ │
│ ┌───────────────┐ OAuth 2.0 ┌───────────────┐ │
│ │ Gitea │ ←─────────────────────────→│ Woodpecker │ │
│ │ (Port 3003) │ Client ID + Secret │ (Port 8090) │ │
│ └───────────────┘ └───────────────┘ │
│ │ │
│ │ OAuth App │ Env Vars│
│ (DB: oauth2_application) │ │
│ │ │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ HashiCorp Vault (Port 8200) │ │
│ │ │ │
│ │ secret/cicd/woodpecker: │ │
│ │ - gitea_client_id │ │
│ │ - gitea_client_secret │ │
│ │ │ │
│ │ secret/cicd/api-tokens: │ │
│ │ - gitea_token (für API-Zugriff) │ │
│ │ - woodpecker_token (für Pipeline-Trigger) │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```yaml
# .gitea/workflows/main.yml
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & Test
run: docker compose build
```
### Credentials-Speicherorte
| Ort | Pfad | Inhalt |
|-----|------|--------|
| **HashiCorp Vault** | `secret/cicd/woodpecker` | Client ID + Secret (Quelle der Wahrheit) |
| **.env Datei** | `WOODPECKER_GITEA_CLIENT/SECRET` | Für Docker Compose (aus Vault geladen) |
| **Gitea PostgreSQL** | `oauth2_application` Tabelle | OAuth App Registration (gehashtes Secret) |
### Troubleshooting: OAuth Fehler
Falls der Fehler "Client ID not registered" oder "user does not exist [uid: 0]" auftritt:
### Runner-Token erneuern
```bash
# Option 1: Automatisches Regenerieren (empfohlen)
./scripts/sync-woodpecker-credentials.sh --regenerate
# Runner-Token in Gitea UI generieren:
# https://macmini:3003 → Settings → Actions → Runners → New Runner
# Option 2: Manuelles Vorgehen
# 1. Credentials aus Vault laden
vault kv get secret/cicd/woodpecker
# Token in .env setzen:
GITEA_RUNNER_TOKEN=<neues_token>
# 2. .env aktualisieren
WOODPECKER_GITEA_CLIENT=<client_id>
WOODPECKER_GITEA_SECRET=<client_secret>
# 3. Zu Mac Mini synchronisieren
rsync .env macmini:~/Projekte/breakpilot-pwa/
# 4. Woodpecker neu starten
ssh macmini "cd ~/Projekte/breakpilot-pwa && \
docker compose up -d --force-recreate woodpecker-server"
# Runner neu starten:
ssh macmini "/usr/local/bin/docker compose \
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d --force-recreate gitea-runner"
```
### Das Sync-Script
Das Script `scripts/sync-woodpecker-credentials.sh` automatisiert den gesamten Prozess:
### Pipeline-Status prüfen
```bash
# Credentials aus Vault laden und .env aktualisieren
./scripts/sync-woodpecker-credentials.sh
# Runner-Logs
ssh macmini "/usr/local/bin/docker logs -f bp-core-gitea-runner"
# Neue Credentials generieren (OAuth App in Gitea + Vault + .env)
./scripts/sync-woodpecker-credentials.sh --regenerate
```
Was das Script macht:
1. **Liest** die aktuellen Credentials aus Vault
2. **Aktualisiert** die .env Datei automatisch
3. **Bei `--regenerate`**:
- Löscht alte OAuth Apps in Gitea
- Erstellt neue OAuth App mit neuem Client ID/Secret
- Speichert Credentials in Vault
- Aktualisiert .env
### Vault-Zugriff
```bash
# Vault Token (Development)
export VAULT_TOKEN=breakpilot-dev-token
# Credentials lesen
docker exec -e VAULT_TOKEN=$VAULT_TOKEN breakpilot-pwa-vault \
vault kv get secret/cicd/woodpecker
# Credentials setzen
docker exec -e VAULT_TOKEN=$VAULT_TOKEN breakpilot-pwa-vault \
vault kv put secret/cicd/woodpecker \
gitea_client_id="..." \
gitea_client_secret="..."
```
### Services neustarten nach Credentials-Änderung
```bash
# Wichtig: --force-recreate um neue Env Vars zu laden
cd /Users/benjaminadmin/Projekte/breakpilot-pwa
docker compose up -d --force-recreate woodpecker-server
# Logs prüfen
docker logs breakpilot-pwa-woodpecker-server --tail 50
# Laufende Jobs
ssh macmini "/usr/local/bin/docker exec bp-core-gitea-runner act_runner list"
```

View File

@@ -33,7 +33,7 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
| Pitch Deck | bp-core-pitch-deck | 3012 | Investor-Praesentation |
| Mailpit | bp-core-mailpit | 8025 | E-Mail (Entwicklung) |
| Gitea | bp-core-gitea | 3003 | Git-Server |
| Woodpecker | bp-core-woodpecker-server | 8090 | CI/CD |
| Gitea Runner | bp-core-gitea-runner | - | CI/CD (Gitea Actions) |
| Jitsi (5 Container) | bp-core-jitsi-* | 8443 | Videokonferenzen |
## Nginx Routing-Tabelle

View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
Dockerfile
.dockerignore

27
levis-holzbau/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500;600;700&family=Nunito:wght@400;600;700&display=swap');
html {
scroll-behavior: smooth;
}
body {
font-family: 'Nunito', sans-serif;
background-color: #FDF8F0;
color: #2C2C2C;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Quicksand', sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import './globals.css'
import { Navbar } from '@/components/Navbar'
import { Footer } from '@/components/Footer'
export const metadata: Metadata = {
title: 'LEVIS Holzbau — Kinder-Holzwerkstatt',
description: 'Lerne Holzfiguren schnitzen und kleine Holzprojekte bauen! Kindgerechte Anleitungen fuer junge Holzwerker.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { motion } from 'framer-motion'
import { Hammer, TreePine, ShieldCheck } from 'lucide-react'
import { HeroSection } from '@/components/HeroSection'
import { ProjectCard } from '@/components/ProjectCard'
import { projects } from '@/lib/projects'
const features = [
{
icon: Hammer,
title: 'Schnitzen',
description: 'Lerne mit Schnitzmesser und Holz umzugehen und forme eigene Figuren.',
color: 'bg-primary/10 text-primary',
},
{
icon: TreePine,
title: 'Bauen',
description: 'Saege, leime und nagle — baue nuetzliche Dinge aus Holz!',
color: 'bg-secondary/10 text-secondary',
},
{
icon: ShieldCheck,
title: 'Sicherheit',
description: 'Jedes Projekt zeigt dir, wie du sicher mit Werkzeug arbeitest.',
color: 'bg-accent/10 text-accent',
},
]
export default function HomePage() {
const featured = projects.slice(0, 4)
return (
<>
<HeroSection />
{/* Features */}
<section className="max-w-6xl mx-auto px-4 py-16">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{features.map((f, i) => (
<motion.div
key={f.title}
className="bg-white rounded-2xl p-6 shadow-sm border border-primary/5 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className={`w-14 h-14 rounded-xl ${f.color} flex items-center justify-center mx-auto mb-4`}>
<f.icon className="w-7 h-7" />
</div>
<h3 className="font-heading font-bold text-lg mb-2">{f.title}</h3>
<p className="text-sm text-dark/60">{f.description}</p>
</motion.div>
))}
</div>
</section>
{/* Popular Projects */}
<section className="max-w-6xl mx-auto px-4 pb-16">
<h2 className="font-heading font-bold text-3xl text-center mb-8">
Beliebte Projekte
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{featured.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,120 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Clock, Wrench, Package } from 'lucide-react'
import { projects, getProject, getRelatedProjects } from '@/lib/projects'
import { DifficultyBadge } from '@/components/DifficultyBadge'
import { AgeBadge } from '@/components/AgeBadge'
import { StepCard } from '@/components/StepCard'
import { SafetyTip } from '@/components/SafetyTip'
import { ToolIcon } from '@/components/ToolIcon'
import { ProjectIllustration } from '@/components/ProjectIllustration'
import { ProjectCard } from '@/components/ProjectCard'
export function generateStaticParams() {
return projects.map((p) => ({ slug: p.slug }))
}
export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const project = getProject(slug)
if (!project) notFound()
const related = getRelatedProjects(slug)
return (
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back */}
<Link href="/projekte" className="inline-flex items-center gap-1 text-accent hover:underline mb-6 text-sm font-semibold">
<ArrowLeft className="w-4 h-4" /> Alle Projekte
</Link>
{/* Hero */}
<div className="bg-white rounded-2xl shadow-sm border border-primary/5 overflow-hidden mb-8">
<div className="bg-cream p-10 flex items-center justify-center">
<ProjectIllustration slug={project.slug} size={180} />
</div>
<div className="p-6 sm:p-8">
<div className="flex flex-wrap items-center gap-3 mb-3">
<AgeBadge range={project.ageRange} />
<DifficultyBadge level={project.difficulty} />
<span className="flex items-center gap-1 text-sm text-dark/50">
<Clock className="w-4 h-4" /> {project.duration}
</span>
</div>
<h1 className="font-heading font-bold text-3xl sm:text-4xl mb-3">{project.name}</h1>
<p className="text-dark/70 text-lg leading-relaxed">{project.description}</p>
</div>
</div>
{/* Tools & Materials */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-white rounded-2xl p-6 border border-primary/5">
<h2 className="font-heading font-bold text-lg flex items-center gap-2 mb-4">
<Wrench className="w-5 h-5 text-primary" /> Werkzeuge
</h2>
<ul className="space-y-2">
{project.tools.map((t) => (
<li key={t} className="flex items-center gap-2 text-sm">
<ToolIcon name={t} />
{t}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl p-6 border border-primary/5">
<h2 className="font-heading font-bold text-lg flex items-center gap-2 mb-4">
<Package className="w-5 h-5 text-secondary" /> Material
</h2>
<ul className="space-y-2">
{project.materials.map((m) => (
<li key={m} className="flex items-center gap-2 text-sm">
<span className="w-2 h-2 rounded-full bg-secondary flex-shrink-0" />
{m}
</li>
))}
</ul>
</div>
</div>
{/* Safety */}
<div className="space-y-3 mb-10">
<h2 className="font-heading font-bold text-xl mb-2">Sicherheitshinweise</h2>
{project.safetyTips.map((tip) => (
<SafetyTip key={tip}>{tip}</SafetyTip>
))}
</div>
{/* Steps */}
<div className="mb-10">
<h2 className="font-heading font-bold text-xl mb-6">Schritt fuer Schritt</h2>
<div className="space-y-0">
{project.steps.map((step, i) => (
<StepCard key={i} step={step} index={i} />
))}
</div>
</div>
{/* Skills */}
<div className="bg-secondary/5 rounded-2xl p-6 mb-12">
<h2 className="font-heading font-bold text-xl mb-3">Was du lernst</h2>
<div className="flex flex-wrap gap-2">
{project.skills.map((s) => (
<span key={s} className="px-3 py-1.5 bg-secondary/10 text-secondary rounded-full text-sm font-semibold">
{s}
</span>
))}
</div>
</div>
{/* Related */}
<div>
<h2 className="font-heading font-bold text-xl mb-6">Aehnliche Projekte</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{related.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { ProjectCard } from '@/components/ProjectCard'
import { projects } from '@/lib/projects'
const filters = [
{ label: 'Alle', value: 0 },
{ label: 'Anfaenger', value: 1 },
{ label: 'Fortgeschritten', value: 2 },
{ label: 'Profi', value: 3 },
]
export default function ProjektePage() {
const [filter, setFilter] = useState(0)
const filtered = filter === 0 ? projects : projects.filter((p) => p.difficulty === filter)
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-10"
>
<h1 className="font-heading font-bold text-4xl mb-3">Alle Projekte</h1>
<p className="text-dark/60 text-lg">Waehle ein Projekt und leg los!</p>
</motion.div>
{/* Filter */}
<div className="flex justify-center gap-2 mb-10">
{filters.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value as 0 | 1 | 2 | 3)}
className={`px-4 py-2 rounded-xl font-semibold text-sm transition-colors ${
filter === f.value
? 'bg-primary text-white'
: 'bg-white text-dark/60 hover:bg-primary/5'
}`}
>
{f.label}
</button>
))}
</div>
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
{filtered.length === 0 && (
<p className="text-center text-dark/40 mt-12">Keine Projekte in dieser Kategorie.</p>
)}
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { motion } from 'framer-motion'
import { ShieldCheck, Eye, Hand, Scissors, AlertTriangle, Users } from 'lucide-react'
import { SafetyTip } from '@/components/SafetyTip'
const rules = [
{ icon: Users, title: 'Immer mit Erwachsenen', text: 'Bei Saegen, Bohren und Schnitzen muss immer ein Erwachsener dabei sein.' },
{ icon: Hand, title: 'Vom Koerper weg', text: 'Schnitze, saege und schneide immer vom Koerper weg. So kannst du dich nicht verletzen.' },
{ icon: Eye, title: 'Schutzbrille tragen', text: 'Beim Saegen und Schleifen fliegen Spaene — eine Schutzbrille schuetzt deine Augen.' },
{ icon: Scissors, title: 'Werkzeug richtig halten', text: 'Greife Werkzeuge immer am Griff. Trage Messer und Saegen mit der Spitze nach unten.' },
{ icon: AlertTriangle, title: 'Aufgeraeumter Arbeitsplatz', text: 'Raeume Werkzeug nach dem Benutzen weg. Ein ordentlicher Platz ist ein sicherer Platz!' },
{ icon: ShieldCheck, title: 'Scharfes Werkzeug', text: 'Klingt komisch, aber: Scharfe Messer sind sicherer als stumpfe, weil du weniger Kraft brauchst.' },
]
const toolGuides = [
{ name: 'Schnitzmesser', age: 'Ab 6 Jahren (mit Hilfe)', tips: ['Immer vom Koerper weg schnitzen', 'Nach dem Benutzen zuklappen', 'Weiches Holz (Linde) verwenden'] },
{ name: 'Handsaege', age: 'Ab 7 Jahren (mit Hilfe)', tips: ['Holz immer fest einspannen', 'Langsam und gleichmaessig saegen', 'Nicht auf die Klinge druecken'] },
{ name: 'Hammer', age: 'Ab 5 Jahren', tips: ['Leichten Kinderhammer verwenden', 'Naegel mit Zange halten, nie mit Fingern', 'Auf stabile Unterlage achten'] },
{ name: 'Schleifpapier', age: 'Ab 5 Jahren', tips: ['Immer in eine Richtung schleifen', 'Staub nicht einatmen', 'Erst grob, dann fein'] },
{ name: 'Holzleim', age: 'Ab 5 Jahren', tips: ['Nicht giftig, aber nicht essen', 'Duenn auftragen reicht', 'Mindestens 1 Stunde trocknen lassen'] },
]
export default function SicherheitPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12"
>
<div className="w-16 h-16 bg-warning/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<ShieldCheck className="w-8 h-8 text-warning" />
</div>
<h1 className="font-heading font-bold text-4xl mb-3">Sicherheit geht vor!</h1>
<p className="text-dark/60 text-lg max-w-2xl mx-auto">
Holzarbeiten macht riesig Spass aber nur, wenn du sicher arbeitest.
Hier findest du die wichtigsten Regeln.
</p>
</motion.div>
{/* Rules Grid */}
<section className="mb-16">
<h2 className="font-heading font-bold text-2xl mb-6">Die goldenen Regeln</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{rules.map((r, i) => (
<motion.div
key={r.title}
className="bg-white rounded-2xl p-5 border border-primary/5 flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<div className="w-10 h-10 bg-warning/10 rounded-xl flex items-center justify-center flex-shrink-0">
<r.icon className="w-5 h-5 text-warning" />
</div>
<div>
<h3 className="font-heading font-bold mb-1">{r.title}</h3>
<p className="text-sm text-dark/60">{r.text}</p>
</div>
</motion.div>
))}
</div>
</section>
{/* Tool Guides */}
<section className="mb-16">
<h2 className="font-heading font-bold text-2xl mb-6">Werkzeug-Guide</h2>
<div className="space-y-4">
{toolGuides.map((tool) => (
<div key={tool.name} className="bg-white rounded-2xl p-5 border border-primary/5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-heading font-bold text-lg">{tool.name}</h3>
<span className="text-xs font-semibold bg-accent/10 text-accent px-2.5 py-1 rounded-full">{tool.age}</span>
</div>
<ul className="space-y-1.5">
{tool.tips.map((tip) => (
<li key={tip} className="flex items-center gap-2 text-sm text-dark/70">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
{tip}
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* Parents */}
<section>
<h2 className="font-heading font-bold text-2xl mb-4">Hinweise fuer Eltern</h2>
<div className="space-y-3">
<SafetyTip>Beaufsichtigen Sie Ihr Kind bei allen Projekten besonders beim Umgang mit Schneidwerkzeugen.</SafetyTip>
<SafetyTip>Stellen Sie altersgerechtes Werkzeug bereit. Kinderschnitzmesser haben abgerundete Spitzen.</SafetyTip>
<SafetyTip>Richten Sie einen festen Arbeitsplatz ein idealerweise auf einer stabilen Werkbank oder einem alten Tisch.</SafetyTip>
<SafetyTip>Leinoel und Acrylfarben sind fuer Kinder unbedenklich. Vermeiden Sie Lacke mit Loesungsmitteln.</SafetyTip>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { motion } from 'framer-motion'
import { TreePine, Heart, Sparkles, Users } from 'lucide-react'
import Link from 'next/link'
const reasons = [
{ icon: Sparkles, title: 'Kreativitaet', text: 'Du kannst dir selbst ausdenken, was du baust — und es dann wirklich machen!' },
{ icon: Heart, title: 'Stolz', text: 'Wenn du etwas mit deinen eigenen Haenden baust, macht dich das richtig stolz.' },
{ icon: TreePine, title: 'Natur', text: 'Holz ist ein natuerliches Material. Du lernst die Natur besser kennen.' },
{ icon: Users, title: 'Zusammen', text: 'Holzarbeiten macht zusammen mit Freunden oder der Familie am meisten Spass!' },
]
export default function UeberPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12"
>
<h1 className="font-heading font-bold text-4xl mb-3">Ueber LEVIS Holzbau</h1>
<p className="text-dark/60 text-lg max-w-2xl mx-auto">
Wir zeigen dir, wie du aus einem einfachen Stueck Holz etwas Tolles machen kannst!
</p>
</motion.div>
{/* Story */}
<div className="bg-white rounded-2xl p-6 sm:p-8 border border-primary/5 mb-12">
<h2 className="font-heading font-bold text-2xl mb-4">Was ist LEVIS Holzbau?</h2>
<div className="space-y-4 text-dark/70 leading-relaxed">
<p>
LEVIS Holzbau ist deine Online-Holzwerkstatt! Hier findest du Anleitungen fuer tolle Projekte
aus Holz vom einfachen Zauberstab bis zum echten Vogelhaus.
</p>
<p>
Jedes Projekt erklaert dir Schritt fuer Schritt, was du tun musst. Du siehst welches Werkzeug
und Material du brauchst, und wir zeigen dir immer, worauf du bei der Sicherheit achten musst.
</p>
<p>
Egal ob du 6 oder 12 Jahre alt bist fuer jedes Alter gibt es passende Projekte.
Faengst du gerade erst an? Dann probier den Zauberstab oder die Nagelbilder. Bist du
schon ein Profi? Dann trau dich an den Fliegenpilz!
</p>
</div>
</div>
{/* Why woodworking */}
<h2 className="font-heading font-bold text-2xl mb-6 text-center">Warum Holzarbeiten Spass macht</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-12">
{reasons.map((r, i) => (
<motion.div
key={r.title}
className="bg-white rounded-2xl p-5 border border-primary/5 flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="w-10 h-10 bg-secondary/10 rounded-xl flex items-center justify-center flex-shrink-0">
<r.icon className="w-5 h-5 text-secondary" />
</div>
<div>
<h3 className="font-heading font-bold mb-1">{r.title}</h3>
<p className="text-sm text-dark/60">{r.text}</p>
</div>
</motion.div>
))}
</div>
{/* CTA */}
<div className="text-center bg-gradient-to-br from-primary/5 to-secondary/5 rounded-2xl p-8">
<h2 className="font-heading font-bold text-2xl mb-3">Bereit loszulegen?</h2>
<p className="text-dark/60 mb-6">Schau dir unsere Projekte an und such dir eins aus!</p>
<Link
href="/projekte"
className="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-white font-bold px-8 py-3 rounded-2xl transition-colors"
>
Zu den Projekten
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export function AgeBadge({ range }: { range: string }) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-accent/10 text-accent">
{range} Jahre
</span>
)
}

View File

@@ -0,0 +1,15 @@
import { Hammer } from 'lucide-react'
export function DifficultyBadge({ level }: { level: 1 | 2 | 3 }) {
const labels = ['Anfaenger', 'Fortgeschritten', 'Profi']
return (
<div className="flex items-center gap-1" title={labels[level - 1]}>
{Array.from({ length: 3 }).map((_, i) => (
<Hammer
key={i}
className={`w-4 h-4 ${i < level ? 'text-primary' : 'text-gray-300'}`}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Heart } from 'lucide-react'
import { Logo } from './Logo'
export function Footer() {
return (
<footer className="bg-white border-t border-primary/10 mt-16">
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<Logo size={32} />
<p className="text-sm text-dark/50 flex items-center gap-1">
Gemacht mit <Heart className="w-4 h-4 text-red-400 fill-red-400" /> fuer junge Holzwerker
</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { motion } from 'framer-motion'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Logo } from './Logo'
export function HeroSection() {
return (
<section className="relative overflow-hidden bg-gradient-to-br from-cream via-white to-primary/5 py-16 sm:py-24">
<div className="max-w-6xl mx-auto px-4 flex flex-col lg:flex-row items-center gap-12">
<motion.div
className="flex-1 text-center lg:text-left"
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
>
<div className="flex justify-center lg:justify-start mb-6">
<Logo size={64} />
</div>
<h1 className="font-heading font-bold text-4xl sm:text-5xl text-dark mb-4 text-balance">
Willkommen in der{' '}
<span className="text-primary">Holzwerkstatt</span>!
</h1>
<p className="text-lg text-dark/70 mb-8 max-w-lg mx-auto lg:mx-0">
Hier lernst du, wie man aus Holz tolle Sachen baut und schnitzt.
Vom Zauberstab bis zum Vogelhaus fuer jeden ist etwas dabei!
</p>
<Link
href="/projekte"
className="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-white font-bold px-8 py-4 rounded-2xl text-lg transition-colors shadow-lg shadow-primary/20"
>
Entdecke Projekte <ArrowRight className="w-5 h-5" />
</Link>
</motion.div>
<motion.div
className="flex-1 flex justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<HeroIllustration />
</motion.div>
</div>
</section>
)
}
function HeroIllustration() {
return (
<svg width="320" height="280" viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Workbench */}
<rect x="40" y="180" width="240" height="12" rx="4" fill="#D4915C" />
<rect x="60" y="192" width="12" height="60" rx="2" fill="#C4814C" />
<rect x="248" y="192" width="12" height="60" rx="2" fill="#C4814C" />
<rect x="50" y="248" width="32" height="8" rx="2" fill="#C4814C" />
<rect x="238" y="248" width="32" height="8" rx="2" fill="#C4814C" />
{/* Wood pieces on bench */}
<rect x="80" y="164" width="60" height="16" rx="3" fill="#E8A96C" />
<rect x="85" y="168" width="50" height="2" rx="1" fill="#D4915C" opacity="0.3" />
{/* Small boat */}
<path d="M180 170 Q200 155 220 170 Q200 178 180 170Z" fill="#E8A96C" />
<line x1="200" y1="148" x2="200" y2="170" stroke="#8B6F47" strokeWidth="2" />
<path d="M200 148 L215 158 L200 165Z" fill="#FF6B6B" opacity="0.8" />
{/* Hammer */}
<rect x="240" y="155" width="4" height="25" rx="1" fill="#8B6F47" transform="rotate(-20 240 155)" />
<rect x="232" y="148" width="20" height="10" rx="2" fill="#888" transform="rotate(-20 240 155)" />
{/* Tree background */}
<circle cx="60" cy="100" r="35" fill="#4CAF50" opacity="0.3" />
<circle cx="50" cy="85" r="25" fill="#4CAF50" opacity="0.4" />
<circle cx="70" cy="90" r="28" fill="#4CAF50" opacity="0.35" />
<rect x="56" y="120" width="8" height="60" rx="2" fill="#8B6F47" opacity="0.4" />
{/* Tree right */}
<circle cx="270" cy="110" r="30" fill="#4CAF50" opacity="0.25" />
<circle cx="280" cy="95" r="22" fill="#4CAF50" opacity="0.35" />
<rect x="268" y="130" width="6" height="50" rx="2" fill="#8B6F47" opacity="0.3" />
{/* Sun */}
<circle cx="280" cy="40" r="20" fill="#F5A623" opacity="0.3" />
<circle cx="280" cy="40" r="14" fill="#F5A623" opacity="0.5" />
{/* Sawdust particles */}
<circle cx="120" cy="175" r="1.5" fill="#D4915C" opacity="0.5" />
<circle cx="130" cy="172" r="1" fill="#D4915C" opacity="0.4" />
<circle cx="115" cy="178" r="1.2" fill="#D4915C" opacity="0.3" />
<circle cx="135" cy="176" r="0.8" fill="#D4915C" opacity="0.6" />
</svg>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
export function Logo({ size = 40 }: { size?: number }) {
return (
<div className="flex items-center gap-2">
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Wood log */}
<ellipse cx="24" cy="30" rx="16" ry="10" fill="#D4915C" />
<ellipse cx="24" cy="30" rx="16" ry="10" fill="url(#wood-grain)" opacity="0.3" />
<ellipse cx="24" cy="27" rx="16" ry="10" fill="#E8A96C" />
{/* Tree rings */}
<ellipse cx="24" cy="27" rx="10" ry="6" fill="none" stroke="#D4915C" strokeWidth="1" />
<ellipse cx="24" cy="27" rx="6" ry="3.5" fill="none" stroke="#D4915C" strokeWidth="0.8" />
<ellipse cx="24" cy="27" rx="2.5" ry="1.5" fill="#D4915C" />
{/* Saw */}
<rect x="30" y="6" width="3" height="18" rx="1" fill="#888" transform="rotate(15 30 6)" />
<rect x="29" y="4" width="5" height="5" rx="1" fill="#F5A623" transform="rotate(15 30 6)" />
{/* Saw teeth */}
<path d="M31 10 L34 11 L31 12 L34 13 L31 14 L34 15 L31 16 L34 17 L31 18 L34 19 L31 20" stroke="#666" strokeWidth="0.5" fill="none" transform="rotate(15 30 6)" />
{/* Leaf */}
<path d="M12 8 Q16 2 20 8 Q16 10 12 8Z" fill="#4CAF50" />
<line x1="16" y1="5" x2="16" y2="9" stroke="#388E3C" strokeWidth="0.5" />
<defs>
<pattern id="wood-grain" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="4" y2="4" stroke="#C4814C" strokeWidth="0.3" />
</pattern>
</defs>
</svg>
<div className="flex flex-col leading-tight">
<span className="font-heading font-bold text-xl text-primary">LEVIS</span>
<span className="font-heading text-sm text-dark/70 -mt-1">Holzbau</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Logo } from './Logo'
const links = [
{ href: '/', label: 'Start' },
{ href: '/projekte', label: 'Projekte' },
{ href: '/sicherheit', label: 'Sicherheit' },
{ href: '/ueber', label: 'Ueber LEVIS' },
]
export function Navbar() {
const pathname = usePathname()
return (
<nav className="bg-white/80 backdrop-blur-sm border-b border-primary/10 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/">
<Logo />
</Link>
<div className="flex items-center gap-1 sm:gap-4">
{links.map(({ href, label }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href)
return (
<Link
key={href}
href={href}
className={`px-3 py-2 rounded-xl text-sm sm:text-base font-semibold transition-colors ${
isActive
? 'bg-primary/10 text-primary'
: 'text-dark/70 hover:text-primary hover:bg-primary/5'
}`}
>
{label}
</Link>
)
})}
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { Clock } from 'lucide-react'
import { Project } from '@/lib/types'
import { DifficultyBadge } from './DifficultyBadge'
import { AgeBadge } from './AgeBadge'
import { ProjectIllustration } from './ProjectIllustration'
export function ProjectCard({ project }: { project: Project }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
transition={{ duration: 0.3 }}
>
<Link href={`/projekte/${project.slug}`} className="block">
<div className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden border border-primary/5">
<div className="bg-cream p-6 flex items-center justify-center h-44">
<ProjectIllustration slug={project.slug} size={120} />
</div>
<div className="p-5">
<h3 className="font-heading font-bold text-lg mb-2">{project.name}</h3>
<p className="text-sm text-dark/60 mb-3 line-clamp-2">{project.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AgeBadge range={project.ageRange} />
<DifficultyBadge level={project.difficulty} />
</div>
<div className="flex items-center gap-1 text-xs text-dark/40">
<Clock className="w-3.5 h-3.5" />
{project.duration}
</div>
</div>
</div>
</div>
</Link>
</motion.div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
export function ProjectIllustration({ slug, size = 100 }: { slug: string; size?: number }) {
const illustrations: Record<string, React.ReactNode> = {
zauberstab: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<rect x="20" y="80" width="60" height="4" rx="2" fill="#D4915C" transform="rotate(-45 50 50)" />
<circle cx="28" cy="28" r="4" fill="#F5A623" opacity="0.6" />
<circle cx="22" cy="35" r="2.5" fill="#FFC107" opacity="0.5" />
<circle cx="35" cy="22" r="2" fill="#FFC107" opacity="0.4" />
<path d="M25 25 L20 18 M25 25 L32 20 M25 25 L22 32" stroke="#F5A623" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="26" cy="26" r="6" fill="none" stroke="#F5A623" strokeWidth="0.5" opacity="0.3" />
</svg>
),
untersetzer: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<ellipse cx="50" cy="55" rx="32" ry="8" fill="#C4814C" />
<ellipse cx="50" cy="50" rx="32" ry="8" fill="#E8A96C" />
<ellipse cx="50" cy="50" rx="22" ry="5" fill="none" stroke="#D4915C" strokeWidth="0.8" />
<ellipse cx="50" cy="50" rx="12" ry="2.8" fill="none" stroke="#D4915C" strokeWidth="0.6" />
<circle cx="42" cy="48" r="3" fill="#FF6B6B" opacity="0.5" />
<circle cx="55" cy="46" r="2" fill="#4CAF50" opacity="0.5" />
<circle cx="48" cy="53" r="2.5" fill="#2196F3" opacity="0.4" />
</svg>
),
nagelbilder: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<rect x="20" y="20" width="60" height="60" rx="4" fill="#E8A96C" />
{/* Nails forming a star */}
<circle cx="50" cy="30" r="2" fill="#888" />
<circle cx="35" cy="45" r="2" fill="#888" />
<circle cx="65" cy="45" r="2" fill="#888" />
<circle cx="40" cy="65" r="2" fill="#888" />
<circle cx="60" cy="65" r="2" fill="#888" />
{/* String */}
<path d="M50 30 L35 45 L60 65 L40 65 L65 45 Z" stroke="#FF6B6B" strokeWidth="1.5" fill="none" />
<path d="M50 30 L40 65 M50 30 L60 65 M35 45 L65 45" stroke="#4CAF50" strokeWidth="1" fill="none" opacity="0.6" />
</svg>
),
bleistiftbox: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<path d="M25 75 L25 35 L75 35 L75 75 Z" fill="#E8A96C" />
<path d="M25 35 L30 30 L80 30 L75 35 Z" fill="#D4915C" />
<path d="M75 35 L80 30 L80 70 L75 75 Z" fill="#C4814C" />
{/* Pencils */}
<rect x="35" y="20" width="4" height="30" rx="1" fill="#FFC107" />
<polygon points="35,50 39,50 37,55" fill="#2C2C2C" />
<rect x="45" y="15" width="4" height="32" rx="1" fill="#2196F3" />
<polygon points="45,47 49,47 47,52" fill="#2C2C2C" />
<rect x="55" y="22" width="4" height="28" rx="1" fill="#FF6B6B" />
<polygon points="55,50 59,50 57,55" fill="#2C2C2C" />
</svg>
),
segelboot: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<path d="M20 65 Q50 55 80 65 Q50 72 20 65Z" fill="#E8A96C" />
<line x1="50" y1="25" x2="50" y2="62" stroke="#8B6F47" strokeWidth="2.5" />
<path d="M50 25 L70 50 L50 58Z" fill="white" stroke="#ddd" strokeWidth="0.5" />
<path d="M50 30 L38 52 L50 58Z" fill="#FF6B6B" opacity="0.8" />
{/* Water */}
<path d="M10 72 Q25 68 40 72 Q55 76 70 72 Q85 68 100 72" stroke="#2196F3" strokeWidth="1.5" fill="none" opacity="0.4" />
<path d="M5 78 Q20 74 35 78 Q50 82 65 78 Q80 74 95 78" stroke="#2196F3" strokeWidth="1" fill="none" opacity="0.3" />
</svg>
),
vogelhaus: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Roof */}
<path d="M25 45 L50 25 L75 45 Z" fill="#C4814C" />
{/* Body */}
<rect x="30" y="45" width="40" height="35" fill="#E8A96C" />
{/* Entrance hole */}
<circle cx="50" cy="58" r="6" fill="#5D4037" />
{/* Perch */}
<rect x="47" y="65" width="6" height="2" rx="1" fill="#8B6F47" />
<rect x="48" y="67" width="4" height="6" rx="1" fill="#8B6F47" />
{/* Post */}
<rect x="46" y="80" width="8" height="15" rx="1" fill="#8B6F47" />
{/* Bird */}
<ellipse cx="68" cy="40" rx="5" ry="4" fill="#FF6B6B" />
<circle cx="71" cy="38" r="1.5" fill="#2C2C2C" />
<path d="M73 39 L77 38.5" stroke="#F5A623" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
'holztier-igel': (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Body */}
<ellipse cx="50" cy="60" rx="25" ry="18" fill="#C4814C" />
{/* Head */}
<ellipse cx="28" cy="58" rx="10" ry="9" fill="#D4915C" />
{/* Nose */}
<circle cx="20" cy="57" r="2" fill="#2C2C2C" />
{/* Eye */}
<circle cx="25" cy="54" r="1.5" fill="#2C2C2C" />
<circle cx="25.5" cy="53.5" r="0.5" fill="white" />
{/* Spines */}
{[0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150].map((angle, i) => {
const rad = (angle - 30) * Math.PI / 180
const x1 = 55 + Math.cos(rad) * 20
const y1 = 52 + Math.sin(rad) * 14
const x2 = 55 + Math.cos(rad) * 30
const y2 = 52 + Math.sin(rad) * 22
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#8B6F47" strokeWidth="2" strokeLinecap="round" />
})}
{/* Feet */}
<ellipse cx="35" cy="75" rx="4" ry="2" fill="#D4915C" />
<ellipse cx="60" cy="75" rx="4" ry="2" fill="#D4915C" />
</svg>
),
'schnitzfigur-pilz': (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Stem */}
<path d="M40 55 Q38 75 42 85 L58 85 Q62 75 60 55 Z" fill="#F5F5DC" />
<ellipse cx="50" cy="85" rx="10" ry="3" fill="#E8E0C8" />
{/* Cap */}
<ellipse cx="50" cy="48" rx="28" ry="18" fill="#D32F2F" />
<ellipse cx="50" cy="55" rx="22" ry="5" fill="#E8A96C" />
{/* White dots */}
<circle cx="38" cy="40" r="3" fill="white" opacity="0.9" />
<circle cx="55" cy="35" r="2.5" fill="white" opacity="0.9" />
<circle cx="48" cy="45" r="2" fill="white" opacity="0.8" />
<circle cx="62" cy="42" r="2.5" fill="white" opacity="0.85" />
<circle cx="42" cy="50" r="1.8" fill="white" opacity="0.7" />
{/* Grass */}
<path d="M30 85 Q32 78 34 85" stroke="#4CAF50" strokeWidth="1.5" fill="none" />
<path d="M65 85 Q67 79 69 85" stroke="#4CAF50" strokeWidth="1.5" fill="none" />
<path d="M72 85 Q73 80 75 85" stroke="#4CAF50" strokeWidth="1" fill="none" opacity="0.6" />
</svg>
),
}
return <>{illustrations[slug] || illustrations.zauberstab}</>
}

View File

@@ -0,0 +1,10 @@
import { AlertTriangle } from 'lucide-react'
export function SafetyTip({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 bg-warning/10 border border-warning/30 rounded-xl p-4">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm font-medium">{children}</p>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Step } from '@/lib/types'
export function StepCard({ step, index }: { step: Step; index: number }) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
{index + 1}
</div>
<div className="flex-1 pb-8 border-l-2 border-primary/20 pl-6 -ml-5 mt-5">
<h3 className="font-heading font-bold text-lg mb-1">{step.title}</h3>
<p className="text-dark/70 leading-relaxed">{step.description}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Hammer, Scissors, Ruler, Paintbrush, Wrench } from 'lucide-react'
const iconMap: Record<string, React.ElementType> = {
hammer: Hammer,
schnitzmesser: Scissors,
lineal: Ruler,
pinsel: Paintbrush,
}
export function ToolIcon({ name }: { name: string }) {
const key = name.toLowerCase()
const Icon = Object.entries(iconMap).find(([k]) => key.includes(k))?.[1] || Wrench
return <Icon className="w-5 h-5 text-primary" />
}

View File

@@ -0,0 +1,15 @@
export const fadeInUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5 },
}
export const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
export const scaleIn = {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.4 },
}

View File

@@ -0,0 +1,214 @@
import { Project } from './types'
export const projects: Project[] = [
{
slug: 'zauberstab',
name: 'Zauberstab',
description: 'Schnitze deinen eigenen magischen Zauberstab aus einem Ast! Mit Schleifpapier und etwas Farbe wird daraus ein echtes Zauberwerkzeug.',
ageRange: '6-8',
difficulty: 1,
duration: '45 Minuten',
tools: ['Schnitzmesser (kindersicher)', 'Schleifpapier (fein)', 'Pinsel'],
materials: ['1 gerader Ast (ca. 30cm, daumendicke)', 'Acrylfarben', 'Klarlack'],
steps: [
{ title: 'Ast aussuchen', description: 'Such dir einen geraden, trockenen Ast. Er sollte ungefaehr so lang sein wie dein Unterarm und gut in deiner Hand liegen.' },
{ title: 'Rinde entfernen', description: 'Zieh vorsichtig die Rinde ab. Wenn sie nicht leicht abgeht, hilft ein Erwachsener mit dem Schnitzmesser.' },
{ title: 'Schleifen', description: 'Schleife den Ast mit dem Schleifpapier glatt. Immer in eine Richtung schleifen — wie beim Streicheln einer Katze!' },
{ title: 'Spitze formen', description: 'Ein Ende kannst du mit dem Schleifpapier etwas spitzer machen. Nicht zu spitz — es soll ein Zauberstab sein, kein Speer!' },
{ title: 'Bemalen', description: 'Jetzt wird es bunt! Male Spiralen, Sterne oder Streifen auf deinen Stab. Lass jede Farbe trocknen bevor du die naechste nimmst.' },
{ title: 'Trocknen lassen', description: 'Stell den Stab zum Trocknen aufrecht in ein Glas. Wenn die Farbe trocken ist, kann ein Erwachsener Klarlack auftragen.' },
],
safetyTips: [
'Ein Erwachsener sollte beim Schnitzen immer dabei sein.',
'Immer vom Koerper weg schnitzen!',
'Frische Aeste sind weicher — trockene Aeste koennen splittern.',
],
skills: ['Feinmotorik', 'Schleifen', 'Kreatives Gestalten'],
},
{
slug: 'untersetzer',
name: 'Holz-Untersetzer',
description: 'Bastle praktische Untersetzer aus Holzscheiben! Eine tolle Geschenkidee fuer die ganze Familie.',
ageRange: '6+',
difficulty: 1,
duration: '30 Minuten',
tools: ['Schleifpapier (mittel + fein)', 'Pinsel'],
materials: ['Holzscheiben (ca. 10cm Durchmesser)', 'Acrylfarben', 'Klarlack', 'Filzgleiter'],
steps: [
{ title: 'Holzscheiben vorbereiten', description: 'Nimm eine Holzscheibe und pruefe ob sie flach auf dem Tisch liegt. Wackelt sie? Dann such dir eine andere aus.' },
{ title: 'Oberflaeche schleifen', description: 'Schleife beide Seiten der Holzscheibe glatt. Erst mit dem groben, dann mit dem feinen Schleifpapier.' },
{ title: 'Staub abwischen', description: 'Wisch den Schleifstaub mit einem feuchten Tuch ab. Die Scheibe muss sauber sein damit die Farbe haelt.' },
{ title: 'Muster malen', description: 'Bemale die Oberseite mit einem schoenen Muster: Blumen, Tiere, Punkte oder Streifen — alles ist erlaubt!' },
{ title: 'Versiegeln', description: 'Wenn die Farbe trocken ist, traegt ein Erwachsener Klarlack auf. So wird der Untersetzer wasserfest.' },
{ title: 'Filzgleiter aufkleben', description: 'Klebe 3-4 kleine Filzgleiter auf die Unterseite. So rutscht der Untersetzer nicht und zerkratzt den Tisch nicht.' },
],
safetyTips: [
'Beim Schleifen Staub nicht einatmen — am besten draussen arbeiten.',
'Klarlack nur von Erwachsenen auftragen lassen (gut lueften!).',
],
skills: ['Schleifen', 'Malen', 'Sorgfaeltiges Arbeiten'],
},
{
slug: 'nagelbilder',
name: 'Nagelbilder',
description: 'Schlage Naegel in ein Brett und spanne bunte Faeden dazwischen — so entstehen tolle Kunstwerke!',
ageRange: '5-7',
difficulty: 1,
duration: '40 Minuten',
tools: ['Hammer (leicht, kindgerecht)', 'Bleistift'],
materials: ['Holzbrett (ca. 20x20cm)', 'Kleine Naegel (ca. 20 Stueck)', 'Bunte Wollfaeden', 'Vorlage auf Papier'],
steps: [
{ title: 'Vorlage waehlen', description: 'Such dir eine einfache Form aus: ein Herz, einen Stern oder ein Haus. Zeichne die Form auf Papier und lege es auf das Brett.' },
{ title: 'Punkte markieren', description: 'Druecke mit dem Bleistift entlang der Form Punkte ins Holz. Alle 2cm ein Punkt reicht aus.' },
{ title: 'Papier entfernen', description: 'Nimm das Papier vorsichtig ab. Du siehst jetzt die Bleistiftpunkte auf dem Holz.' },
{ title: 'Naegel einschlagen', description: 'Schlage an jedem Punkt einen Nagel ein. Der Nagel sollte ungefaehr 1cm aus dem Holz schauen. Halt den Nagel mit einer Zange, nicht mit den Fingern!' },
{ title: 'Faeden spannen', description: 'Knote einen Faden an einen Nagel und spanne ihn kreuz und quer zu den anderen Naegeln. Experimentiere mit verschiedenen Farben!' },
{ title: 'Aufhaengen', description: 'Schraube eine kleine Oese auf die Rueckseite — fertig ist dein Kunstwerk zum Aufhaengen!' },
],
safetyTips: [
'Naegel immer mit einer Zange festhalten, niemals mit den Fingern!',
'Einen leichten Kinderhammer verwenden.',
'Auf eine stabile Unterlage achten beim Haemmern.',
],
skills: ['Haemmern', 'Feinmotorik', 'Kreativitaet'],
},
{
slug: 'bleistiftbox',
name: 'Bleistiftbox',
description: 'Baue eine praktische Box fuer deine Stifte und Pinsel! Aus duennen Holzbrettchen entsteht ein nuetzlicher Schreibtischhelfer.',
ageRange: '7-9',
difficulty: 2,
duration: '1 Stunde',
tools: ['Handsaege (kindersicher)', 'Schleifpapier', 'Holzleim', 'Schraubzwinge', 'Lineal', 'Bleistift'],
materials: ['Duennes Sperrholz (4mm)', 'Holzleim', 'Acrylfarbe', 'Klarlack'],
steps: [
{ title: 'Teile anzeichnen', description: 'Zeichne die 5 Teile auf das Sperrholz: 1 Boden (8x8cm), 4 Seitenwaende (8x10cm). Miss genau mit dem Lineal!' },
{ title: 'Aussaegen', description: 'Saege die Teile vorsichtig aus. Ein Erwachsener hilft beim Festhalten. Immer langsam und gleichmaessig saegen.' },
{ title: 'Kanten schleifen', description: 'Schleife alle Kanten glatt. Besonders die Saegekanten muessen schoen eben werden.' },
{ title: 'Zusammenleimen', description: 'Trage Holzleim auf die Kanten auf und druecke die Teile zusammen. Erst zwei Seiten an den Boden, dann die anderen zwei.' },
{ title: 'Trocknen lassen', description: 'Fixiere alles mit Schraubzwingen oder Klebeband. Der Leim braucht mindestens 1 Stunde zum Trocknen.' },
{ title: 'Dekorieren', description: 'Bemale deine Box mit Acrylfarben. Du kannst deinen Namen draufschreiben oder Muster malen.' },
{ title: 'Versiegeln', description: 'Nach dem Trocknen der Farbe traegt ein Erwachsener Klarlack auf. Fertig ist deine Bleistiftbox!' },
],
safetyTips: [
'Beim Saegen immer das Holz fest einspannen!',
'Die Saege vom Koerper weg fuehren.',
'Holzleim ist nicht giftig, aber trotzdem nicht in den Mund nehmen.',
],
skills: ['Messen und Anzeichnen', 'Saegen', 'Leimen', 'Geduld'],
},
{
slug: 'segelboot',
name: 'Segelboot',
description: 'Baue ein kleines Segelboot das wirklich schwimmt! Perfekt fuer die Badewanne oder den Bach im Park.',
ageRange: '8-10',
difficulty: 2,
duration: '1.5 Stunden',
tools: ['Handsaege', 'Schleifpapier', 'Bohrer (Handbohrer)', 'Schnitzmesser'],
materials: ['Holzklotz (ca. 20x8x4cm)', 'Rundstab (ca. 20cm)', 'Stoffrest fuer Segel', 'Holzleim', 'Wasserfarbe + Klarlack'],
steps: [
{ title: 'Rumpf anzeichnen', description: 'Zeichne die Bootsform von oben auf den Holzklotz: Vorne spitz, hinten breit. Die typische Bootsform kennst du bestimmt!' },
{ title: 'Rumpf aussaegen', description: 'Saege die Bootsform aus. Ein Erwachsener hilft beim Festhalten. Die Kurven langsam und vorsichtig saegen.' },
{ title: 'Rumpf schleifen', description: 'Schleife den Rumpf schoen rund. Die Unterseite sollte leicht gewoelbt sein wie bei einem echten Boot.' },
{ title: 'Mastloch bohren', description: 'Ein Erwachsener bohrt in der Mitte ein Loch fuer den Mast. Es muss so gross sein, dass der Rundstab genau reinpasst.' },
{ title: 'Segel basteln', description: 'Schneide aus dem Stoff ein Dreieck aus (ca. 15cm hoch). Klebe oder naehe es am Rundstab fest.' },
{ title: 'Zusammenbauen', description: 'Stecke den Mast mit etwas Holzleim ins Loch. Lass alles gut trocknen.' },
{ title: 'Wasserfest machen', description: 'Bemale dein Boot und lass es trocknen. Dann traegt ein Erwachsener mehrere Schichten Klarlack auf — so bleibt dein Boot wasserdicht!' },
],
safetyTips: [
'Bohren ist Erwachsenensache — hilf beim Festhalten!',
'Beim Schnitzen immer vom Koerper weg arbeiten.',
'Boot nur unter Aufsicht im Wasser testen.',
],
skills: ['Saegen', 'Formen', 'Zusammenbauen', 'Wasserdicht machen'],
},
{
slug: 'vogelhaus',
name: 'Vogelhaus',
description: 'Baue ein kuscheliges Vogelhaus fuer die Voegel in deinem Garten! Im Winter freuen sie sich besonders ueber ein Futterhaus.',
ageRange: '8-10',
difficulty: 2,
duration: '2 Stunden',
tools: ['Handsaege', 'Hammer', 'Schleifpapier', 'Bohrer', 'Lineal', 'Bleistift'],
materials: ['Holzbretter (1cm dick)', 'Kleine Naegel oder Schrauben', 'Holzleim', 'Dachpappe oder Rinde', 'Leinoel (ungiftig)'],
steps: [
{ title: 'Teile anzeichnen', description: 'Zeichne alle Teile auf: Boden (18x18cm), 2 Seitenwaende, 2 Giebel (mit Spitze fuer das Dach), 2 Dachhaelften. Ein Erwachsener hilft beim Ausmessen.' },
{ title: 'Aussaegen', description: 'Saege alle Teile vorsichtig aus. Bei den Giebeln mit der Spitze besonders aufpassen. Immer mit Hilfe eines Erwachsenen!' },
{ title: 'Einflugsloch', description: 'Ein Erwachsener bohrt in eine Giebelseite ein rundes Loch (ca. 3cm). Das ist die Tuer fuer die Voegel!' },
{ title: 'Schleifen', description: 'Schleife alle Teile glatt, besonders die Kanten. Voegel sollen sich nicht verletzen.' },
{ title: 'Zusammenbauen', description: 'Leime und nagle die Teile zusammen: Erst die Seitenwaende am Boden, dann die Giebel, zum Schluss das Dach.' },
{ title: 'Dach schuetzen', description: 'Klebe Dachpappe oder Rindenstuecke auf das Dach. So bleibt das Innere trocken bei Regen.' },
{ title: 'Behandeln', description: 'Reibe das Haeuschen von aussen mit Leinoel ein. KEINE Farbe verwenden — die Chemikalien koennten den Voegeln schaden!' },
],
safetyTips: [
'Naegel mit der Zange halten beim Einschlagen.',
'Saegen und Bohren nur mit Erwachsenen zusammen.',
'Kein giftiges Holzschutzmittel verwenden — nur Leinoel!',
],
skills: ['Messen', 'Saegen', 'Naegeln', 'Zusammenbauen', 'Tierschutz'],
},
{
slug: 'holztier-igel',
name: 'Holztier — Igel',
description: 'Schnitze einen niedlichen Igel aus Holz! Die Stacheln werden aus kurzen Naegeln oder Zahnstochern gemacht.',
ageRange: '8-10',
difficulty: 2,
duration: '1 Stunde',
tools: ['Schnitzmesser (kindersicher)', 'Schleifpapier', 'Bohrer (duenn)', 'Hammer (leicht)'],
materials: ['Holzklotz (ca. 10x6x5cm, weiches Holz)', 'Zahnstocher oder kurze Naegel', 'Schwarzer Filzstift', 'Holzleim'],
steps: [
{ title: 'Form anzeichnen', description: 'Zeichne die Igelform von der Seite auf den Holzklotz: Vorne eine kleine Spitznase, hinten rund. Von oben tropfenfoermig.' },
{ title: 'Grob schnitzen', description: 'Schnitze mit dem Schnitzmesser die grobe Form. Ein Erwachsener hilft bei harten Stellen. Immer vom Koerper weg schnitzen!' },
{ title: 'Form verfeinern', description: 'Schnitze die Nase spitzer und den Koerper runder. Der Igel soll von hinten huebsch rund aussehen.' },
{ title: 'Schleifen', description: 'Schleife den ganzen Igel glatt. Besonders das Gesicht soll weich und glatt sein.' },
{ title: 'Stacheln vorbereiten', description: 'Ein Erwachsener bohrt viele kleine Loecher in den Ruecken (nicht zu tief!). Die Loecher sollten leicht schraeg nach hinten zeigen.' },
{ title: 'Stacheln einsetzen', description: 'Stecke Zahnstocher in die Loecher und kuerze sie auf 1-2cm. Ein Tropfen Holzleim in jedes Loch haelt die Stacheln fest.' },
{ title: 'Gesicht malen', description: 'Male mit dem schwarzen Filzstift zwei Augen und eine kleine Nase. Fertig ist dein Igel!' },
],
safetyTips: [
'Schnitzmesser immer geschlossen ablegen.',
'Vom Koerper weg schnitzen — das ist die wichtigste Regel!',
'Weiches Holz wie Linde oder Pappel verwenden.',
],
skills: ['Schnitzen', 'Feinarbeit', 'Raeumliches Denken'],
},
{
slug: 'schnitzfigur-pilz',
name: 'Schnitzfigur — Pilz',
description: 'Schnitze einen huebschen Fliegenpilz aus Holz! Ein anspruchsvolles Projekt fuer erfahrene junge Holzwerker.',
ageRange: '10-12',
difficulty: 3,
duration: '2 Stunden',
tools: ['Schnitzmesser-Set (3 Messer)', 'Schleifpapier (fein + sehr fein)', 'Schraubstock'],
materials: ['Holzklotz (ca. 12x8x8cm, Linde)', 'Acrylfarben (rot, weiss, braun)', 'Klarlack', 'Pinsel (duenn + mittel)'],
steps: [
{ title: 'Entwurf zeichnen', description: 'Zeichne deinen Pilz von vorne und von der Seite auf Papier. Uebertrage die Form mit Bleistift auf den Holzklotz.' },
{ title: 'Grobe Form', description: 'Spanne den Klotz im Schraubstock ein. Schnitze mit dem groessten Messer die Grundform: oben die runde Kappe, unten den Stiel.' },
{ title: 'Kappe formen', description: 'Schnitze die Pilzkappe rund und leicht gewoelbt. Die Unterseite der Kappe ist leicht nach innen gewoelbt (hohl).' },
{ title: 'Stiel formen', description: 'Der Stiel wird nach unten etwas breiter. Schnitze ihn schoen rund und gleichmaessig.' },
{ title: 'Details schnitzen', description: 'Schnitze mit dem kleinsten Messer feine Details: Die Lamellen unter der Kappe (feine Rillen) und einen kleinen Ring am Stiel.' },
{ title: 'Feinschliff', description: 'Schleife den ganzen Pilz erst mit feinem, dann mit sehr feinem Schleifpapier. Je glatter, desto schoener die Bemalung!' },
{ title: 'Bemalen', description: 'Male die Kappe rot mit weissen Punkten (Fliegenpilz!). Der Stiel wird weiss oder hellbraun. Lass jede Schicht gut trocknen.' },
],
safetyTips: [
'Dieses Projekt nur mit Schnitz-Erfahrung beginnen!',
'Schraubstock verwenden — niemals das Holz in der Hand halten beim Schnitzen!',
'Scharfe Messer sind sicherer als stumpfe — ein Erwachsener schaerft die Messer.',
'Immer konzentriert arbeiten, nicht ablenken lassen.',
],
skills: ['Fortgeschrittenes Schnitzen', 'Detailarbeit', 'Geduld', 'Dreidimensionales Denken'],
},
]
export function getProject(slug: string): Project | undefined {
return projects.find((p) => p.slug === slug)
}
export function getRelatedProjects(slug: string, count = 3): Project[] {
const current = getProject(slug)
if (!current) return projects.slice(0, count)
return projects
.filter((p) => p.slug !== slug)
.sort((a, b) => Math.abs(a.difficulty - current.difficulty) - Math.abs(b.difficulty - current.difficulty))
.slice(0, count)
}

View File

@@ -0,0 +1,18 @@
export interface Project {
slug: string
name: string
description: string
ageRange: string
difficulty: 1 | 2 | 3
duration: string
tools: string[]
materials: string[]
steps: Step[]
safetyTips: string[]
skills: string[]
}
export interface Step {
title: string
description: string
}

6
levis-holzbau/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

2017
levis-holzbau/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "levis-holzbau",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3013",
"build": "next build",
"start": "next start -p 3013"
},
"dependencies": {
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.5",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: '#F5A623',
secondary: '#4CAF50',
accent: '#2196F3',
warning: '#FFC107',
cream: '#FDF8F0',
dark: '#2C2C2C',
},
fontFamily: {
heading: ['Quicksand', 'sans-serif'],
body: ['Nunito', 'sans-serif'],
},
},
},
plugins: [],
}
export default config

View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -32,7 +32,7 @@ server {
# Jitsi WebSocket endpoints
location /xmpp-websocket {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -46,7 +46,7 @@ server {
}
location /colibri-ws {
set $upstream_jvb bp-core-jitsi-jvb:9090;
set $upstream_jvb bp-lehrer-jitsi-jvb:9090;
proxy_pass http://$upstream_jvb;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -60,7 +60,7 @@ server {
}
location /http-bind {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -71,7 +71,7 @@ server {
# Jitsi static assets
location ~ ^/(css|images|fonts|sounds|static|libs|lang|connection_optimization)/ {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -81,7 +81,7 @@ server {
}
location ~ ^/(config\.js|interface_config\.js|logging_config\.js|external_api\.js|external_api\.min\.js|favicon\.ico|robots\.txt|manifest\.json|pwa-worker\.js) {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -91,7 +91,7 @@ server {
}
location /jitsi/ {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
rewrite ^/jitsi(/.*)$ $1 break;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
@@ -248,6 +248,15 @@ server {
proxy_set_header X-Forwarded-Proto https;
}
# RAG Original-PDFs fuer QA Split-View
location /rag-originals/ {
alias /data/rag-originals/;
autoindex off;
types { application/pdf pdf; }
add_header Cache-Control "public, max-age=86400";
add_header X-Content-Type-Options nosniff;
}
# Admin Lehrer Frontend (fallback for everything else)
location / {
set $upstream_admin_lehrer bp-lehrer-admin:3000;
@@ -564,7 +573,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
@@ -620,6 +630,31 @@ server {
}
}
# =========================================================
# COMPLIANCE: Docs (MkDocs) on port 8011
# =========================================================
server {
listen 8011 ssl;
http2 on;
server_name macmini localhost;
ssl_certificate /etc/nginx/certs/macmini.crt;
ssl_certificate_key /etc/nginx/certs/macmini.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
location / {
set $upstream_docs bp-compliance-docs:80;
proxy_pass http://$upstream_docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
# =========================================================
# CORE: Jitsi Meet on port 8443
# =========================================================
@@ -635,7 +670,7 @@ server {
ssl_prefer_server_ciphers off;
location /xmpp-websocket {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -649,7 +684,7 @@ server {
}
location /colibri-ws {
set $upstream_jvb bp-core-jitsi-jvb:9090;
set $upstream_jvb bp-lehrer-jitsi-jvb:9090;
proxy_pass http://$upstream_jvb;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -663,7 +698,7 @@ server {
}
location / {
set $upstream_jitsi bp-core-jitsi-web:80;
set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;

View File

@@ -679,24 +679,6 @@
</div>
</a>
<a class="card card-compliance" href="https://macmini:3007/dashboard">
<div class="stripe stripe-compliance"></div>
<div class="card-body">
<h3>Compliance Dashboard</h3>
<p>Kataloge, Statistiken, Verwaltung</p>
<div class="url">macmini:3007/dashboard</div>
</div>
</a>
<a class="card card-compliance" href="https://macmini:3007/dashboard">
<div class="stripe stripe-compliance"></div>
<div class="card-body">
<h3>Katalogverwaltung</h3>
<p>SDK-Kataloge &amp; Auswahltabellen</p>
<div class="url">macmini:3007/dashboard</div>
</div>
</a>
<a class="card card-compliance" href="https://macmini:3010/compliance-hub/">
<div class="stripe stripe-compliance"></div>
<div class="card-body">

View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 libgomp1 curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
CMD curl -f http://127.0.0.1:8095/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"]

110
paddleocr-service/main.py Normal file
View File

@@ -0,0 +1,110 @@
"""PaddleOCR Remote Service — PP-OCRv4 on x86_64 (CPU)."""
import io
import logging
import os
import threading
import numpy as np
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from PIL import Image
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="PaddleOCR Service")
_engine = None
_ready = False
_loading = False
API_KEY = os.environ.get("PADDLEOCR_API_KEY", "")
def _load_model():
"""Load PaddleOCR model in background thread."""
global _engine, _ready
try:
logger.info("Importing paddleocr...")
from paddleocr import PaddleOCR
logger.info("Loading PaddleOCR model (PP-OCRv4, lang=en)...")
_engine = PaddleOCR(
lang="en",
use_angle_cls=True,
show_log=False,
enable_mkldnn=False,
use_gpu=False,
)
logger.info("PaddleOCR model loaded — running warmup...")
# Warmup with tiny image to trigger any lazy init
dummy = np.ones((30, 100, 3), dtype=np.uint8) * 255
_engine.ocr(dummy)
_ready = True
logger.info("PaddleOCR ready to serve")
except Exception as e:
logger.error(f"Failed to load PaddleOCR: {e}", exc_info=True)
@app.on_event("startup")
def startup_load_model():
"""Start model loading in background so health check passes immediately."""
global _loading
_loading = True
threading.Thread(target=_load_model, daemon=True).start()
logger.info("Model loading started in background thread")
@app.get("/health")
def health():
if _ready:
return {"status": "ok", "model": "PP-OCRv4"}
if _loading:
return {"status": "loading"}
return {"status": "error"}
@app.post("/ocr")
async def ocr(
file: UploadFile = File(...),
x_api_key: str = Header(default=""),
):
if API_KEY and x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
if not _ready:
raise HTTPException(status_code=503, detail="Model still loading")
img_bytes = await file.read()
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img)
try:
result = _engine.ocr(img_np)
except Exception as e:
logger.error(f"OCR failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"OCR failed: {e}")
if not result or not result[0]:
return {"words": [], "image_width": img_np.shape[1], "image_height": img_np.shape[0]}
words = []
for line in result[0]:
box, (text, conf) = line[0], line[1]
x_min = min(p[0] for p in box)
y_min = min(p[1] for p in box)
x_max = max(p[0] for p in box)
y_max = max(p[1] for p in box)
words.append({
"text": str(text).strip(),
"left": int(x_min),
"top": int(y_min),
"width": int(x_max - x_min),
"height": int(y_max - y_min),
"conf": round(float(conf) * 100, 1),
})
return {
"words": words,
"image_width": img_np.shape[1],
"image_height": img_np.shape[0],
}

View File

@@ -0,0 +1,7 @@
paddlepaddle>=2.6.0,<3.0.0
paddleocr>=2.7.0,<3.0.0
fastapi>=0.110.0
uvicorn>=0.25.0
python-multipart>=0.0.6
Pillow>=10.0.0
numpy>=1.24.0

View File

@@ -180,9 +180,11 @@ export async function POST(request: NextRequest) {
model: OLLAMA_MODEL,
messages,
stream: true,
think: false,
options: {
temperature: 0.4,
num_predict: 4096,
num_ctx: 8192,
},
}),
signal: AbortSignal.timeout(120000),

View File

@@ -88,6 +88,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -126,6 +127,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
setIsWaiting(true)
abortRef.current = new AbortController()
@@ -152,24 +154,31 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
let firstChunk = true
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
const currentText = content
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: currentText }
return updated
})
if (firstChunk) {
firstChunk = false
setIsWaiting(false)
setMessages(prev => [...prev, { role: 'assistant', content }])
} else {
const currentText = content
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: currentText }
return updated
})
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Chat error:', err)
setIsWaiting(false)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
@@ -179,6 +188,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
])
} finally {
setIsStreaming(false)
setIsWaiting(false)
abortRef.current = null
}
}
@@ -344,6 +354,32 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
</div>
)}
{/* Waiting indicator */}
<AnimatePresence>
{isWaiting && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex gap-2.5"
>
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
{[0, 1, 2].map(i => (
<motion.span
key={i}
className="block w-1.5 h-1.5 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
transition={{ duration: 0.7, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{messages.map((msg, idx) => (
<div
key={idx}

View File

@@ -17,20 +17,20 @@ interface CompetitionSlideProps {
const securityFeatures = {
de: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrierte Security-Tools scannen Firmware und Software Ihrer Maschinen kontinuierlich auf Schwachstellen' },
{ icon: ScanLine, title: 'Firmware & Code Scanning', desc: 'Semgrep + Gitleaks analysieren Embedded-Code, Steuerungs-Software und CI/CD Pipelines automatisch' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scannen Container-Images und Abhaengigkeiten auf CVEs — CRA-konform dokumentiert' },
{ icon: Package, title: 'SBOM-Generator (CRA/NIS2)', desc: 'CycloneDX/SPDX Software Bill of Materials — Pflicht fuer Hersteller unter dem Cyber Resilience Act' },
{ icon: FileSearch, title: 'Software-Risikoanalyse', desc: 'Automatisierte Risikoklassifizierung fuer Embedded-Software, Firmware und AI-Act-konforme Steuerungssysteme' },
{ icon: Code2, title: 'KI-Code-Assistent (1000b)', desc: 'BSI-zertifiziertes Cloud-LLM implementiert Security-Fixes, schreibt Risikoanalysen und ist fuer Mitarbeiter nutzbar' },
{ icon: ShieldCheck, title: 'Self-Hosted + PII-Redaction', desc: 'Einziger Anbieter mit On-Premise-Deployment. LLM Gateway maskiert personenbezogene Daten vor KI-Verarbeitung' },
{ icon: ScanLine, title: 'DevSecOps Security Suite', desc: 'Semgrep, Gitleaks, Trivy, Grype — 6 integrierte Tools scannen Code, Container und Dependencies kontinuierlich' },
{ icon: Bug, title: 'SBOM + CI/CD Evidence', desc: 'CycloneDX/SPDX SBOM-Generator + automatische Compliance-Nachweise direkt aus der Build-Pipeline' },
{ icon: Package, title: 'Multi-Framework SDK', desc: 'Consent-Banner und Compliance-Widgets fuer React, Vue, Angular, iOS, Android, Flutter' },
{ icon: FileSearch, title: 'IPFS Dokumenten-Archivierung', desc: 'Dezentrale, manipulationssichere Archivierung mit kryptographischem Nachweis der Unveraendertheit' },
{ icon: Code2, title: 'KI-Compliance-Advisor (RAG)', desc: '2.274 indexierte Rechtstexte, KI-gestuetzte Dokumentenerstellung, Scope Engine L1-L4' },
],
en: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrated security tools continuously scan firmware and software of your machines for vulnerabilities' },
{ icon: ScanLine, title: 'Firmware & Code Scanning', desc: 'Semgrep + Gitleaks analyze embedded code, controller software and CI/CD pipelines automatically' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scan container images and dependencies for CVEs — CRA-compliant documentation' },
{ icon: Package, title: 'SBOM Generator (CRA/NIS2)', desc: 'CycloneDX/SPDX Software Bill of Materials — mandatory for manufacturers under the Cyber Resilience Act' },
{ icon: FileSearch, title: 'Software Risk Assessment', desc: 'Automated risk classification for embedded software, firmware and AI Act compliant control systems' },
{ icon: Code2, title: 'AI Code Assistant (1000B)', desc: 'BSI-certified cloud LLM implements security fixes, writes risk assessments and is available for employee use' },
{ icon: ShieldCheck, title: 'Self-Hosted + PII Redaction', desc: 'Only provider with on-premise deployment. LLM Gateway masks personal data before AI processing' },
{ icon: ScanLine, title: 'DevSecOps Security Suite', desc: 'Semgrep, Gitleaks, Trivy, Grype — 6 integrated tools continuously scan code, containers and dependencies' },
{ icon: Bug, title: 'SBOM + CI/CD Evidence', desc: 'CycloneDX/SPDX SBOM generator + automatic compliance evidence directly from the build pipeline' },
{ icon: Package, title: 'Multi-Framework SDK', desc: 'Consent banners and compliance widgets for React, Vue, Angular, iOS, Android, Flutter' },
{ icon: FileSearch, title: 'IPFS Document Archiving', desc: 'Decentralized, tamper-proof archiving with cryptographic proof of integrity' },
{ icon: Code2, title: 'AI Compliance Advisor (RAG)', desc: '2,274 indexed legal texts, AI-powered document generation, Scope Engine L1-L4' },
],
}

View File

@@ -30,7 +30,7 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
const heroStats = [
{
value: '691K',
value: '761K',
label: de ? 'Zeilen Code' : 'Lines of Code',
sub: 'Go · Python · TypeScript',
color: 'text-indigo-400',
@@ -60,9 +60,9 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
]
const languageBreakdown = [
{ lang: 'TypeScript / TSX', pct: 58, loc: '403K', color: 'bg-blue-500', icon: Braces },
{ lang: 'Python', pct: 23, loc: '160K', color: 'bg-yellow-500', icon: Terminal },
{ lang: 'Go', pct: 18, loc: '127K', color: 'bg-cyan-500', icon: Code2 },
{ lang: 'TypeScript / TSX', pct: 54, loc: '408K', color: 'bg-blue-500', icon: Braces },
{ lang: 'Python', pct: 28, loc: '213K', color: 'bg-yellow-500', icon: Terminal },
{ lang: 'Go', pct: 18, loc: '141K', color: 'bg-cyan-500', icon: Code2 },
]
const devopsStack = [
@@ -120,8 +120,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
color: 'text-emerald-400',
dotColor: 'bg-emerald-400',
services: de
? ['Compliance Dashboard', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS Gateway', 'Security Scanner (Go)']
: ['Compliance Dashboard', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS Gateway', 'Security Scanner (Go)'],
? ['Compliance Dashboard (57 Module)', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS/IPFS Gateway', 'TTS Service']
: ['Compliance Dashboard (57 Modules)', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS/IPFS Gateway', 'TTS Service'],
},
]

View File

@@ -15,6 +15,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -29,6 +30,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
setIsWaiting(true)
try {
const res = await fetch('/api/chat', {
@@ -46,22 +48,29 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
let firstChunk = true
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
return updated
})
if (firstChunk) {
firstChunk = false
setIsWaiting(false)
setMessages(prev => [...prev, { role: 'assistant', content }])
} else {
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
return updated
})
}
}
} catch (err) {
console.error('Chat error:', err)
setIsWaiting(false)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
@@ -71,6 +80,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
])
} finally {
setIsStreaming(false)
setIsWaiting(false)
}
}
@@ -135,6 +145,33 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
</motion.div>
))}
</AnimatePresence>
{/* Waiting indicator — shown between send and first token */}
<AnimatePresence>
{isWaiting && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="flex gap-3"
>
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-4 py-3 flex items-center gap-1">
{[0, 1, 2].map(i => (
<motion.span
key={i}
className="block w-2 h-2 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -4, 0] }}
transition={{ duration: 0.8, repeat: Infinity, delay: i * 0.18 }}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>

View File

@@ -68,8 +68,8 @@ const translations = {
icon: 'scan',
},
{
title: 'Compliance-KI',
desc: 'Macht Ihr Unternehmen UND Ihre Produkte compliant. DSGVO, AI Act, CRA und NIS2 — automatisiert. BSI-zertifiziertes 1000B LLM in Deutschland gehostet.',
title: 'Compliance-KI (57 Module)',
desc: 'Macht Ihr Unternehmen UND Ihre Produkte compliant. DSGVO, AI Act, CRA, NIS2, HinSchG — 57 SDK-Module, 19 Regularien, 2.274 indexierte Rechtstexte.',
icon: 'bot',
},
],
@@ -137,7 +137,7 @@ const translations = {
},
competition: {
title: 'Wettbewerb',
subtitle: 'Was uns differenziert',
subtitle: '44 Features vs. ~15-25 bei Wettbewerbern — 9 einzigartige USPs',
feature: 'Feature',
selfHosted: 'Self-Hosted',
integratedAI: 'Integrierte KI',
@@ -213,7 +213,7 @@ const translations = {
},
engineering: {
title: 'Engineering Deep Dive',
subtitle: '691K Zeilen Code \u00b7 45 Container \u00b7 100% Self-Hosted',
subtitle: '761K Zeilen Code \u00b7 45 Container \u00b7 100% Self-Hosted',
},
aipipeline: {
title: 'KI-Pipeline Deep Dive',
@@ -288,8 +288,8 @@ const translations = {
icon: 'scan',
},
{
title: 'Compliance AI',
desc: 'Makes your company AND your products compliant. GDPR, AI Act, CRA and NIS2 — automated. BSI-certified 1000B LLM hosted in Germany.',
title: 'Compliance AI (57 Modules)',
desc: 'Makes your company AND your products compliant. GDPR, AI Act, CRA, NIS2, HinSchG — 57 SDK modules, 19 regulations, 2,274 indexed legal texts.',
icon: 'bot',
},
],
@@ -357,7 +357,7 @@ const translations = {
},
competition: {
title: 'Competition',
subtitle: 'What differentiates us',
subtitle: '44 features vs. ~15-25 competitors — 9 unique USPs',
feature: 'Feature',
selfHosted: 'Self-Hosted',
integratedAI: 'Integrated AI',
@@ -433,7 +433,7 @@ const translations = {
},
engineering: {
title: 'Engineering Deep Dive',
subtitle: '691K Lines of Code \u00b7 45 Containers \u00b7 100% Self-Hosted',
subtitle: '761K Lines of Code \u00b7 45 Containers \u00b7 100% Self-Hosted',
},
aipipeline: {
title: 'AI Pipeline Deep Dive',

View File

@@ -6,6 +6,7 @@ class Settings:
# Qdrant
QDRANT_URL: str = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_API_KEY: str = os.getenv("QDRANT_API_KEY", "")
# MinIO
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "localhost:9000")

View File

@@ -27,6 +27,8 @@ _COMPLIANCE_COLLECTIONS = {
"bp_compliance_gdpr": 1024,
"bp_compliance_schulrecht": 1024,
"bp_compliance_datenschutz": 1024,
"bp_compliance_gesetze": 1024,
"bp_compliance_ce": 1024,
"bp_dsfa_templates": 1024,
"bp_dsfa_risks": 1024,
}
@@ -46,7 +48,11 @@ class QdrantClientWrapper:
@property
def client(self) -> QdrantClient:
if self._client is None:
self._client = QdrantClient(url=settings.QDRANT_URL, timeout=30)
self._client = QdrantClient(
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY or None,
timeout=30,
)
logger.info("Connected to Qdrant at %s", settings.QDRANT_URL)
return self._client
@@ -103,6 +109,13 @@ class QdrantClientWrapper:
# Indexing
# ------------------------------------------------------------------
async def ensure_collection(self, name: str, vector_size: int = 1024) -> None:
"""Create collection if it doesn't exist."""
try:
self.client.get_collection(name)
except Exception:
await self.create_collection(name, vector_size)
async def index_documents(
self,
collection: str,
@@ -116,6 +129,10 @@ class QdrantClientWrapper:
f"vectors ({len(vectors)}) and payloads ({len(payloads)}) must have equal length"
)
# Auto-create collection if missing
vector_size = len(vectors[0]) if vectors else 1024
await self.ensure_collection(collection, vector_size)
if ids is None:
ids = [str(uuid.uuid4()) for _ in vectors]