Compare commits
62 Commits
19ee99a3bc
...
coolify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520a0f401c | ||
|
|
6adf1fe1eb | ||
|
|
2ac6559291 | ||
|
|
52618a0630 | ||
|
|
e1a84fd568 | ||
|
|
dd0bda05be | ||
|
|
4c68666c5c | ||
|
|
46b1fdc20f | ||
|
|
445cbc3100 | ||
|
|
8fe4473205 | ||
|
|
07dbd78962 | ||
|
|
e9487a31c6 | ||
|
|
0fb4a7e359 | ||
|
|
cf2cabd098 | ||
|
|
8ee02bd2e4 | ||
|
|
d9687725e5 | ||
|
|
6c3911ca47 | ||
|
|
30807d1ce1 | ||
|
|
82c28a2b6e | ||
|
|
86624d72dd | ||
|
|
9218664400 | ||
|
|
8fa5d9061a | ||
|
|
84002f5719 | ||
|
|
8b87b90cbb | ||
|
|
be45adb975 | ||
|
|
7c932c441f | ||
|
|
1eb402b3da | ||
|
|
963e824328 | ||
|
|
c0782e0039 | ||
|
|
44d66e2d6c | ||
|
|
f9b475db8f | ||
|
|
0770ff499b | ||
|
|
32aade553d | ||
|
|
f467db2ea0 | ||
|
|
35aad9b169 | ||
|
|
806d3e0b56 | ||
|
|
9f0e8328e5 | ||
|
|
65184c02c3 | ||
|
|
4245e24980 | ||
|
|
8dc1b4c67f | ||
|
|
2801e44d39 | ||
|
|
62ecb3eb24 | ||
|
|
fe9a9c2df2 | ||
|
|
5fe2617857 | ||
|
|
c8cc8774db | ||
|
|
1527f4ffe7 | ||
|
|
db1b3c40ed | ||
|
|
85df14c552 | ||
|
|
72e0f18d08 | ||
|
|
1c8f528c7a | ||
|
|
403cb5b85d | ||
|
|
5c8307f58a | ||
|
|
92ca5b7ba5 | ||
|
|
d7cc6bfbc7 | ||
|
|
13ba1457b0 | ||
|
|
0ac23089f4 | ||
|
|
e87ec2520d | ||
|
|
b7d21daa24 | ||
|
|
eb43b40dd0 | ||
|
|
bde0e11ba2 | ||
|
|
c736a596c0 | ||
|
|
022c00cd17 |
@@ -6,19 +6,26 @@
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Client | Claude Terminal, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Code-Ausfuehrung, Tests, Git |
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
|
||||
**WICHTIG:** Die Entwicklung findet vollstaendig auf dem **Mac Mini** statt!
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
|
||||
### SSH-Verbindung
|
||||
### Entwicklungsworkflow
|
||||
|
||||
```bash
|
||||
ssh macmini
|
||||
# Projektverzeichnis:
|
||||
cd /Users/benjaminadmin/Projekte/breakpilot-core
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Einzelbefehle (BEVORZUGT):
|
||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
|
||||
```bash
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||
```
|
||||
|
||||
|
||||
65
.env.coolify.example
Normal file
65
.env.coolify.example
Normal 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
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ jobs:
|
||||
# ========================================
|
||||
|
||||
go-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: golangci/golangci-lint:v1.55-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint consent-service
|
||||
run: |
|
||||
if [ -d "consent-service" ]; then
|
||||
@@ -32,11 +35,14 @@ jobs:
|
||||
fi
|
||||
|
||||
python-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Python services
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
@@ -48,11 +54,14 @@ jobs:
|
||||
done
|
||||
|
||||
nodejs-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint admin-core
|
||||
run: |
|
||||
if [ -d "admin-core" ]; then
|
||||
@@ -66,16 +75,18 @@ jobs:
|
||||
# ========================================
|
||||
|
||||
test-go-consent:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
container: golang:1.23-alpine
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test consent-service
|
||||
run: |
|
||||
apk add --no-cache jq bash
|
||||
if [ \! -d "consent-service" ]; then
|
||||
if [ ! -d "consent-service" ]; then
|
||||
echo "WARNUNG: consent-service nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
@@ -85,15 +96,18 @@ jobs:
|
||||
echo "Coverage: $COVERAGE"
|
||||
|
||||
test-python-voice:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test voice-service
|
||||
run: |
|
||||
if [ \! -d "voice-service" ]; then
|
||||
if [ ! -d "voice-service" ]; then
|
||||
echo "WARNUNG: voice-service nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
@@ -104,15 +118,18 @@ jobs:
|
||||
python -m pytest tests/ -v --tb=short --ignore=tests/bqas
|
||||
|
||||
test-bqas:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test BQAS
|
||||
run: |
|
||||
if [ \! -d "voice-service/tests/bqas" ]; then
|
||||
if [ ! -d "voice-service/tests/bqas" ]; then
|
||||
echo "WARNUNG: BQAS Tests nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
@@ -121,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 }}"
|
||||
|
||||
29
.gitea/workflows/deploy-coolify.yml
Normal file
29
.gitea/workflows/deploy-coolify.yml
Normal 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!"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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} "{rule.conditions[0]?.value}"
|
||||
</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 "Inklusion"</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 "Werbung"</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 "new"</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 "\d{4}"</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: "{intent.example}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
{/* ================================================================ */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,208 +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 || ''
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
export interface WoodpeckerStatusResponse {
|
||||
status: 'online' | 'offline'
|
||||
pipelines: Pipeline[]
|
||||
lastUpdate: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const repoId = searchParams.get('repo') || '1'
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
try {
|
||||
// Fetch pipelines from Woodpecker API
|
||||
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 NextResponse.json({
|
||||
status: 'offline',
|
||||
pipelines: [],
|
||||
lastUpdate: new Date().toISOString(),
|
||||
error: `Woodpecker API nicht erreichbar (${response.status})`
|
||||
} as WoodpeckerStatusResponse)
|
||||
}
|
||||
|
||||
const rawPipelines = await response.json()
|
||||
|
||||
// Transform pipelines to our format
|
||||
const pipelines: Pipeline[] = rawPipelines.map((p: any) => {
|
||||
// Extract errors from workflows/steps
|
||||
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 NextResponse.json({
|
||||
status: 'online',
|
||||
pipelines,
|
||||
lastUpdate: new Date().toISOString()
|
||||
} as WoodpeckerStatusResponse)
|
||||
|
||||
} 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
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -18,7 +18,8 @@ COPY requirements.txt .
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir semgrep bandit
|
||||
|
||||
# ---------- Runtime stage ----------
|
||||
FROM python:3.12-slim-bookworm
|
||||
@@ -38,8 +39,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install DevSecOps tools (gitleaks, trivy, grype, syft)
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
ARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||
# Gitleaks
|
||||
GITLEAKS_VERSION=8.21.2; \
|
||||
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
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin; \
|
||||
# Grype
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin; \
|
||||
# Syft
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin; \
|
||||
# Verify
|
||||
gitleaks version && trivy --version && grype version && syft version
|
||||
|
||||
# Copy virtualenv from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
@@ -25,7 +25,6 @@ from email_template_api import (
|
||||
)
|
||||
from system_api import router as system_router
|
||||
from security_api import router as security_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,9 @@ BACKEND_DIR = Path(__file__).parent
|
||||
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
||||
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
||||
|
||||
# Projekt-Root fuer Security-Scans
|
||||
PROJECT_ROOT = BACKEND_DIR
|
||||
|
||||
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
||||
try:
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
@@ -15,7 +16,9 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -62,6 +70,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
197
docker-compose.coolify.yml
Normal file
197
docker-compose.coolify.yml
Normal 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
175
docker-compose.hetzner.yml
Normal 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:
|
||||
@@ -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:
|
||||
@@ -71,15 +59,15 @@ 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
|
||||
extra_hosts:
|
||||
- "breakpilot-edu-search:host-gateway"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- breakpilot-network
|
||||
@@ -89,19 +77,20 @@ services:
|
||||
# =========================================================
|
||||
vault:
|
||||
image: hashicorp/vault:1.15
|
||||
entrypoint: ["vault"]
|
||||
command: server -config=/vault/config/config.hcl
|
||||
container_name: bp-core-vault
|
||||
ports:
|
||||
- "8200:8200"
|
||||
volumes:
|
||||
- vault_data:/vault/data
|
||||
- ./vault/config.hcl:/vault/config/config.hcl:ro
|
||||
cap_add:
|
||||
- IPC_LOCK
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_TOKEN:-breakpilot-dev-token}
|
||||
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
|
||||
VAULT_ADDR: "http://127.0.0.1:8200"
|
||||
healthcheck:
|
||||
test: ["CMD", "vault", "status"]
|
||||
test: ["CMD-SHELL", "vault status; test $? -le 2"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -113,14 +102,16 @@ services:
|
||||
image: hashicorp/vault:1.15
|
||||
container_name: bp-core-vault-init
|
||||
volumes:
|
||||
- ./vault/init-pki.sh:/init-pki.sh:ro
|
||||
- ./vault/init-vault.sh:/vault/scripts/init-vault.sh:ro
|
||||
- ./vault/init-pki.sh:/vault/scripts/init-pki.sh:ro
|
||||
- ./vault/init-secrets.sh:/vault/scripts/init-secrets.sh:ro
|
||||
- vault_data:/vault/data
|
||||
- vault_agent_config:/vault/agent/data
|
||||
- vault_certs:/vault/certs
|
||||
environment:
|
||||
VAULT_ADDR: "http://vault:8200"
|
||||
VAULT_TOKEN: ${VAULT_TOKEN:-breakpilot-dev-token}
|
||||
entrypoint: /bin/sh
|
||||
command: /init-pki.sh
|
||||
command: /vault/scripts/init-vault.sh
|
||||
depends_on:
|
||||
vault:
|
||||
condition: service_healthy
|
||||
@@ -191,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
|
||||
# =========================================================
|
||||
@@ -376,14 +347,18 @@ 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}
|
||||
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
@@ -411,7 +386,7 @@ services:
|
||||
- embedding_models:/root/.cache/huggingface
|
||||
environment:
|
||||
EMBEDDING_BACKEND: ${EMBEDDING_BACKEND:-local}
|
||||
LOCAL_EMBEDDING_MODEL: ${LOCAL_EMBEDDING_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||
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:-}
|
||||
@@ -420,7 +395,7 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
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
|
||||
@@ -457,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
|
||||
# =========================================================
|
||||
@@ -721,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://gitea: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
|
||||
# =========================================================
|
||||
@@ -837,6 +537,9 @@ services:
|
||||
networks:
|
||||
- breakpilot-network
|
||||
|
||||
# =========================================================
|
||||
# NIGHT SCHEDULER
|
||||
# =========================================================
|
||||
night-scheduler:
|
||||
build:
|
||||
context: ./night-scheduler
|
||||
@@ -877,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"
|
||||
@@ -1133,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:
|
||||
@@ -1142,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
|
||||
|
||||
317
docs-src/architecture/sdk-protection.md
Normal file
317
docs-src/architecture/sdk-protection.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# SDK Protection Middleware
|
||||
|
||||
## 1. Worum geht es?
|
||||
|
||||
Die SDK Protection Middleware schuetzt die Compliance-SDK-Endpunkte vor einer bestimmten Art von Angriff: der **systematischen Enumeration**. Was bedeutet das?
|
||||
|
||||
> *Ein Wettbewerber registriert sich als zahlender Kunde und laesst ein Skript langsam und verteilt alle TOM-Controls, alle Pruefaspekte und alle Assessment-Kriterien abfragen. Aus den Ergebnissen rekonstruiert er die gesamte Compliance-Framework-Logik.*
|
||||
|
||||
Der klassische Rate Limiter (100 Requests/Minute) hilft hier nicht, weil ein cleverer Angreifer langsam vorgeht -- vielleicht nur 20 Anfragen pro Minute, dafuer systematisch und ueber Stunden. Die SDK Protection erkennt solche Muster und reagiert darauf.
|
||||
|
||||
!!! info "Kern-Designprinzip"
|
||||
**Normale Nutzer merken nichts.** Ein Lehrer, der im TOM-Modul arbeitet, greift typischerweise auf 3-5 Kategorien zu und wiederholt Anfragen an gleiche Endpunkte. Ein Angreifer durchlaeuft dagegen 40+ Kategorien in alphabetischer Reihenfolge. Genau diesen Unterschied erkennt die Middleware.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wie funktioniert der Schutz?
|
||||
|
||||
Die Middleware nutzt ein **Anomaly-Score-System**. Jeder Benutzer hat einen Score, der bei 0 beginnt. Verschiedene verdaechtige Verhaltensweisen erhoehen den Score. Ueber die Zeit sinkt er wieder ab. Je hoeher der Score, desto staerker wird der Benutzer gebremst.
|
||||
|
||||
Man kann es sich wie eine Ampel vorstellen:
|
||||
|
||||
| Score | Ampel | Wirkung | Beispiel |
|
||||
|-------|-------|---------|----------|
|
||||
| 0-29 | Gruen | Keine Einschraenkung | Normaler Nutzer |
|
||||
| 30-59 | Gelb | 1-3 Sekunden Verzoegerung | Leicht auffaelliges Muster |
|
||||
| 60-84 | Orange | 5-10 Sekunden Verzoegerung, reduzierte Details | Deutlich verdaechtiges Verhalten |
|
||||
| 85+ | Rot | Zugriff blockiert (HTTP 429) | Sehr wahrscheinlich automatisierter Angriff |
|
||||
|
||||
### Score-Zerfall
|
||||
|
||||
Der Score sinkt automatisch: Alle 5 Minuten wird er mit dem Faktor 0,95 multipliziert. Ein Score von 60 faellt also innerhalb einer Stunde auf etwa 30 -- wenn kein neues verdaechtiges Verhalten hinzukommt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Was wird erkannt?
|
||||
|
||||
Die Middleware erkennt fuenf verschiedene Anomalie-Muster:
|
||||
|
||||
### 3.1 Hohe Kategorie-Diversitaet
|
||||
|
||||
**Was:** Ein Benutzer greift innerhalb einer Stunde auf mehr als 40 verschiedene SDK-Kategorien zu.
|
||||
|
||||
**Warum verdaechtig:** Ein normaler Nutzer arbeitet in der Regel mit 3-10 Kategorien. Wer systematisch alle durchlaeuft, sammelt vermutlich Daten.
|
||||
|
||||
**Score-Erhoehung:** +15
|
||||
|
||||
```
|
||||
Normal: tom/access-control → tom/access-control → tom/encryption → tom/encryption
|
||||
(3 verschiedene Kategorien in einer Stunde)
|
||||
|
||||
Verdaechtig: tom/access-control → tom/encryption → tom/pseudonymization → tom/integrity
|
||||
→ tom/availability → tom/resilience → dsfa/threshold → dsfa/necessity → ...
|
||||
(40+ verschiedene Kategorien in einer Stunde)
|
||||
```
|
||||
|
||||
### 3.2 Burst-Erkennung
|
||||
|
||||
**Was:** Ein Benutzer sendet mehr als 15 Anfragen an die gleiche Kategorie innerhalb von 2 Minuten.
|
||||
|
||||
**Warum verdaechtig:** Selbst ein eifriger Nutzer klickt nicht 15-mal pro Minute auf denselben Endpunkt. Das deutet auf automatisiertes Scraping hin.
|
||||
|
||||
**Score-Erhoehung:** +20
|
||||
|
||||
### 3.3 Sequentielle Enumeration
|
||||
|
||||
**Was:** Die letzten 10 aufgerufenen Kategorien sind zu mindestens 70% in alphabetischer oder numerischer Reihenfolge.
|
||||
|
||||
**Warum verdaechtig:** Menschen springen zwischen Kategorien -- sie arbeiten thematisch, nicht alphabetisch. Ein Skript dagegen iteriert oft ueber eine sortierte Liste.
|
||||
|
||||
**Score-Erhoehung:** +25
|
||||
|
||||
```
|
||||
Verdaechtig: assessment_general → compliance_general → controls_general
|
||||
→ dsfa_measures → dsfa_necessity → dsfa_residual → dsfa_risks
|
||||
→ dsfa_threshold → eh_general → namespace_general
|
||||
(alphabetisch sortiert = Skript-Verhalten)
|
||||
```
|
||||
|
||||
### 3.4 Ungewoehnliche Uhrzeiten
|
||||
|
||||
**Was:** Anfragen zwischen 0:00 und 5:00 Uhr UTC.
|
||||
|
||||
**Warum verdaechtig:** Lehrer arbeiten tagsüber. Wer um 3 Uhr morgens SDK-Endpunkte abfragt, ist wahrscheinlich ein automatisierter Prozess.
|
||||
|
||||
**Score-Erhoehung:** +10
|
||||
|
||||
### 3.5 Multi-Tenant-Zugriff
|
||||
|
||||
**Was:** Ein Benutzer greift innerhalb einer Stunde auf mehr als 3 verschiedene Mandanten (Tenants) zu.
|
||||
|
||||
**Warum verdaechtig:** Ein normaler Nutzer gehoert zu einem Mandanten. Wer mehrere durchprobiert, koennte versuchen, mandantenuebergreifend Daten zu sammeln.
|
||||
|
||||
**Score-Erhoehung:** +15
|
||||
|
||||
---
|
||||
|
||||
## 4. Quota-System (Mengenbegrenzung)
|
||||
|
||||
Zusaetzlich zum Anomaly-Score gibt es klassische Mengenbegrenzungen in vier Zeitfenstern:
|
||||
|
||||
| Tier | pro Minute | pro Stunde | pro Tag | pro Monat |
|
||||
|------|-----------|-----------|---------|-----------|
|
||||
| **Free** | 30 | 500 | 3.000 | 50.000 |
|
||||
| **Standard** | 60 | 1.500 | 10.000 | 200.000 |
|
||||
| **Enterprise** | 120 | 5.000 | 50.000 | 1.000.000 |
|
||||
|
||||
Wenn ein Limit in irgendeinem Zeitfenster ueberschritten wird, erhaelt der Nutzer sofort HTTP 429 -- unabhaengig vom Anomaly-Score.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architektur
|
||||
|
||||
### Datenfluss eines SDK-Requests
|
||||
|
||||
```
|
||||
Request kommt an
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Ist der Pfad geschuetzt? │
|
||||
│ (/api/sdk/*, /api/v1/tom/*, /api/v1/dsfa/*, ...) │
|
||||
│ Nein → direkt weiterleiten │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│ Ja
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User + Tier + Kategorie extrahieren │
|
||||
│ (aus Session, API-Key oder X-SDK-Tier Header) │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Multi-Window Quota pruefen │
|
||||
│ (Minute / Stunde / Tag / Monat) │
|
||||
│ Ueberschritten → HTTP 429 zurueck │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│ OK
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Anomaly-Score laden (aus Valkey) │
|
||||
│ Zeitbasierten Zerfall anwenden (×0,95 alle 5 min) │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Anomalie-Detektoren ausfuehren: │
|
||||
│ ├── Diversity-Tracking (+15 wenn >40 Kategorien/h) │
|
||||
│ ├── Burst-Detection (+20 wenn >15 gleiche/2min) │
|
||||
│ ├── Sequential-Enumeration (+25 wenn sortiert) │
|
||||
│ ├── Unusual-Hours (+10 wenn 0-5 Uhr UTC) │
|
||||
│ └── Multi-Tenant (+15 wenn >3 Tenants/h) │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Throttle-Level bestimmen │
|
||||
│ Level 3 (Score ≥85) → HTTP 429 │
|
||||
│ Level 2 (Score ≥60) → 5-10s Delay + reduzierte Details │
|
||||
│ Level 1 (Score ≥30) → 1-3s Delay │
|
||||
│ Level 0 → keine Einschraenkung │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Request weiterleiten │
|
||||
│ Response-Headers setzen: │
|
||||
│ ├── X-SDK-Quota-Remaining-Minute/Hour │
|
||||
│ ├── X-SDK-Throttle-Level │
|
||||
│ ├── X-SDK-Detail-Reduced (bei Level ≥2) │
|
||||
│ └── X-BP-Trace (HMAC-Watermark) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Valkey-Datenstrukturen
|
||||
|
||||
Die Middleware speichert alle Tracking-Daten in Valkey (Redis-Fork). Wenn Valkey nicht erreichbar ist, wird automatisch auf eine In-Memory-Implementierung zurueckgefallen.
|
||||
|
||||
| Zweck | Valkey-Typ | Key-Muster | TTL |
|
||||
|-------|-----------|------------|-----|
|
||||
| Quota pro Zeitfenster | Sorted Set | `sdk_protect:quota:{user}:{window}` | Fenster + 10s |
|
||||
| Kategorie-Diversitaet | Set | `sdk_protect:diversity:{user}:{stunde}` | 3660s |
|
||||
| Burst-Tracking | Sorted Set | `sdk_protect:burst:{user}:{kategorie}` | 130s |
|
||||
| Sequenz-Tracking | List | `sdk_protect:seq:{user}` | 310s |
|
||||
| Anomaly-Score | Hash | `sdk_protect:score:{user}` | 86400s |
|
||||
| Tenant-Tracking | Set | `sdk_protect:tenants:{user}:{stunde}` | 3660s |
|
||||
|
||||
### Watermarking
|
||||
|
||||
Jede Antwort enthaelt einen `X-BP-Trace` Header mit einem HMAC-basierten Fingerabdruck. Damit kann nachtraeglich nachgewiesen werden, welcher Benutzer wann welche Daten abgerufen hat -- ohne dass der Benutzer den Trace veraendern kann.
|
||||
|
||||
---
|
||||
|
||||
## 6. Geschuetzte Endpunkte
|
||||
|
||||
Die Middleware schuetzt alle Pfade, die SDK- und Compliance-relevante Daten liefern:
|
||||
|
||||
| Pfad-Prefix | Bereich |
|
||||
|-------------|---------|
|
||||
| `/api/sdk/*` | SDK-Hauptendpunkte |
|
||||
| `/api/compliance/*` | Compliance-Bewertungen |
|
||||
| `/api/v1/tom/*` | Technisch-organisatorische Massnahmen |
|
||||
| `/api/v1/dsfa/*` | Datenschutz-Folgenabschaetzung |
|
||||
| `/api/v1/vvt/*` | Verarbeitungsverzeichnis |
|
||||
| `/api/v1/controls/*` | Controls und Massnahmen |
|
||||
| `/api/v1/assessment/*` | Assessment-Bewertungen |
|
||||
| `/api/v1/eh/*` | Erwartungshorizonte |
|
||||
| `/api/v1/namespace/*` | Namespace-Verwaltung |
|
||||
|
||||
Nicht geschuetzt sind `/health`, `/metrics` und `/api/health`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Admin-Verwaltung
|
||||
|
||||
Ueber das Admin-Dashboard koennen Anomaly-Scores eingesehen und verwaltet werden:
|
||||
|
||||
| Endpoint | Methode | Beschreibung |
|
||||
|----------|---------|--------------|
|
||||
| `/api/admin/middleware/sdk-protection/scores` | GET | Aktuelle Anomaly-Scores aller Benutzer |
|
||||
| `/api/admin/middleware/sdk-protection/stats` | GET | Statistik: Benutzer pro Throttle-Level |
|
||||
| `/api/admin/middleware/sdk-protection/reset-score/{user_id}` | POST | Score eines Benutzers zuruecksetzen |
|
||||
| `/api/admin/middleware/sdk-protection/tiers` | GET | Tier-Konfigurationen anzeigen |
|
||||
| `/api/admin/middleware/sdk-protection/tiers/{name}` | PUT | Tier-Limits aendern |
|
||||
|
||||
---
|
||||
|
||||
## 8. Dateien und Quellcode
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `backend/middleware/sdk_protection.py` | Kern-Middleware (~460 Zeilen) |
|
||||
| `backend/middleware/__init__.py` | Export der Middleware-Klassen |
|
||||
| `backend/main.py` | Registrierung im FastAPI-Stack |
|
||||
| `backend/middleware_admin_api.py` | Admin-API-Endpoints |
|
||||
| `backend/migrations/add_sdk_protection_tables.sql` | Datenbank-Migration |
|
||||
| `backend/tests/test_middleware.py` | 14 Tests fuer alle Erkennungsmechanismen |
|
||||
|
||||
---
|
||||
|
||||
## 9. Datenbank-Tabellen
|
||||
|
||||
### sdk_anomaly_scores
|
||||
|
||||
Speichert Snapshots der Anomaly-Scores fuer Audit und Analyse.
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|--------------|
|
||||
| `id` | UUID | Primaerschluessel |
|
||||
| `user_id` | VARCHAR(255) | Benutzer-Identifikation |
|
||||
| `score` | DECIMAL(5,2) | Aktueller Anomaly-Score |
|
||||
| `throttle_level` | SMALLINT | Aktueller Throttle-Level (0-3) |
|
||||
| `triggered_rules` | JSONB | Welche Regeln ausgeloest wurden |
|
||||
| `endpoint_diversity_count` | INT | Anzahl verschiedener Kategorien |
|
||||
| `request_count_1h` | INT | Anfragen in der letzten Stunde |
|
||||
| `snapshot_at` | TIMESTAMPTZ | Zeitpunkt des Snapshots |
|
||||
|
||||
### sdk_protection_tiers
|
||||
|
||||
Konfigurierbare Quota-Tiers, editierbar ueber die Admin-API.
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|--------------|
|
||||
| `tier_name` | VARCHAR(50) | Name des Tiers (free, standard, enterprise) |
|
||||
| `quota_per_minute` | INT | Maximale Anfragen pro Minute |
|
||||
| `quota_per_hour` | INT | Maximale Anfragen pro Stunde |
|
||||
| `quota_per_day` | INT | Maximale Anfragen pro Tag |
|
||||
| `quota_per_month` | INT | Maximale Anfragen pro Monat |
|
||||
| `diversity_threshold` | INT | Max verschiedene Kategorien pro Stunde |
|
||||
| `burst_threshold` | INT | Max gleiche Kategorie in 2 Minuten |
|
||||
|
||||
---
|
||||
|
||||
## 10. Konfiguration
|
||||
|
||||
Die Middleware wird in `main.py` registriert:
|
||||
|
||||
```python
|
||||
from middleware import SDKProtectionMiddleware
|
||||
|
||||
app.add_middleware(SDKProtectionMiddleware)
|
||||
```
|
||||
|
||||
Alle Parameter koennen ueber die `SDKProtectionConfig` Dataclass angepasst werden. Die wichtigsten Umgebungsvariablen:
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
|----------|---------|--------------|
|
||||
| `VALKEY_URL` | `redis://localhost:6379` | Verbindung zur Valkey-Instanz |
|
||||
| `SDK_WATERMARK_SECRET` | (generiert) | HMAC-Secret fuer Watermarks |
|
||||
|
||||
---
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Die Middleware wird durch 14 automatisierte Tests abgedeckt:
|
||||
|
||||
```bash
|
||||
# Alle SDK Protection Tests ausfuehren
|
||||
docker compose run --rm --no-deps backend \
|
||||
python -m pytest tests/test_middleware.py -v -k sdk
|
||||
```
|
||||
|
||||
| Test | Prueft |
|
||||
|------|--------|
|
||||
| `test_allows_normal_request` | Normaler Request wird durchgelassen |
|
||||
| `test_blocks_after_quota_exceeded` | 429 bei Quota-Ueberschreitung |
|
||||
| `test_diversity_tracking_increments_score` | Viele Kategorien erhoehen den Score |
|
||||
| `test_burst_detection` | Schnelle gleiche Anfragen erhoehen den Score |
|
||||
| `test_sequential_enumeration_detection` | Alphabetische Muster werden erkannt |
|
||||
| `test_progressive_throttling_level_1` | Delay bei Score >= 30 |
|
||||
| `test_progressive_throttling_level_3_blocks` | Block bei Score >= 85 |
|
||||
| `test_score_decay_over_time` | Score sinkt ueber die Zeit |
|
||||
| `test_skips_non_protected_paths` | Nicht-SDK-Pfade bleiben frei |
|
||||
| `test_watermark_header_present` | X-BP-Trace Header vorhanden |
|
||||
| `test_fallback_to_inmemory` | Funktioniert ohne Valkey |
|
||||
| `test_no_user_passes_through` | Anonyme Requests passieren |
|
||||
| `test_category_extraction` | Korrekte Kategorie-Zuordnung |
|
||||
| `test_quota_headers_present` | Response-Headers vorhanden |
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
5
levis-holzbau/.dockerignore
Normal file
5
levis-holzbau/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
27
levis-holzbau/Dockerfile
Normal file
27
levis-holzbau/Dockerfile
Normal 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"]
|
||||
25
levis-holzbau/app/globals.css
Normal file
25
levis-holzbau/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
levis-holzbau/app/layout.tsx
Normal file
21
levis-holzbau/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
levis-holzbau/app/page.tsx
Normal file
71
levis-holzbau/app/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
120
levis-holzbau/app/projekte/[slug]/page.tsx
Normal file
120
levis-holzbau/app/projekte/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
levis-holzbau/app/projekte/page.tsx
Normal file
59
levis-holzbau/app/projekte/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
levis-holzbau/app/sicherheit/page.tsx
Normal file
101
levis-holzbau/app/sicherheit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
levis-holzbau/app/ueber/page.tsx
Normal file
83
levis-holzbau/app/ueber/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
levis-holzbau/components/AgeBadge.tsx
Normal file
7
levis-holzbau/components/AgeBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
levis-holzbau/components/DifficultyBadge.tsx
Normal file
15
levis-holzbau/components/DifficultyBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
levis-holzbau/components/Footer.tsx
Normal file
17
levis-holzbau/components/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
levis-holzbau/components/HeroSection.tsx
Normal file
95
levis-holzbau/components/HeroSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
levis-holzbau/components/Logo.tsx
Normal file
35
levis-holzbau/components/Logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
levis-holzbau/components/Navbar.tsx
Normal file
44
levis-holzbau/components/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
levis-holzbau/components/ProjectCard.tsx
Normal file
42
levis-holzbau/components/ProjectCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
levis-holzbau/components/ProjectIllustration.tsx
Normal file
132
levis-holzbau/components/ProjectIllustration.tsx
Normal 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}</>
|
||||
}
|
||||
10
levis-holzbau/components/SafetyTip.tsx
Normal file
10
levis-holzbau/components/SafetyTip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
levis-holzbau/components/StepCard.tsx
Normal file
15
levis-holzbau/components/StepCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
levis-holzbau/components/ToolIcon.tsx
Normal file
14
levis-holzbau/components/ToolIcon.tsx
Normal 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" />
|
||||
}
|
||||
15
levis-holzbau/lib/animations.ts
Normal file
15
levis-holzbau/lib/animations.ts
Normal 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 },
|
||||
}
|
||||
214
levis-holzbau/lib/projects.ts
Normal file
214
levis-holzbau/lib/projects.ts
Normal 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)
|
||||
}
|
||||
18
levis-holzbau/lib/types.ts
Normal file
18
levis-holzbau/lib/types.ts
Normal 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
6
levis-holzbau/next-env.d.ts
vendored
Normal 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.
|
||||
6
levis-holzbau/next.config.js
Normal file
6
levis-holzbau/next.config.js
Normal 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
2017
levis-holzbau/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
levis-holzbau/package.json
Normal file
25
levis-holzbau/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
levis-holzbau/postcss.config.mjs
Normal file
8
levis-holzbau/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
26
levis-holzbau/tailwind.config.ts
Normal file
26
levis-holzbau/tailwind.config.ts
Normal 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
|
||||
40
levis-holzbau/tsconfig.json
Normal file
40
levis-holzbau/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
47
mkdocs.yml
47
mkdocs.yml
@@ -1,5 +1,5 @@
|
||||
site_name: BreakPilot Core - Dokumentation
|
||||
site_url: https://macmini:8009
|
||||
site_name: Breakpilot Dokumentation
|
||||
site_url: http://macmini:8009
|
||||
docs_dir: docs-src
|
||||
site_dir: docs-site
|
||||
|
||||
@@ -9,16 +9,14 @@ theme:
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: teal
|
||||
accent: teal
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Dark Mode
|
||||
name: Dark Mode aktivieren
|
||||
- scheme: slate
|
||||
primary: teal
|
||||
accent: teal
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Light Mode
|
||||
name: Light Mode aktivieren
|
||||
features:
|
||||
- search.highlight
|
||||
- search.suggest
|
||||
@@ -27,6 +25,7 @@ theme:
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
- toc.follow
|
||||
|
||||
plugins:
|
||||
@@ -53,18 +52,48 @@ markdown_extensions:
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: http://macmini:3003/breakpilot/breakpilot-pwa
|
||||
|
||||
nav:
|
||||
- Start: index.md
|
||||
- Erste Schritte:
|
||||
- Einrichtung: getting-started/environment-setup.md
|
||||
- Umgebung einrichten: getting-started/environment-setup.md
|
||||
- Mac Mini Setup: getting-started/mac-mini-setup.md
|
||||
- Architektur:
|
||||
- System-Architektur: architecture/system-architecture.md
|
||||
- Systemuebersicht: architecture/system-architecture.md
|
||||
- Auth-System: architecture/auth-system.md
|
||||
- Mail & RBAC: architecture/mail-rbac-architecture.md
|
||||
- Mail-RBAC: architecture/mail-rbac-architecture.md
|
||||
- Multi-Agent: architecture/multi-agent.md
|
||||
- Secrets Management: architecture/secrets-management.md
|
||||
- DevSecOps: architecture/devsecops.md
|
||||
- SDK Protection: architecture/sdk-protection.md
|
||||
- Environments: architecture/environments.md
|
||||
- Zeugnis-System: architecture/zeugnis-system.md
|
||||
- Services:
|
||||
- KI-Daten-Pipeline:
|
||||
- Uebersicht: services/ki-daten-pipeline/index.md
|
||||
- Architektur: services/ki-daten-pipeline/architecture.md
|
||||
- Klausur-Service:
|
||||
- Uebersicht: services/klausur-service/index.md
|
||||
- BYOEH Systemerklaerung: services/klausur-service/byoeh-system-erklaerung.md
|
||||
- BYOEH Architektur: services/klausur-service/BYOEH-Architecture.md
|
||||
- BYOEH Developer Guide: services/klausur-service/BYOEH-Developer-Guide.md
|
||||
- NiBiS Pipeline: services/klausur-service/NiBiS-Ingestion-Pipeline.md
|
||||
- OCR Labeling: services/klausur-service/OCR-Labeling-Spec.md
|
||||
- OCR Compare: services/klausur-service/OCR-Compare.md
|
||||
- RAG Admin: services/klausur-service/RAG-Admin-Spec.md
|
||||
- Worksheet Editor: services/klausur-service/Worksheet-Editor-Architecture.md
|
||||
- Voice-Service: services/voice-service/index.md
|
||||
- Agent-Core: services/agent-core/index.md
|
||||
- AI-Compliance-SDK:
|
||||
- Uebersicht: services/ai-compliance-sdk/index.md
|
||||
- Architektur: services/ai-compliance-sdk/ARCHITECTURE.md
|
||||
- Developer Guide: services/ai-compliance-sdk/DEVELOPER.md
|
||||
- Auditor Dokumentation: services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md
|
||||
- SBOM: services/ai-compliance-sdk/SBOM.md
|
||||
- API:
|
||||
- Backend API: api/backend-api.md
|
||||
- Entwicklung:
|
||||
|
||||
@@ -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;
|
||||
@@ -198,7 +198,66 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# Admin Lehrer Frontend
|
||||
# SDK pages & API proxy → Compliance Admin
|
||||
location /sdk/ {
|
||||
set $upstream_compliance bp-compliance-admin:3000;
|
||||
proxy_pass http://$upstream_compliance;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
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;
|
||||
}
|
||||
|
||||
location /api/sdk/ {
|
||||
set $upstream_compliance bp-compliance-admin:3000;
|
||||
proxy_pass http://$upstream_compliance;
|
||||
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;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
|
||||
# Next.js static assets for SDK pages
|
||||
location /_next/ {
|
||||
set $upstream_admin_lehrer bp-lehrer-admin:3000;
|
||||
proxy_pass http://$upstream_admin_lehrer;
|
||||
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;
|
||||
|
||||
# Try compliance admin as fallback for SDK chunks
|
||||
proxy_intercept_errors on;
|
||||
error_page 404 = @compliance_next;
|
||||
}
|
||||
|
||||
location @compliance_next {
|
||||
set $upstream_compliance bp-compliance-admin:3000;
|
||||
proxy_pass http://$upstream_compliance;
|
||||
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;
|
||||
}
|
||||
|
||||
# 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;
|
||||
proxy_pass http://$upstream_admin_lehrer;
|
||||
@@ -514,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +593,7 @@ server {
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
set $upstream_edu_search breakpilot-edu-search:8088;
|
||||
set $upstream_edu_search bp-lehrer-edu-search:8088;
|
||||
proxy_pass http://$upstream_edu_search;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -570,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
|
||||
# =========================================================
|
||||
@@ -585,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;
|
||||
@@ -599,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;
|
||||
@@ -613,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;
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
--docs-core: #14b8a6;
|
||||
--docs-lehrer: #0ea5e9;
|
||||
--docs-compliance: #8b5cf6;
|
||||
|
||||
--pitch-500: #f59e0b;
|
||||
--pitch-400: #fbbf24;
|
||||
--pitch-600: #d97706;
|
||||
--pitch-bg: rgba(245, 158, 11, 0.08);
|
||||
--pitch-bg-hover: rgba(245, 158, 11, 0.14);
|
||||
--pitch-border: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
/* ── Light Theme ── */
|
||||
@@ -95,6 +102,9 @@
|
||||
--core-bg: rgba(100, 116, 139, 0.1);
|
||||
--core-bg-hover: rgba(100, 116, 139, 0.18);
|
||||
--core-border: rgba(148, 163, 184, 0.15);
|
||||
--pitch-bg: rgba(245, 158, 11, 0.1);
|
||||
--pitch-bg-hover: rgba(245, 158, 11, 0.18);
|
||||
--pitch-border: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -176,6 +186,57 @@
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.columns-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.column-header-lehrer {
|
||||
color: var(--lehrer-500);
|
||||
background: var(--lehrer-bg);
|
||||
}
|
||||
|
||||
.column-header-compliance {
|
||||
color: var(--compliance-500);
|
||||
background: var(--compliance-bg);
|
||||
}
|
||||
|
||||
.column-header-core {
|
||||
color: var(--core-400);
|
||||
background: var(--core-bg);
|
||||
}
|
||||
|
||||
.column .card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.columns-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
border-radius: 14px;
|
||||
@@ -214,6 +275,12 @@
|
||||
}
|
||||
.card-core:hover { background: var(--core-bg-hover); }
|
||||
|
||||
.card-pitch {
|
||||
border-color: var(--pitch-border);
|
||||
background: var(--pitch-bg);
|
||||
}
|
||||
.card-pitch:hover { background: var(--pitch-bg-hover); }
|
||||
|
||||
.card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -258,6 +325,7 @@
|
||||
.stripe-docs-core { background: var(--docs-core); }
|
||||
.stripe-docs-lehrer { background: var(--docs-lehrer); }
|
||||
.stripe-docs-compliance { background: var(--docs-compliance); }
|
||||
.stripe-pitch { background: var(--pitch-500); }
|
||||
|
||||
.divider {
|
||||
max-width: 1100px;
|
||||
@@ -555,116 +623,112 @@
|
||||
<div style="max-width: 1100px; margin: 0 auto;">
|
||||
<div class="section-title">Projekte</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="columns-layout">
|
||||
|
||||
<a class="card card-core" href="https://macmini:3008/dashboard">
|
||||
<div class="stripe stripe-core"></div>
|
||||
<div class="card-body">
|
||||
<h3>Admin Core</h3>
|
||||
<p>Infrastruktur, Services, Monitoring</p>
|
||||
<div class="url">macmini:3008/dashboard</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- ── Lehrer (links) ── -->
|
||||
<div class="column">
|
||||
<div class="column-header column-header-lehrer">Lehrer</div>
|
||||
|
||||
<a class="card card-lehrer" href="https://macmini:3002/dashboard">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Admin Lehrer</h3>
|
||||
<p>Verwaltung, AI Tools, Klausuren</p>
|
||||
<div class="url">macmini:3002</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-lehrer" href="https://macmini:3002/dashboard">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Admin Lehrer</h3>
|
||||
<p>Verwaltung, AI Tools, Klausuren</p>
|
||||
<div class="url">macmini:3002</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card card-compliance" href="https://macmini:3007/sdk">
|
||||
<div class="stripe stripe-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Compliance SDK</h3>
|
||||
<p>DSGVO, Audit, GRC — Alle SDK-Module</p>
|
||||
<div class="url">macmini:3007/sdk</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-lehrer" href="https://macmini">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Studio v2</h3>
|
||||
<p>Lehrer- und Schueler-Interface</p>
|
||||
<div class="url">macmini</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card card-lehrer" href="https://macmini">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Studio v2</h3>
|
||||
<p>Lehrer- und Schueler-Interface</p>
|
||||
<div class="url">macmini</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-lehrer" href="https://macmini:3000">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Website</h3>
|
||||
<p>Oeffentliche BreakPilot Website</p>
|
||||
<div class="url">macmini:3000</div>
|
||||
</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-lehrer" href="http://macmini:8010/">
|
||||
<div class="stripe stripe-docs-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Dokumentation</h3>
|
||||
<p>Klausur, Voice, Agent-Core, Studio</p>
|
||||
<div class="url">macmini:8010</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a class="card card-lehrer" href="https://macmini:3000">
|
||||
<div class="stripe stripe-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Website</h3>
|
||||
<p>Oeffentliche BreakPilot Website</p>
|
||||
<div class="url">macmini:3000</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- ── Compliance (mitte) ── -->
|
||||
<div class="column">
|
||||
<div class="column-header column-header-compliance">Compliance</div>
|
||||
|
||||
<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 & Auswahltabellen</p>
|
||||
<div class="url">macmini:3007/dashboard</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-compliance" href="https://macmini:3007/sdk">
|
||||
<div class="stripe stripe-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Compliance SDK</h3>
|
||||
<p>DSGVO, Audit, GRC — Alle SDK-Module</p>
|
||||
<div class="url">macmini:3007/sdk</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card card-compliance" href="https://macmini:3010/compliance-hub/">
|
||||
<div class="stripe stripe-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Comply Website</h3>
|
||||
<p>Marketing-Website fuer den KI Compliance Hub</p>
|
||||
<div class="url">macmini:3010/compliance-hub</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-compliance" href="https://macmini:3010/compliance-hub/">
|
||||
<div class="stripe stripe-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Comply Website</h3>
|
||||
<p>Marketing-Website fuer den KI Compliance Hub</p>
|
||||
<div class="url">macmini:3010/compliance-hub</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<a class="card card-pitch" href="http://macmini:3012">
|
||||
<div class="stripe stripe-pitch"></div>
|
||||
<div class="card-body">
|
||||
<h3>Pitch Deck</h3>
|
||||
<p>Interaktives Investor Pitch Deck — ComplAI</p>
|
||||
<div class="url">macmini:3012</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<hr class="divider">
|
||||
<a class="card card-compliance" href="http://macmini:8011/">
|
||||
<div class="stripe stripe-docs-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Dokumentation</h3>
|
||||
<p>AI-SDK, Auditor-Doku, SBOM</p>
|
||||
<div class="url">macmini:8011</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ── Dokumentation ── -->
|
||||
<div style="max-width: 1100px; margin: 0 auto;">
|
||||
<div class="section-title">Dokumentation</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<!-- ── Core (rechts) ── -->
|
||||
<div class="column">
|
||||
<div class="column-header column-header-core">Core</div>
|
||||
|
||||
<a class="card card-core" href="http://macmini:8009/">
|
||||
<div class="stripe stripe-docs-core"></div>
|
||||
<div class="card-body">
|
||||
<h3>Core Dokumentation</h3>
|
||||
<p>Architektur, Auth, DevSecOps, RAG</p>
|
||||
<div class="url">macmini:8009</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-core" href="https://macmini:3008/dashboard">
|
||||
<div class="stripe stripe-core"></div>
|
||||
<div class="card-body">
|
||||
<h3>Admin Core</h3>
|
||||
<p>Infrastruktur, Services, Monitoring</p>
|
||||
<div class="url">macmini:3008/dashboard</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card card-lehrer" href="http://macmini:8010/">
|
||||
<div class="stripe stripe-docs-lehrer"></div>
|
||||
<div class="card-body">
|
||||
<h3>Lehrer Dokumentation</h3>
|
||||
<p>Klausur, Voice, Agent-Core, Studio</p>
|
||||
<div class="url">macmini:8010</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card card-compliance" href="http://macmini:8011/">
|
||||
<div class="stripe stripe-docs-compliance"></div>
|
||||
<div class="card-body">
|
||||
<h3>Compliance Dokumentation</h3>
|
||||
<p>AI-SDK, Auditor-Doku, SBOM</p>
|
||||
<div class="url">macmini:8011</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card card-core" href="http://macmini:8009/">
|
||||
<div class="stripe stripe-docs-core"></div>
|
||||
<div class="card-body">
|
||||
<h3>Dokumentation</h3>
|
||||
<p>Architektur, Auth, DevSecOps, RAG</p>
|
||||
<div class="url">macmini:8009</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
16
paddleocr-service/Dockerfile
Normal file
16
paddleocr-service/Dockerfile
Normal 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
110
paddleocr-service/main.py
Normal 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],
|
||||
}
|
||||
7
paddleocr-service/requirements.txt
Normal file
7
paddleocr-service/requirements.txt
Normal 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
|
||||
@@ -18,11 +18,12 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
||||
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
|
||||
|
||||
## Kernbotschaften (IMMER betonen wenn passend)
|
||||
1. AI-First: "Alles was durch KI loesbar ist, wird durch KI geloest. Kein klassischer Support, kein grosses Sales-Team."
|
||||
2. Skalierbarkeit: "10x Kunden ≠ 10x Personal. Die KI skaliert mit."
|
||||
3. Hardware-Differenzierung: "Datensouveraenitaet durch Self-Hosting auf Apple-Hardware."
|
||||
4. Kostenstruktur: "18 Mitarbeiter in 2030 bei 8.4 Mio EUR Umsatz."
|
||||
5. Marktchance: "12.4 Mrd EUR TAM, regulatorisch getrieben."
|
||||
1. Zielmarkt: "Maschinen- und Anlagenbauer (VDMA ~3.600 Mitglieder in DE, ~5.000 DACH) die eigene Software/Firmware entwickeln."
|
||||
2. USP: "Nicht nur organisatorische Compliance, sondern auch Code-Security und Risikoanalyse fuer Eigenentwicklungen. Das koennen Proliance, DataGuard und heyData NICHT."
|
||||
3. Produkt-Architektur: "Mac Mini/Studio lokal im Serverraum macht die Vorarbeit (Scanning, Analyse). Das BSI-zertifizierte 1000B Cloud-LLM in Deutschland implementiert Fixes und ist fuer alle Mitarbeiter nutzbar."
|
||||
4. Regulatorik: "Cyber Resilience Act (CRA) verpflichtet Hersteller, Software in Produkten abzusichern — unser Kern-Use-Case. Plus DSGVO, AI Act und NIS2."
|
||||
5. Skalierbarkeit: "AI-First — 10x Kunden ≠ 10x Personal. 380 Kunden in 2030 bei 5.5 Mio EUR Umsatz."
|
||||
6. Marktchance: "8.7 Mrd EUR TAM, SOM 7.2 Mio EUR (500 DACH-Maschinenbauer x 14.400 EUR/Jahr)."
|
||||
|
||||
## Kommunikationsstil
|
||||
- Professionell, knapp und ueberzeugend
|
||||
@@ -179,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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -28,6 +28,12 @@ import TeamSlide from './slides/TeamSlide'
|
||||
import FinancialsSlide from './slides/FinancialsSlide'
|
||||
import TheAskSlide from './slides/TheAskSlide'
|
||||
import AIQASlide from './slides/AIQASlide'
|
||||
import AssumptionsSlide from './slides/AssumptionsSlide'
|
||||
import ArchitectureSlide from './slides/ArchitectureSlide'
|
||||
import GTMSlide from './slides/GTMSlide'
|
||||
import RegulatorySlide from './slides/RegulatorySlide'
|
||||
import EngineeringSlide from './slides/EngineeringSlide'
|
||||
import AIPipelineSlide from './slides/AIPipelineSlide'
|
||||
|
||||
interface PitchDeckProps {
|
||||
lang: Language
|
||||
@@ -91,7 +97,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
|
||||
switch (nav.currentSlide) {
|
||||
case 'cover':
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} />
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||
case 'problem':
|
||||
return <ProblemSlide lang={lang} />
|
||||
case 'solution':
|
||||
@@ -116,6 +122,18 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
return <TheAskSlide lang={lang} funding={data.funding} />
|
||||
case 'ai-qa':
|
||||
return <AIQASlide lang={lang} />
|
||||
case 'annex-assumptions':
|
||||
return <AssumptionsSlide lang={lang} />
|
||||
case 'annex-architecture':
|
||||
return <ArchitectureSlide lang={lang} />
|
||||
case 'annex-gtm':
|
||||
return <GTMSlide lang={lang} />
|
||||
case 'annex-regulatory':
|
||||
return <RegulatorySlide lang={lang} />
|
||||
case 'annex-engineering':
|
||||
return <EngineeringSlide lang={lang} />
|
||||
case 'annex-aipipeline':
|
||||
return <AIPipelineSlide lang={lang} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export default function SlideContainer({ children, slideKey, direction }: SlideC
|
||||
opacity: { duration: 0.3 },
|
||||
scale: { duration: 0.3 },
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center overflow-y-auto"
|
||||
className="absolute inset-0 flex justify-center overflow-y-auto"
|
||||
>
|
||||
<div className="w-full max-w-6xl mx-auto px-6 py-12 md:py-16">
|
||||
<div className="w-full max-w-6xl mx-auto px-6 py-12 md:py-16 my-auto">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
329
pitch-deck/components/slides/AIPipelineSlide.tsx
Normal file
329
pitch-deck/components/slides/AIPipelineSlide.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
Database,
|
||||
FileText,
|
||||
Bot,
|
||||
Zap,
|
||||
Layers,
|
||||
ArrowRight,
|
||||
Activity,
|
||||
Shield,
|
||||
Cpu,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
Gauge,
|
||||
Network,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface AIPipelineSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
type PipelineTab = 'rag' | 'agents' | 'quality'
|
||||
|
||||
export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
const [activeTab, setActiveTab] = useState<PipelineTab>('rag')
|
||||
|
||||
const heroStats = [
|
||||
{ value: '19', label: de ? 'Indexierte Verordnungen' : 'Indexed Regulations', sub: 'DSGVO · AI Act · NIS2 · CRA · ePrivacy · ...', color: 'text-indigo-400' },
|
||||
{ value: '7', label: de ? 'Autonome Agenten' : 'Autonomous Agents', sub: de ? 'SOUL-basiert · Orchestriert' : 'SOUL-based · Orchestrated', color: 'text-purple-400' },
|
||||
{ value: '5', label: de ? 'KI-Modelle lokal' : 'Local AI Models', sub: 'Llama 3.2 · Qwen 2.5 · BGE-M3 · TrOCR · CrossEncoder', color: 'text-emerald-400' },
|
||||
{ value: '97', label: de ? 'Golden-Suite Tests' : 'Golden Suite Tests', sub: de ? 'Automatische Qualitaetssicherung' : 'Automatic Quality Assurance', color: 'text-amber-400' },
|
||||
]
|
||||
|
||||
const tabs: { id: PipelineTab; label: string; icon: typeof Brain }[] = [
|
||||
{ id: 'rag', label: de ? 'RAG-Pipeline' : 'RAG Pipeline', icon: Search },
|
||||
{ id: 'agents', label: de ? 'Multi-Agent-System' : 'Multi-Agent System', icon: Bot },
|
||||
{ id: 'quality', label: de ? 'Qualitaetssicherung' : 'Quality Assurance', icon: Gauge },
|
||||
]
|
||||
|
||||
// RAG Pipeline content
|
||||
const ragPipelineSteps = [
|
||||
{
|
||||
icon: FileText,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10 border-blue-500/20',
|
||||
title: de ? '1. Ingestion' : '1. Ingestion',
|
||||
items: de
|
||||
? ['PDF-Upload, URL-Crawling, API-Import', 'Automatische Spracherkennung (DE/EN)', 'Semantisches Chunking (rekursiv, 512 Tokens)', 'Metadaten-Extraktion (Verordnung, Artikel, Absatz)']
|
||||
: ['PDF upload, URL crawling, API import', 'Automatic language detection (DE/EN)', 'Semantic chunking (recursive, 512 tokens)', 'Metadata extraction (regulation, article, paragraph)'],
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? '2. Embedding' : '2. Embedding',
|
||||
items: de
|
||||
? ['BGE-M3 Multilingual Embeddings (lokal)', 'CrossEncoder Re-Ranking (lokal)', 'HyDE: Hypothetical Document Embeddings', 'Lazy Model Loading (Speicher-optimiert)']
|
||||
: ['BGE-M3 multilingual embeddings (local)', 'CrossEncoder re-ranking (local)', 'HyDE: Hypothetical Document Embeddings', 'Lazy model loading (memory-optimized)'],
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? '3. Vektorspeicher' : '3. Vector Store',
|
||||
items: de
|
||||
? ['Qdrant Vector DB (Self-hosted)', '5 Collections: Legal Corpus, DSFA, Compliance, Dokumente, Agenten-Wissen', 'MinIO Object Storage fuer Quelldokumente', 'Automatische Re-Indexierung bei Updates']
|
||||
: ['Qdrant Vector DB (self-hosted)', '5 Collections: Legal Corpus, DSFA, Compliance, Documents, Agent Knowledge', 'MinIO object storage for source documents', 'Automatic re-indexing on updates'],
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
color: 'text-indigo-400',
|
||||
bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? '4. Hybrid Search' : '4. Hybrid Search',
|
||||
items: de
|
||||
? ['Dense Retrieval (70%) + BM25 Keyword (30%)', 'Deutsche Komposita-Zerlegung', 'Cross-Encoder Re-Ranking der Top-K Ergebnisse', 'Quellen-Attribution mit Artikel-Referenz']
|
||||
: ['Dense retrieval (70%) + BM25 keyword (30%)', 'German compound word decomposition', 'Cross-encoder re-ranking of top-K results', 'Source attribution with article reference'],
|
||||
},
|
||||
]
|
||||
|
||||
// Multi-Agent System content
|
||||
const agents = [
|
||||
{ name: de ? 'Compliance-Berater' : 'Compliance Advisor', soul: 'compliance-advisor.soul.md', desc: de ? 'Beantwortet Compliance-Fragen mit RAG-Kontext' : 'Answers compliance questions with RAG context', color: 'text-indigo-400' },
|
||||
{ name: de ? 'Audit-Agent' : 'Audit Agent', soul: 'quality-judge.soul.md', desc: de ? 'Prueft Dokumente gegen regulatorische Anforderungen' : 'Checks documents against regulatory requirements', color: 'text-emerald-400' },
|
||||
{ name: de ? 'Dokument-Agent' : 'Drafting Agent', soul: 'drafting-agent.soul.md', desc: de ? 'Erstellt Compliance-Dokumente und Policies' : 'Creates compliance documents and policies', color: 'text-purple-400' },
|
||||
{ name: 'Orchestrator', soul: 'orchestrator.soul.md', desc: de ? 'Task-Routing und Koordination aller Agenten' : 'Task routing and coordination of all agents', color: 'text-amber-400' },
|
||||
{ name: de ? 'Alert-Agent' : 'Alert Agent', soul: 'alert-agent.soul.md', desc: de ? 'Monitoring, Fristen und Benachrichtigungen' : 'Monitoring, deadlines and notifications', color: 'text-red-400' },
|
||||
{ name: de ? 'Tutor-Agent' : 'Tutor Agent', soul: 'tutor-agent.soul.md', desc: de ? 'Interaktive Compliance-Schulungen' : 'Interactive compliance training', color: 'text-blue-400' },
|
||||
]
|
||||
|
||||
const agentInfra = [
|
||||
{ icon: MessageSquare, label: 'SOUL Files', desc: de ? 'Deklarative Agenten-Persoenlichkeit in Markdown' : 'Declarative agent personality in Markdown' },
|
||||
{ icon: Brain, label: 'Shared Brain', desc: de ? 'Gemeinsamer Wissensspeicher + Langzeitgedaechtnis' : 'Shared knowledge store + long-term memory' },
|
||||
{ icon: Network, label: 'Message Bus', desc: de ? 'Valkey/Redis · Pub/Sub · Task Queue' : 'Valkey/Redis · Pub/Sub · Task Queue' },
|
||||
{ icon: Activity, label: 'Session Manager', desc: de ? 'Heartbeat · Checkpoints · Recovery' : 'Heartbeat · Checkpoints · Recovery' },
|
||||
]
|
||||
|
||||
// Quality Assurance content
|
||||
const qaFeatures = [
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'text-emerald-400',
|
||||
title: de ? 'BQAS — Quality Assurance System' : 'BQAS — Quality Assurance System',
|
||||
items: de
|
||||
? ['97 Golden-Suite-Referenztests fuer Regressionserkennung', 'Synthetische Testgenerierung per LLM', 'RAG-Retrieval-Accuracy und Correction-Tests', 'Precision, Recall, F1 Tracking ueber alle Releases']
|
||||
: ['97 golden suite reference tests for regression detection', 'Synthetic test generation via LLM', 'RAG retrieval accuracy and correction tests', 'Precision, recall, F1 tracking across all releases'],
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
color: 'text-indigo-400',
|
||||
title: de ? 'LLM Evaluation & Vergleich' : 'LLM Evaluation & Comparison',
|
||||
items: de
|
||||
? ['Side-by-Side-Vergleich: Ollama lokal vs. OpenAI vs. Claude', 'Latenz-, Token- und Qualitaets-Metriken pro Provider', 'Automatischer Fallback bei Provider-Ausfall', 'Self-RAG: Selbstreflektierende Antwortvalidierung']
|
||||
: ['Side-by-side comparison: Ollama local vs. OpenAI vs. Claude', 'Latency, token and quality metrics per provider', 'Automatic fallback on provider failure', 'Self-RAG: Self-reflective answer validation'],
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
color: 'text-purple-400',
|
||||
title: de ? 'Document Intelligence' : 'Document Intelligence',
|
||||
items: de
|
||||
? ['TrOCR Handschrifterkennung mit LoRA Fine-Tuning', 'Multi-OCR-Pipeline: 5 Methoden parallel (Vision LLM, Tesseract, OpenCV, ...)', 'Labeling-Interface fuer Trainingsdaten-Erstellung (DSGVO-konform, lokal)', 'OpenCV Document Reconstruction (Deskew, Dewarp, Binarisierung)']
|
||||
: ['TrOCR handwriting recognition with LoRA fine-tuning', 'Multi-OCR pipeline: 5 methods in parallel (Vision LLM, Tesseract, OpenCV, ...)', 'Labeling interface for training data creation (GDPR-compliant, local)', 'OpenCV document reconstruction (deskew, dewarp, binarization)'],
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
color: 'text-amber-400',
|
||||
title: de ? 'GPU & Training' : 'GPU & Training',
|
||||
items: de
|
||||
? ['Lokales Training auf Apple Silicon (M4 Max, 64 GB unified)', 'vast.ai Integration fuer Cloud-GPU bei Bedarf', 'LoRA/QLoRA Fine-Tuning mit konfigurierbaren Hyperparametern', 'SSE-Streaming fuer Echtzeit-Trainingsmetriken (Loss, Accuracy, F1)']
|
||||
: ['Local training on Apple Silicon (M4 Max, 64 GB unified)', 'vast.ai integration for cloud GPU on demand', 'LoRA/QLoRA fine-tuning with configurable hyperparameters', 'SSE streaming for real-time training metrics (loss, accuracy, F1)'],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-5">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-2">
|
||||
<GradientText>{i.annex.aipipeline.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.aipipeline.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Hero Stats */}
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
{heroStats.map((stat, idx) => (
|
||||
<div key={idx} className="border border-white/[0.08] rounded-xl p-2.5 bg-white/[0.03] text-center">
|
||||
<p className={`text-2xl font-black tracking-tight ${stat.color}`}>{stat.value}</p>
|
||||
<p className="text-[11px] font-semibold text-white/70">{stat.label}</p>
|
||||
<p className="text-[9px] text-white/30 mt-0.5 leading-tight">{stat.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<FadeInView delay={0.15}>
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Tab Content */}
|
||||
<FadeInView delay={0.2} key={activeTab}>
|
||||
{activeTab === 'rag' && (
|
||||
<div>
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="flex items-center justify-center gap-1 mb-4 flex-wrap">
|
||||
{[
|
||||
{ icon: FileText, label: de ? 'Dokumente' : 'Documents' },
|
||||
{ icon: Layers, label: 'Chunking' },
|
||||
{ icon: Cpu, label: 'BGE-M3' },
|
||||
{ icon: Database, label: 'Qdrant' },
|
||||
{ icon: Search, label: 'Hybrid Search' },
|
||||
{ icon: Brain, label: 'LLM' },
|
||||
].map((step, idx, arr) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-white/[0.05] border border-white/[0.08]">
|
||||
<step.icon className="w-3 h-3 text-indigo-400" />
|
||||
<span className="text-[10px] text-white/50">{step.label}</span>
|
||||
</div>
|
||||
{idx < arr.length - 1 && <ArrowRight className="w-3 h-3 text-white/20" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pipeline Steps */}
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{ragPipelineSteps.map((step, idx) => {
|
||||
const Icon = step.icon
|
||||
return (
|
||||
<div key={idx} className={`border rounded-xl p-3 ${step.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${step.color}`} />
|
||||
<h3 className="text-xs font-bold text-white">{step.title}</h3>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{step.items.map((item, iidx) => (
|
||||
<li key={iidx} className="flex items-start gap-1.5 text-[11px] text-white/50">
|
||||
<span className={`w-1 h-1 rounded-full mt-1.5 ${step.color} bg-current shrink-0`} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'agents' && (
|
||||
<div className="grid md:grid-cols-12 gap-4">
|
||||
{/* Agent List */}
|
||||
<div className="md:col-span-7">
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-white/40" />
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider">
|
||||
{de ? 'Agenten-Fleet' : 'Agent Fleet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{agents.map((agent, idx) => (
|
||||
<div key={idx} className="p-2 rounded-lg bg-white/[0.03] border border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${agent.color} bg-current`} />
|
||||
<p className="text-xs font-bold text-white/80">{agent.name}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40 leading-tight">{agent.desc}</p>
|
||||
<p className="text-[9px] font-mono text-white/20 mt-1">{agent.soul}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
{/* Agent Infrastructure */}
|
||||
<div className="md:col-span-5">
|
||||
<GlassCard hover={false} className="p-4 h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Network className="w-4 h-4 text-white/40" />
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider">
|
||||
{de ? 'Infrastruktur' : 'Infrastructure'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{agentInfra.map((inf, idx) => {
|
||||
const Icon = inf.icon
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-3.5 h-3.5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-white/70">{inf.label}</p>
|
||||
<p className="text-[10px] text-white/40">{inf.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<p className="text-[10px] text-white/20">
|
||||
{de
|
||||
? 'Alle Agenten laufen lokal · Kein API-Schluessel erforderlich · DSGVO-konform'
|
||||
: 'All agents run locally · No API key required · GDPR-compliant'}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{qaFeatures.map((feat, idx) => {
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<GlassCard key={idx} hover={false} className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${feat.color}`} />
|
||||
<h3 className="text-xs font-bold text-white">{feat.title}</h3>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{feat.items.map((item, iidx) => (
|
||||
<li key={iidx} className="flex items-start gap-1.5 text-[11px] text-white/50">
|
||||
<span className={`w-1 h-1 rounded-full mt-1.5 ${feat.color} bg-current shrink-0`} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
pitch-deck/components/slides/ArchitectureSlide.tsx
Normal file
130
pitch-deck/components/slides/ArchitectureSlide.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { Server, Cpu, Shield, Database, Globe, Lock, Layers, Workflow } from 'lucide-react'
|
||||
|
||||
interface ArchitectureSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
const layers = [
|
||||
{
|
||||
icon: Server,
|
||||
color: 'text-indigo-400',
|
||||
bg: 'bg-indigo-500/10 border-indigo-500/20',
|
||||
title: de ? 'Hardware-Schicht' : 'Hardware Layer',
|
||||
items: [
|
||||
{ label: 'ComplAI Mini', desc: 'Mac Mini M4 · 16 GB · Llama 3.2 3B' },
|
||||
{ label: 'ComplAI Studio', desc: 'Mac Studio M4 Max · 64 GB · Qwen 2.5 32B' },
|
||||
{ label: 'ComplAI Cloud', desc: de ? 'Managed GPU-Cluster · Multi-Model' : 'Managed GPU Cluster · Multi-Model' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/10 border-purple-500/20',
|
||||
title: de ? 'KI-Engine' : 'AI Engine',
|
||||
items: [
|
||||
{ label: 'Ollama Runtime', desc: de ? 'Lokale LLM-Inferenz, GPU-optimiert' : 'Local LLM inference, GPU-optimized' },
|
||||
{ label: 'RAG Pipeline', desc: de ? 'Vektorsuche mit Compliance-Wissensbasis' : 'Vector search with compliance knowledge base' },
|
||||
{ label: 'Agent Framework', desc: de ? 'Autonome Compliance-Agenten (Audit, Monitoring, Reporting)' : 'Autonomous compliance agents (Audit, Monitoring, Reporting)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
title: de ? 'Compliance-Module' : 'Compliance Modules',
|
||||
items: [
|
||||
{ label: 'DSGVO Engine', desc: de ? 'VVT, DSFA, Betroffenenrechte, Loeschkonzept' : 'RoPA, DPIA, Data Subject Rights, Deletion Concept' },
|
||||
{ label: 'AI Act Module', desc: de ? 'Risikoklassifizierung, Konformitaetsbewertung, Dokumentation' : 'Risk Classification, Conformity Assessment, Documentation' },
|
||||
{ label: 'NIS2 Module', desc: de ? 'Cybersecurity-Policies, Incident Response, Meldewege' : 'Cybersecurity Policies, Incident Response, Reporting Chains' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10 border-blue-500/20',
|
||||
title: de ? 'Plattform-Services' : 'Platform Services',
|
||||
items: [
|
||||
{ label: de ? 'Admin-Dashboard' : 'Admin Dashboard', desc: 'Next.js · ' + (de ? 'Mandantenfaehig · Rollenbasiert' : 'Multi-Tenant · Role-Based') },
|
||||
{ label: 'SDK API', desc: 'Go/Gin · REST · ' + (de ? 'Tenant-isoliert' : 'Tenant-Isolated') },
|
||||
{ label: 'DevSecOps Suite', desc: 'Semgrep · Trivy · Gitleaks · CycloneDX SBOM' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const securityFeatures = [
|
||||
{ icon: Lock, label: de ? 'Zero-Trust Architektur' : 'Zero-Trust Architecture' },
|
||||
{ icon: Database, label: de ? 'Daten verlassen nie das Unternehmen' : 'Data Never Leaves the Company' },
|
||||
{ icon: Globe, label: de ? 'Kein Cloud-Abhaengigkeit' : 'No Cloud Dependency' },
|
||||
{ icon: Workflow, label: de ? 'Air-Gap faehig' : 'Air-Gap Capable' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-8">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.annex.architecture.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.architecture.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Architecture Layers */}
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
{layers.map((layer, idx) => {
|
||||
const Icon = layer.icon
|
||||
return (
|
||||
<FadeInView key={idx} delay={0.2 + idx * 0.1}>
|
||||
<div className={`border rounded-xl p-4 ${layer.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={`w-5 h-5 ${layer.color}`} />
|
||||
<h3 className="text-sm font-bold text-white">{layer.title}</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{layer.items.map((item, iidx) => (
|
||||
<div key={iidx} className="flex items-start gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 ${layer.color} bg-current opacity-50`} />
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-white/80">{item.label}</span>
|
||||
<span className="text-xs text-white/40 ml-2">{item.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Security Bar */}
|
||||
<FadeInView delay={0.6}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="flex items-center justify-center gap-8 flex-wrap">
|
||||
{securityFeatures.map((feat, idx) => {
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs text-white/60">{feat.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
pitch-deck/components/slides/AssumptionsSlide.tsx
Normal file
198
pitch-deck/components/slides/AssumptionsSlide.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import { useFinancialModel } from '@/lib/hooks/useFinancialModel'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { SlidersHorizontal, TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
|
||||
interface AssumptionsSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
interface SensitivityResult {
|
||||
label: string
|
||||
base: string
|
||||
bull: string
|
||||
bear: string
|
||||
}
|
||||
|
||||
export default function AssumptionsSlide({ lang }: AssumptionsSlideProps) {
|
||||
const i = t(lang)
|
||||
const fm = useFinancialModel()
|
||||
const de = lang === 'de'
|
||||
|
||||
const baseScenario = fm.scenarios.find(s => s.name === 'Base Case')
|
||||
const bullScenario = fm.scenarios.find(s => s.name === 'Bull Case')
|
||||
const bearScenario = fm.scenarios.find(s => s.name === 'Bear Case')
|
||||
|
||||
function getVal(scenario: typeof baseScenario, key: string): string {
|
||||
if (!scenario) return '-'
|
||||
const a = scenario.assumptions.find(a => a.key === key)
|
||||
if (!a) return '-'
|
||||
const v = a.value
|
||||
if (typeof v === 'number') return String(v)
|
||||
return String(v)
|
||||
}
|
||||
|
||||
const rows: SensitivityResult[] = [
|
||||
{
|
||||
label: de ? 'Monatliches Wachstum' : 'Monthly Growth Rate',
|
||||
base: getVal(baseScenario, 'monthly_growth_rate') + '%',
|
||||
bull: getVal(bullScenario, 'monthly_growth_rate') + '%',
|
||||
bear: getVal(bearScenario, 'monthly_growth_rate') + '%',
|
||||
},
|
||||
{
|
||||
label: de ? 'Monatliche Churn Rate' : 'Monthly Churn Rate',
|
||||
base: getVal(baseScenario, 'churn_rate_monthly') + '%',
|
||||
bull: getVal(bullScenario, 'churn_rate_monthly') + '%',
|
||||
bear: getVal(bearScenario, 'churn_rate_monthly') + '%',
|
||||
},
|
||||
{
|
||||
label: de ? 'Startkunden' : 'Initial Customers',
|
||||
base: getVal(baseScenario, 'initial_customers'),
|
||||
bull: getVal(bullScenario, 'initial_customers'),
|
||||
bear: getVal(bearScenario, 'initial_customers'),
|
||||
},
|
||||
{
|
||||
label: 'ARPU Mini',
|
||||
base: getVal(baseScenario, 'arpu_mini') + ' EUR',
|
||||
bull: getVal(bullScenario, 'arpu_mini') + ' EUR',
|
||||
bear: getVal(bearScenario, 'arpu_mini') + ' EUR',
|
||||
},
|
||||
{
|
||||
label: 'ARPU Studio',
|
||||
base: getVal(baseScenario, 'arpu_studio') + ' EUR',
|
||||
bull: getVal(bullScenario, 'arpu_studio') + ' EUR',
|
||||
bear: getVal(bearScenario, 'arpu_studio') + ' EUR',
|
||||
},
|
||||
{
|
||||
label: 'ARPU Cloud',
|
||||
base: getVal(baseScenario, 'arpu_cloud') + ' EUR',
|
||||
bull: getVal(bullScenario, 'arpu_cloud') + ' EUR',
|
||||
bear: getVal(bearScenario, 'arpu_cloud') + ' EUR',
|
||||
},
|
||||
{
|
||||
label: 'CAC',
|
||||
base: getVal(baseScenario, 'cac') + ' EUR',
|
||||
bull: getVal(bullScenario, 'cac') + ' EUR',
|
||||
bear: getVal(bearScenario, 'cac') + ' EUR',
|
||||
},
|
||||
{
|
||||
label: de ? 'Produktmix Mini/Studio/Cloud' : 'Product Mix Mini/Studio/Cloud',
|
||||
base: `${getVal(baseScenario, 'product_mix_mini')}/${getVal(baseScenario, 'product_mix_studio')}/${getVal(baseScenario, 'product_mix_cloud')}`,
|
||||
bull: `${getVal(bullScenario, 'product_mix_mini')}/${getVal(bullScenario, 'product_mix_studio')}/${getVal(bullScenario, 'product_mix_cloud')}`,
|
||||
bear: `${getVal(bearScenario, 'product_mix_mini')}/${getVal(bearScenario, 'product_mix_studio')}/${getVal(bearScenario, 'product_mix_cloud')}`,
|
||||
},
|
||||
{
|
||||
label: de ? 'Marketing / Monat' : 'Marketing / Month',
|
||||
base: getVal(baseScenario, 'marketing_monthly') + ' EUR',
|
||||
bull: getVal(bullScenario, 'marketing_monthly') + ' EUR',
|
||||
bear: getVal(bearScenario, 'marketing_monthly') + ' EUR',
|
||||
},
|
||||
]
|
||||
|
||||
// Summary KPIs from computed results
|
||||
const baseSummary = fm.results.get(baseScenario?.id || '')?.summary
|
||||
const bullSummary = fm.results.get(bullScenario?.id || '')?.summary
|
||||
const bearSummary = fm.results.get(bearScenario?.id || '')?.summary
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-8">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.annex.assumptions.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.assumptions.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Sensitivity Table */}
|
||||
<FadeInView delay={0.2}>
|
||||
<GlassCard hover={false} className="p-5 mb-6 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 pr-4 text-white/40 font-medium min-w-[200px]">
|
||||
{de ? 'Annahme' : 'Assumption'}
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 min-w-[100px]">
|
||||
<span className="flex items-center justify-center gap-1 text-red-400 font-medium">
|
||||
<TrendingDown className="w-3 h-3" /> Bear
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 min-w-[100px]">
|
||||
<span className="flex items-center justify-center gap-1 text-indigo-400 font-medium">
|
||||
<Minus className="w-3 h-3" /> Base
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 min-w-[100px]">
|
||||
<span className="flex items-center justify-center gap-1 text-emerald-400 font-medium">
|
||||
<TrendingUp className="w-3 h-3" /> Bull
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={idx} className={idx % 2 === 0 ? 'bg-white/[0.02]' : ''}>
|
||||
<td className="py-2 pr-4 text-white/60">{row.label}</td>
|
||||
<td className="py-2 px-3 text-center font-mono text-red-400/70">{row.bear}</td>
|
||||
<td className="py-2 px-3 text-center font-mono text-white font-semibold">{row.base}</td>
|
||||
<td className="py-2 px-3 text-center font-mono text-emerald-400/70">{row.bull}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
{/* Outcome Summary */}
|
||||
{baseSummary && (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Bear', summary: bearSummary, color: 'text-red-400', bg: 'bg-red-500/5 border-red-500/10' },
|
||||
{ label: 'Base', summary: baseSummary, color: 'text-indigo-400', bg: 'bg-indigo-500/5 border-indigo-500/10' },
|
||||
{ label: 'Bull', summary: bullSummary, color: 'text-emerald-400', bg: 'bg-emerald-500/5 border-emerald-500/10' },
|
||||
].map((s, idx) => (
|
||||
<FadeInView key={s.label} delay={0.4 + idx * 0.1}>
|
||||
<div className={`border rounded-xl p-4 ${s.bg}`}>
|
||||
<p className={`text-sm font-bold ${s.color} mb-3`}>{s.label} Case</p>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/40">ARR 2030</span>
|
||||
<span className="text-white font-mono">
|
||||
{s.summary ? `${(s.summary.final_arr / 1_000_000).toFixed(1)}M` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/40">{de ? 'Kunden 2030' : 'Customers 2030'}</span>
|
||||
<span className="text-white font-mono">
|
||||
{s.summary?.final_customers?.toLocaleString('de-DE') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/40">Break-Even</span>
|
||||
<span className="text-white font-mono">
|
||||
{s.summary?.break_even_month ? `${de ? 'Monat' : 'Month'} ${s.summary.break_even_month}` : (de ? 'Nicht erreicht' : 'Not reached')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/40">LTV/CAC</span>
|
||||
<span className="text-white font-mono">
|
||||
{s.summary?.final_ltv_cac ? `${s.summary.final_ltv_cac.toFixed(1)}x` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,48 @@ interface BusinessModelSlideProps {
|
||||
products: PitchProduct[]
|
||||
}
|
||||
|
||||
const AMORT_MONTHS = 24
|
||||
|
||||
function computeKPIs(products: PitchProduct[]) {
|
||||
if (!products.length) return { weightedMarginDuring: 0, weightedMarginAfter: 0, amortMonths: AMORT_MONTHS }
|
||||
|
||||
// Compute weighted margin based on product mix (equal weight per product as proxy)
|
||||
const n = products.length
|
||||
let sumMarginDuring = 0
|
||||
let sumMarginAfter = 0
|
||||
let maxAmortMonths = 0
|
||||
|
||||
for (const p of products) {
|
||||
const price = p.monthly_price_eur
|
||||
if (price <= 0) continue
|
||||
const amort = p.hardware_cost_eur > 0 ? p.hardware_cost_eur / AMORT_MONTHS : 0
|
||||
const opex = p.operating_cost_eur > 0 ? p.operating_cost_eur : 0
|
||||
|
||||
// Margin during amortization
|
||||
const marginDuring = (price - amort - opex) / price
|
||||
sumMarginDuring += marginDuring
|
||||
|
||||
// Margin after amortization (no more HW cost)
|
||||
const marginAfter = (price - opex) / price
|
||||
sumMarginAfter += marginAfter
|
||||
|
||||
// Payback period in months
|
||||
if (p.hardware_cost_eur > 0 && price - opex > 0) {
|
||||
const payback = Math.ceil(p.hardware_cost_eur / (price - opex))
|
||||
if (payback > maxAmortMonths) maxAmortMonths = payback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
weightedMarginDuring: Math.round((sumMarginDuring / n) * 100),
|
||||
weightedMarginAfter: Math.round((sumMarginAfter / n) * 100),
|
||||
amortMonths: maxAmortMonths || AMORT_MONTHS,
|
||||
}
|
||||
}
|
||||
|
||||
export default function BusinessModelSlide({ lang, products }: BusinessModelSlideProps) {
|
||||
const i = t(lang)
|
||||
const { weightedMarginDuring, weightedMarginAfter, amortMonths } = computeKPIs(products)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -25,7 +65,7 @@ export default function BusinessModelSlide({ lang, products }: BusinessModelSlid
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.businessModel.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Key Metrics */}
|
||||
{/* Key Metrics — dynamisch berechnet */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<GlassCard delay={0.2} className="text-center">
|
||||
<Repeat className="w-6 h-6 text-indigo-400 mx-auto mb-2" />
|
||||
@@ -36,14 +76,18 @@ export default function BusinessModelSlide({ lang, products }: BusinessModelSlid
|
||||
<GlassCard delay={0.3} className="text-center">
|
||||
<DollarSign className="w-6 h-6 text-green-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-white/50 mb-1">{i.businessModel.margin}</p>
|
||||
<p className="text-2xl font-bold text-white">>70%</p>
|
||||
<p className="text-xs text-white/30">{lang === 'de' ? 'nach Amortisation' : 'post amortization'}</p>
|
||||
<p className="text-2xl font-bold text-white">>{weightedMarginAfter}%</p>
|
||||
<p className="text-xs text-white/30">
|
||||
{lang === 'de' ? 'nach Amortisation' : 'post amortization'}
|
||||
{' · '}
|
||||
{weightedMarginDuring}% {lang === 'de' ? 'waehrend' : 'during'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.4} className="text-center">
|
||||
<TrendingUp className="w-6 h-6 text-purple-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-white/50 mb-1">{i.businessModel.amortization}</p>
|
||||
<p className="text-2xl font-bold text-white">24 {i.businessModel.months}</p>
|
||||
<p className="text-xs text-white/30">{lang === 'de' ? 'Hardware-Amortisation' : 'Hardware Amortization'}</p>
|
||||
<p className="text-2xl font-bold text-white">{amortMonths} {i.businessModel.months}</p>
|
||||
<p className="text-xs text-white/30">{lang === 'de' ? 'max. Hardware-Amortisation' : 'max. Hardware Amortization'}</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +96,7 @@ export default function BusinessModelSlide({ lang, products }: BusinessModelSlid
|
||||
<h3 className="text-lg font-semibold mb-4 text-white/70">{i.businessModel.unitEconomics}</h3>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{products.map((p, idx) => {
|
||||
const amort = p.hardware_cost_eur > 0 ? Math.round(p.hardware_cost_eur / 24) : 0
|
||||
const amort = p.hardware_cost_eur > 0 ? Math.round(p.hardware_cost_eur / AMORT_MONTHS) : 0
|
||||
const monthlyMargin = p.monthly_price_eur - amort - (p.operating_cost_eur > 0 ? p.operating_cost_eur : 0)
|
||||
const marginPct = Math.round((monthlyMargin / p.monthly_price_eur) * 100)
|
||||
|
||||
@@ -79,7 +123,7 @@ export default function BusinessModelSlide({ lang, products }: BusinessModelSlid
|
||||
{p.operating_cost_eur > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/50">{i.businessModel.operatingCost}</span>
|
||||
<span className="text-white/70">-{p.operating_cost_eur.toLocaleString('de-DE')} EUR/Mo</span>
|
||||
<span className="text-white/70">-{p.operating_cost_eur} EUR/Mo</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-white/10 pt-2 flex justify-between">
|
||||
|
||||
@@ -17,20 +17,20 @@ interface CompetitionSlideProps {
|
||||
|
||||
const securityFeatures = {
|
||||
de: [
|
||||
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrierte Security-Tools fuer kontinuierliche Sicherheitsueberwachung' },
|
||||
{ icon: ScanLine, title: 'SAST & Secrets Detection', desc: 'Automatische Code-Analyse (Semgrep) + Secrets-Scanning (Gitleaks) in der CI/CD Pipeline' },
|
||||
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scannen Container-Images und Abhaengigkeiten auf CVEs' },
|
||||
{ icon: Package, title: 'SBOM-Generator (NIS2-konform)', desc: 'CycloneDX/SPDX Software Bill of Materials fuer NIS2 und ISO 27001 Compliance' },
|
||||
{ icon: FileSearch, title: 'Software-Risikoanalyse', desc: 'Automatisierte Risikoklassifizierung fuer Embedded-Entwicklung und AI-Act-konforme Systeme' },
|
||||
{ icon: Code2, title: 'KI-Code-Assistent (1000b)', desc: 'Das Cloud-LLM unterstuetzt Entwickler bei Code-Reviews, Security-Fixes und Compliance-Dokumentation' },
|
||||
{ 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 for continuous security monitoring' },
|
||||
{ icon: ScanLine, title: 'SAST & Secrets Detection', desc: 'Automatic code analysis (Semgrep) + secrets scanning (Gitleaks) in CI/CD pipeline' },
|
||||
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scan container images and dependencies for CVEs' },
|
||||
{ icon: Package, title: 'SBOM Generator (NIS2 compliant)', desc: 'CycloneDX/SPDX Software Bill of Materials for NIS2 and ISO 27001 compliance' },
|
||||
{ icon: FileSearch, title: 'Software Risk Analysis', desc: 'Automated risk classification for embedded development and AI Act compliant systems' },
|
||||
{ icon: Code2, title: 'AI Code Assistant (1000b)', desc: 'Cloud LLM assists developers with code reviews, security fixes and compliance documentation' },
|
||||
{ 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' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Language } from '@/lib/types'
|
||||
import { Language, PitchFunding } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
@@ -10,11 +10,36 @@ import BrandName from '../ui/BrandName'
|
||||
interface CoverSlideProps {
|
||||
lang: Language
|
||||
onNext: () => void
|
||||
funding?: PitchFunding
|
||||
}
|
||||
|
||||
export default function CoverSlide({ lang, onNext }: CoverSlideProps) {
|
||||
function formatRoundLabel(funding: PitchFunding | undefined): string {
|
||||
if (!funding) return 'Pre-Seed'
|
||||
// Extract a short round label from round_name
|
||||
const name = funding.round_name || ''
|
||||
if (name.toLowerCase().includes('seed')) return 'Pre-Seed'
|
||||
if (name.toLowerCase().includes('series a')) return 'Series A'
|
||||
return 'Pre-Seed'
|
||||
}
|
||||
|
||||
function formatQuarter(dateStr: string | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
const quarter = Math.ceil((d.getMonth() + 1) / 3)
|
||||
return `Q${quarter} ${d.getFullYear()}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function CoverSlide({ lang, onNext, funding }: CoverSlideProps) {
|
||||
const i = t(lang)
|
||||
|
||||
const roundLabel = formatRoundLabel(funding)
|
||||
const quarter = formatQuarter(funding?.target_date)
|
||||
const subtitle = quarter ? `${roundLabel} · ${quarter}` : roundLabel
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center min-h-[70vh]">
|
||||
{/* Logo / Brand */}
|
||||
@@ -63,14 +88,14 @@ export default function CoverSlide({ lang, onNext }: CoverSlideProps) {
|
||||
{i.cover.tagline}
|
||||
</motion.p>
|
||||
|
||||
{/* Subtitle */}
|
||||
{/* Subtitle — dynamisch aus Funding */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
className="text-sm text-white/30 font-mono tracking-wider mb-12"
|
||||
>
|
||||
{i.cover.subtitle}
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA */}
|
||||
|
||||
274
pitch-deck/components/slides/EngineeringSlide.tsx
Normal file
274
pitch-deck/components/slides/EngineeringSlide.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import {
|
||||
Code2,
|
||||
Container,
|
||||
GitBranch,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Cpu,
|
||||
Database,
|
||||
Braces,
|
||||
FileCode2,
|
||||
Server,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface EngineeringSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
const heroStats = [
|
||||
{
|
||||
value: '761K',
|
||||
label: de ? 'Zeilen Code' : 'Lines of Code',
|
||||
sub: 'Go · Python · TypeScript',
|
||||
color: 'text-indigo-400',
|
||||
borderColor: 'border-indigo-500/30',
|
||||
},
|
||||
{
|
||||
value: '45',
|
||||
label: de ? 'Docker Container' : 'Docker Containers',
|
||||
sub: de ? 'Produktiv auf einem Mac Studio' : 'Production on one Mac Studio',
|
||||
color: 'text-emerald-400',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
},
|
||||
{
|
||||
value: '27',
|
||||
label: de ? 'Microservices' : 'Microservices',
|
||||
sub: '10 Go · 9 Python · 8 Next.js',
|
||||
color: 'text-purple-400',
|
||||
borderColor: 'border-purple-500/30',
|
||||
},
|
||||
{
|
||||
value: '37',
|
||||
label: 'Dockerfiles',
|
||||
sub: de ? 'Vollstaendig containerisiert' : 'Fully containerized',
|
||||
color: 'text-amber-400',
|
||||
borderColor: 'border-amber-500/30',
|
||||
},
|
||||
]
|
||||
|
||||
const languageBreakdown = [
|
||||
{ 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 = [
|
||||
{
|
||||
icon: GitBranch,
|
||||
label: 'Gitea',
|
||||
desc: de ? 'Self-hosted Git · 4 Repos · Code Review' : 'Self-hosted Git · 4 Repos · Code Review',
|
||||
},
|
||||
{
|
||||
icon: Workflow,
|
||||
label: 'Woodpecker CI',
|
||||
desc: de ? 'Self-hosted CI/CD · Lint · Test · Build · Deploy' : 'Self-hosted CI/CD · Lint · Test · Build · Deploy',
|
||||
},
|
||||
{
|
||||
icon: Container,
|
||||
label: 'Docker Compose',
|
||||
desc: de ? '66 Service-Definitionen · Multi-Stage Builds' : '66 Service Definitions · Multi-Stage Builds',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
label: 'DevSecOps',
|
||||
desc: 'Semgrep · Trivy · Gitleaks · CycloneDX SBOM',
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
label: 'HashiCorp Vault',
|
||||
desc: de ? 'Secrets Management · Auto-Rotation · PKI' : 'Secrets Management · Auto-Rotation · PKI',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
label: de ? 'Infrastruktur' : 'Infrastructure',
|
||||
desc: 'Nginx · PostgreSQL · Qdrant · MinIO · Valkey',
|
||||
},
|
||||
]
|
||||
|
||||
const serviceArchitecture = [
|
||||
{
|
||||
project: 'breakpilot-core',
|
||||
color: 'text-indigo-400',
|
||||
dotColor: 'bg-indigo-400',
|
||||
services: de
|
||||
? ['Admin Dashboard', 'Consent Service (Go)', 'Billing Service (Go)', 'RAG Pipeline', 'Embedding Service', 'Voice Service', 'Pitch Deck', 'Nginx Reverse Proxy']
|
||||
: ['Admin Dashboard', 'Consent Service (Go)', 'Billing Service (Go)', 'RAG Pipeline', 'Embedding Service', 'Voice Service', 'Pitch Deck', 'Nginx Reverse Proxy'],
|
||||
},
|
||||
{
|
||||
project: 'breakpilot-lehrer',
|
||||
color: 'text-purple-400',
|
||||
dotColor: 'bg-purple-400',
|
||||
services: de
|
||||
? ['Lehrer Dashboard', 'Studio v2', 'Website', 'Klausur Service', 'School Service (Go)', 'Edu Search (Go)']
|
||||
: ['Teacher Dashboard', 'Studio v2', 'Website', 'Exam Service', 'School Service (Go)', 'Edu Search (Go)'],
|
||||
},
|
||||
{
|
||||
project: 'breakpilot-compliance',
|
||||
color: 'text-emerald-400',
|
||||
dotColor: 'bg-emerald-400',
|
||||
services: de
|
||||
? ['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'],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-6">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-2">
|
||||
<GradientText>{i.annex.engineering.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.engineering.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Hero Stats */}
|
||||
<FadeInView delay={0.1}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
|
||||
{heroStats.map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-xl p-3 bg-white/[0.03] text-center ${stat.borderColor}`}
|
||||
>
|
||||
<p className={`text-3xl font-black tracking-tight ${stat.color}`}>{stat.value}</p>
|
||||
<p className="text-xs font-semibold text-white/70 mt-0.5">{stat.label}</p>
|
||||
<p className="text-[10px] text-white/30 mt-0.5">{stat.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-4">
|
||||
{/* Left Column: Language Breakdown + Service Map */}
|
||||
<div className="md:col-span-5 space-y-4">
|
||||
{/* Language Breakdown */}
|
||||
<FadeInView delay={0.2}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileCode2 className="w-4 h-4 text-white/40" />
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider">
|
||||
{de ? 'Sprachen-Mix' : 'Language Mix'}
|
||||
</p>
|
||||
</div>
|
||||
{/* Stacked bar */}
|
||||
<div className="flex rounded-full overflow-hidden h-3 mb-3">
|
||||
{languageBreakdown.map((l, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${l.color} transition-all`}
|
||||
style={{ width: `${l.pct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{languageBreakdown.map((l, idx) => {
|
||||
const Icon = l.icon
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${l.color}`} />
|
||||
<Icon className="w-3 h-3 text-white/40" />
|
||||
<span className="text-xs text-white/60">{l.lang}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-white/40">{l.loc}</span>
|
||||
<span className="text-xs font-bold text-white/70">{l.pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
{/* Service Map */}
|
||||
<FadeInView delay={0.3}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Layers className="w-4 h-4 text-white/40" />
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider">
|
||||
{de ? 'Service-Architektur' : 'Service Architecture'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{serviceArchitecture.map((proj, idx) => (
|
||||
<div key={idx}>
|
||||
<p className={`text-[10px] font-bold uppercase tracking-wider ${proj.color} mb-1`}>
|
||||
{proj.project}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{proj.services.map((svc, sidx) => (
|
||||
<span
|
||||
key={sidx}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-white/[0.05] text-white/50 border border-white/[0.06]"
|
||||
>
|
||||
{svc}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
|
||||
{/* Right Column: DevOps Stack */}
|
||||
<div className="md:col-span-7">
|
||||
<FadeInView delay={0.25}>
|
||||
<GlassCard hover={false} className="p-4 h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="w-4 h-4 text-white/40" />
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider">
|
||||
{de ? 'DevOps & Toolchain' : 'DevOps & Toolchain'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{devopsStack.map((tool, idx) => {
|
||||
const Icon = tool.icon
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white/80">{tool.label}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">{tool.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Footer note */}
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<p className="text-[10px] text-white/20 text-center">
|
||||
{de
|
||||
? '100% Self-Hosted · Kein externer Cloud-Anbieter · Vollstaendige Kontrolle ueber Code und Daten'
|
||||
: '100% Self-Hosted · No External Cloud Provider · Full Control Over Code and Data'}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
pitch-deck/components/slides/GTMSlide.tsx
Normal file
141
pitch-deck/components/slides/GTMSlide.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { Target, Users, Handshake, Megaphone, Building2, GraduationCap } from 'lucide-react'
|
||||
|
||||
interface GTMSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
export default function GTMSlide({ lang }: GTMSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
const phases = [
|
||||
{
|
||||
phase: de ? 'Phase 1: Pilot (2026)' : 'Phase 1: Pilot (2026)',
|
||||
color: 'border-indigo-500/30 bg-indigo-500/5',
|
||||
textColor: 'text-indigo-400',
|
||||
items: [
|
||||
de ? 'Direktvertrieb an 5-20 KMU in DACH' : 'Direct sales to 5-20 SMEs in DACH',
|
||||
de ? 'Fokus: Gesundheitswesen, Finanzdienstleister, Rechtsanwaelte' : 'Focus: Healthcare, Financial Services, Law Firms',
|
||||
de ? 'Persoenliches Onboarding, White-Glove-Service' : 'Personal onboarding, white-glove service',
|
||||
de ? 'Case Studies und Referenzkunden aufbauen' : 'Build case studies and reference customers',
|
||||
],
|
||||
},
|
||||
{
|
||||
phase: de ? 'Phase 2: Skalierung (2027)' : 'Phase 2: Scale (2027)',
|
||||
color: 'border-purple-500/30 bg-purple-500/5',
|
||||
textColor: 'text-purple-400',
|
||||
items: [
|
||||
de ? 'Channel-Partnerschaften mit IT-Systemhaeusern' : 'Channel partnerships with IT system integrators',
|
||||
de ? 'IHK- und Handwerkskammer-Kooperationen' : 'Chamber of Commerce & Industry partnerships',
|
||||
de ? 'Content Marketing: Compliance-Webinare, Whitepaper' : 'Content marketing: Compliance webinars, whitepapers',
|
||||
de ? 'Zielkunden: 50-200 in regulierten Branchen' : 'Target: 50-200 customers in regulated industries',
|
||||
],
|
||||
},
|
||||
{
|
||||
phase: de ? 'Phase 3: Expansion (2028+)' : 'Phase 3: Expansion (2028+)',
|
||||
color: 'border-emerald-500/30 bg-emerald-500/5',
|
||||
textColor: 'text-emerald-400',
|
||||
items: [
|
||||
de ? 'Cloud-Tier fuer groessere Unternehmen (50-500 MA)' : 'Cloud tier for larger companies (50-500 employees)',
|
||||
de ? 'EU-Expansion: Oesterreich, Schweiz, Benelux, Nordics' : 'EU expansion: Austria, Switzerland, Benelux, Nordics',
|
||||
de ? 'OEM/Whitelabel fuer Steuerberater und Wirtschaftspruefer' : 'OEM/whitelabel for tax advisors and auditors',
|
||||
de ? 'Self-Service-Onboarding und PLG-Motion' : 'Self-service onboarding and PLG motion',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const channels = [
|
||||
{ icon: Target, label: de ? 'Direktvertrieb' : 'Direct Sales', pct: '40%', desc: de ? 'Outbound + Inbound, 2 AEs ab 2027' : 'Outbound + Inbound, 2 AEs from 2027' },
|
||||
{ icon: Handshake, label: de ? 'Channel-Partner' : 'Channel Partners', pct: '30%', desc: de ? 'IT-Haendler, Systemhaeuser, MSPs' : 'IT resellers, system integrators, MSPs' },
|
||||
{ icon: Megaphone, label: de ? 'Content & Events' : 'Content & Events', pct: '20%', desc: de ? 'Webinare, Messen (it-sa), SEO' : 'Webinars, trade shows (it-sa), SEO' },
|
||||
{ icon: Users, label: de ? 'Empfehlungen' : 'Referrals', pct: '10%', desc: de ? 'Bestandskunden-Empfehlungsprogramm' : 'Customer referral program' },
|
||||
]
|
||||
|
||||
const idealCustomer = [
|
||||
{ icon: Building2, label: de ? '10-250 Mitarbeiter' : '10-250 Employees' },
|
||||
{ icon: GraduationCap, label: de ? 'Regulierte Branche (Gesundheit, Finanzen, Energie, KRITIS)' : 'Regulated Industry (Healthcare, Finance, Energy, Critical Infrastructure)' },
|
||||
{ icon: Target, label: de ? 'Kein interner Compliance-Officer oder DSB' : 'No Internal Compliance Officer or DPO' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-8">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.annex.gtm.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.gtm.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* ICP */}
|
||||
<FadeInView delay={0.15}>
|
||||
<GlassCard hover={false} className="p-4 mb-6">
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">
|
||||
{de ? 'Ideales Kundenprofil (ICP)' : 'Ideal Customer Profile (ICP)'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{idealCustomer.map((ic, idx) => {
|
||||
const Icon = ic.icon
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-indigo-400" />
|
||||
<span className="text-sm text-white/70">{ic.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
|
||||
{/* Phases */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-6">
|
||||
{phases.map((phase, idx) => (
|
||||
<FadeInView key={idx} delay={0.2 + idx * 0.1}>
|
||||
<div className={`border rounded-xl p-4 h-full ${phase.color}`}>
|
||||
<p className={`text-sm font-bold ${phase.textColor} mb-3`}>{phase.phase}</p>
|
||||
<ul className="space-y-2">
|
||||
{phase.items.map((item, iidx) => (
|
||||
<li key={iidx} className="flex items-start gap-2 text-xs text-white/60">
|
||||
<span className={`w-1 h-1 rounded-full mt-1.5 ${phase.textColor} bg-current`} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</FadeInView>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel Mix */}
|
||||
<FadeInView delay={0.5}>
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">
|
||||
{de ? 'Vertriebskanalmix (Ziel 2028)' : 'Channel Mix (Target 2028)'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{channels.map((ch, idx) => {
|
||||
const Icon = ch.icon
|
||||
return (
|
||||
<div key={idx} className="text-center">
|
||||
<Icon className="w-5 h-5 text-indigo-400 mx-auto mb-1" />
|
||||
<p className="text-lg font-bold text-white">{ch.pct}</p>
|
||||
<p className="text-xs font-semibold text-white/60 mb-0.5">{ch.label}</p>
|
||||
<p className="text-[10px] text-white/30">{ch.desc}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Plug, Settings, RefreshCw, CheckCircle2 } from 'lucide-react'
|
||||
import { Plug, GitBranch, RefreshCw, CheckCircle2 } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
|
||||
@@ -11,7 +11,7 @@ interface HowItWorksSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
const stepIcons = [Plug, Settings, RefreshCw, CheckCircle2]
|
||||
const stepIcons = [Plug, GitBranch, RefreshCw, CheckCircle2]
|
||||
const stepColors = ['text-blue-400', 'text-indigo-400', 'text-purple-400', 'text-green-400']
|
||||
|
||||
export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Language, PitchMarket } from '@/lib/types'
|
||||
import { t, formatEur } from '@/lib/i18n'
|
||||
import { ExternalLink, X, TrendingUp } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import AnimatedCounter from '../ui/AnimatedCounter'
|
||||
@@ -12,14 +14,133 @@ interface MarketSlideProps {
|
||||
market: PitchMarket[]
|
||||
}
|
||||
|
||||
interface MarketSourceInfo {
|
||||
name: string
|
||||
url: string
|
||||
date: string
|
||||
excerpt_de: string
|
||||
excerpt_en: string
|
||||
}
|
||||
|
||||
// Quellenangaben fuer die Marktzahlen
|
||||
const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
TAM: [
|
||||
{
|
||||
name: 'Grand View Research — GRC Market Report + MarketsAndMarkets DevSecOps',
|
||||
url: 'https://www.grandviewresearch.com/industry-analysis/governance-risk-management-compliance-market',
|
||||
date: '2024',
|
||||
excerpt_de: 'Der globale GRC-Software-Markt wurde 2023 auf rund 11,8 Mrd. USD bewertet. Zusammen mit dem DevSecOps-Markt fuer die Fertigungsindustrie (~3,5 Mrd. USD) ergibt sich ein kombinierter TAM von ca. 8,7 Mrd. EUR fuer Compliance & Code-Security im produzierenden Gewerbe.',
|
||||
excerpt_en: 'The global GRC software market was valued at approximately USD 11.8B in 2023. Combined with the DevSecOps market for manufacturing (~USD 3.5B), the combined TAM for compliance & code security in manufacturing is approximately EUR 8.7B.',
|
||||
},
|
||||
],
|
||||
SAM: [
|
||||
{
|
||||
name: 'VDMA / Statista / IDC — DACH Maschinenbau Compliance & Security',
|
||||
url: 'https://www.vdma.org/statistics',
|
||||
date: '2024',
|
||||
excerpt_de: 'Die DACH-Region hat ca. 5.000 Maschinen- und Anlagenbauer mit eigener Softwareentwicklung. Der Compliance- und Security-Software-Markt fuer diese Branche wird auf ca. 850 Mio. EUR geschaetzt — getrieben durch CRA, NIS2, AI Act und steigende Anforderungen an Produktsoftware.',
|
||||
excerpt_en: 'The DACH region has approx. 5,000 machine and plant manufacturers with in-house software development. The compliance and security software market for this industry is estimated at approx. EUR 850M — driven by CRA, NIS2, AI Act and increasing requirements for product software.',
|
||||
},
|
||||
],
|
||||
SOM: [
|
||||
{
|
||||
name: 'VDMA Mitgliederstatistik + eigene Analyse',
|
||||
url: 'https://www.vdma.org/mitglieder',
|
||||
date: '2024-2025',
|
||||
excerpt_de: 'Im VDMA sind ca. 3.600 Unternehmen allein in Deutschland registriert, DACH-weit ca. 5.000. Die meisten haben Embedded-Softwareentwicklung im Haus. Bei einer realistischen Marktdurchdringung von 10% (~500 Unternehmen) und einem durchschnittlichen Jahresumsatz von ~14.400 EUR pro Kunde (Blended Avg. aus Mini/Studio/Cloud) ergibt sich ein SOM von ca. 7,2 Mio. EUR.',
|
||||
excerpt_en: 'The VDMA has approx. 3,600 member companies in Germany alone, ~5,000 across DACH. Most have embedded software development in-house. At a realistic market penetration of 10% (~500 companies) and an average annual revenue of ~EUR 14,400 per customer (blended avg. of Mini/Studio/Cloud), the SOM is approx. EUR 7.2M.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const sizes = [280, 200, 130]
|
||||
const colors = ['border-indigo-500/30 bg-indigo-500/5', 'border-purple-500/30 bg-purple-500/5', 'border-blue-500/30 bg-blue-500/5']
|
||||
const textColors = ['text-indigo-400', 'text-purple-400', 'text-blue-400']
|
||||
|
||||
function SourceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
segment,
|
||||
lang,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
segment: string
|
||||
lang: Language
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
const sources = marketSources[segment] || []
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="relative bg-slate-900/95 border border-white/10 rounded-2xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 text-white/40 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-bold text-white mb-1">{segment}</h3>
|
||||
<p className="text-sm text-white/40 mb-6">
|
||||
{lang === 'de' ? 'Quellenangaben' : 'Sources'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sources.map((src, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white/[0.05] border border-white/10 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{src.name}</p>
|
||||
<p className="text-xs text-white/30">{src.date}</p>
|
||||
</div>
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 p-1.5 text-indigo-400 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
title={lang === 'de' ? 'Quelle oeffnen' : 'Open source'}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 leading-relaxed">
|
||||
{lang === 'de' ? src.excerpt_de : src.excerpt_en}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
const i = t(lang)
|
||||
const labels = [i.market.tamLabel, i.market.samLabel, i.market.somLabel]
|
||||
const segments = [i.market.tam, i.market.sam, i.market.som]
|
||||
const segmentKeys = ['TAM', 'SAM', 'SOM']
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -56,36 +177,83 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
|
||||
{/* Labels */}
|
||||
<div className="space-y-6">
|
||||
{market.map((m, idx) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${textColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
<span className="text-xs text-white/30">{labels[idx]}</span>
|
||||
{market.map((m, idx) => {
|
||||
const segKey = segmentKeys[idx] || m.market_segment
|
||||
const sourceCount = marketSources[segKey]?.length || 0
|
||||
return (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(segKey)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${textColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
<span className="text-xs text-white/30">{labels[idx]}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{m.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000_000}
|
||||
suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : m.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000}
|
||||
suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000}
|
||||
suffix={'k EUR'}
|
||||
decimals={0}
|
||||
duration={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{m.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{m.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">
|
||||
{i.market.source}: {m.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}
|
||||
{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000_000}
|
||||
suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-white/40">
|
||||
{i.market.growth}: {m.growth_rate_pct}% · {i.market.source}: {m.source}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Modals */}
|
||||
{segmentKeys.map((seg) => (
|
||||
<SourceModal
|
||||
key={seg}
|
||||
isOpen={activeModal === seg}
|
||||
onClose={() => setActiveModal(null)}
|
||||
segment={seg}
|
||||
lang={lang}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { AlertTriangle, Scale, Shield } from 'lucide-react'
|
||||
import { AlertTriangle, Scale, Shield, ExternalLink, X } from 'lucide-react'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
@@ -12,10 +13,150 @@ interface ProblemSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
interface SourceInfo {
|
||||
name: string
|
||||
url: string
|
||||
date: string
|
||||
excerpt_de: string
|
||||
excerpt_en: string
|
||||
}
|
||||
|
||||
interface ProblemCardData {
|
||||
sources: SourceInfo[]
|
||||
}
|
||||
|
||||
// Quellenangaben fuer jede Behauptung
|
||||
const cardSources: ProblemCardData[] = [
|
||||
{
|
||||
// DSGVO: 4.1 Mrd EUR Bussgelder, 83% KMU
|
||||
sources: [
|
||||
{
|
||||
name: 'GDPR Enforcement Tracker (CMS Law)',
|
||||
url: 'https://www.enforcementtracker.com/',
|
||||
date: '2025',
|
||||
excerpt_de: 'Der GDPR Enforcement Tracker dokumentiert alle oeffentlich bekannten DSGVO-Bussgelder in der EU. Kumuliert belaufen sich die Bussgelder auf ueber 4,1 Mrd. EUR seit Inkrafttreten der DSGVO im Mai 2018.',
|
||||
excerpt_en: 'The GDPR Enforcement Tracker documents all publicly known GDPR fines across the EU. Cumulative fines exceed EUR 4.1 billion since the GDPR took effect in May 2018.',
|
||||
},
|
||||
{
|
||||
name: 'DIHK Digitalisierungsumfrage 2024',
|
||||
url: 'https://www.dihk.de/de/themen-und-positionen/wirtschaft-digital/digitalisierung',
|
||||
date: '2024',
|
||||
excerpt_de: 'Laut der DIHK-Digitalisierungsumfrage 2024 geben 83% der befragten KMU an, die DSGVO-Anforderungen nicht vollstaendig umgesetzt zu haben. Hauptgruende sind mangelnde Ressourcen, fehlendes Know-how und die Komplexitaet der Vorschriften.',
|
||||
excerpt_en: 'According to the DIHK Digitization Survey 2024, 83% of surveyed SMEs report not having fully implemented GDPR requirements. Main reasons cited are lack of resources, missing expertise, and regulatory complexity.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// AI Act: August 2025
|
||||
sources: [
|
||||
{
|
||||
name: 'EU AI Act — Verordnung (EU) 2024/1689',
|
||||
url: 'https://eur-lex.europa.eu/eli/reg/2024/1689',
|
||||
date: '2024-08-01',
|
||||
excerpt_de: 'Die EU-KI-Verordnung (AI Act) trat am 1. August 2024 in Kraft. Ab dem 2. August 2025 gelten die Verbote fuer KI-Systeme mit unannehmbarem Risiko (Art. 5) sowie die Verpflichtungen fuer Anbieter von KI-Modellen mit allgemeinem Verwendungszweck (Art. 51-56). Ab August 2026 gelten die Anforderungen fuer Hochrisiko-KI-Systeme.',
|
||||
excerpt_en: 'The EU AI Act (Regulation 2024/1689) entered into force on August 1, 2024. From August 2, 2025, prohibitions on unacceptable-risk AI systems (Art. 5) and obligations for general-purpose AI model providers (Art. 51-56) apply. Requirements for high-risk AI systems apply from August 2026.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// NIS2: 30.000+ Unternehmen
|
||||
sources: [
|
||||
{
|
||||
name: 'BSI — NIS-2-Umsetzungs- und Cybersicherheitsstaerkungsgesetz',
|
||||
url: 'https://www.bsi.bund.de/DE/Themen/Regulierte-Wirtschaft/NIS-2-regulierte-Unternehmen/nis-2-regulierte-unternehmen_node.html',
|
||||
date: '2025',
|
||||
excerpt_de: 'Das NIS-2-Umsetzungsgesetz (NIS2UmsuCG) erweitert den Kreis der regulierten Unternehmen in Deutschland erheblich. Nach Schaetzungen des BSI und des BMI sind kuenftig mehr als 30.000 Unternehmen und Einrichtungen von den neuen Cybersicherheitsanforderungen betroffen — gegenueber bisher ca. 4.500 unter der NIS-1-Richtlinie.',
|
||||
excerpt_en: 'The NIS-2 Implementation Act significantly expands the scope of regulated entities in Germany. According to BSI and BMI estimates, more than 30,000 companies and institutions will be affected by the new cybersecurity requirements — compared to approximately 4,500 under NIS-1.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const icons = [AlertTriangle, Scale, Shield]
|
||||
|
||||
function SourceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
cardIndex,
|
||||
lang,
|
||||
cardTitle,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
cardIndex: number
|
||||
lang: Language
|
||||
cardTitle: string
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
const sources = cardSources[cardIndex]?.sources || []
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="relative bg-slate-900/95 border border-white/10 rounded-2xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 text-white/40 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-bold text-white mb-1">{cardTitle}</h3>
|
||||
<p className="text-sm text-white/40 mb-6">
|
||||
{lang === 'de' ? 'Quellenangaben' : 'Sources'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sources.map((src, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white/[0.05] border border-white/10 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{src.name}</p>
|
||||
<p className="text-xs text-white/30">{src.date}</p>
|
||||
</div>
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 p-1.5 text-indigo-400 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
title={lang === 'de' ? 'Quelle oeffnen' : 'Open source'}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 leading-relaxed">
|
||||
{lang === 'de' ? src.excerpt_de : src.excerpt_en}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProblemSlide({ lang }: ProblemSlideProps) {
|
||||
const i = t(lang)
|
||||
const [activeModal, setActiveModal] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -29,14 +170,25 @@ export default function ProblemSlide({ lang }: ProblemSlideProps) {
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-12">
|
||||
{i.problem.cards.map((card, idx) => {
|
||||
const Icon = icons[idx]
|
||||
const sourceCount = cardSources[idx]?.sources.length || 0
|
||||
return (
|
||||
<GlassCard key={idx} delay={0.2 + idx * 0.15} className="text-center">
|
||||
<GlassCard
|
||||
key={idx}
|
||||
delay={0.2 + idx * 0.15}
|
||||
className="text-center cursor-pointer group"
|
||||
onClick={() => setActiveModal(idx)}
|
||||
>
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-xl bg-red-500/10 flex items-center justify-center">
|
||||
<Icon className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2 text-white">{card.title}</h3>
|
||||
<p className="text-3xl font-bold text-red-400 mb-3">{card.stat}</p>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{card.desc}</p>
|
||||
<p className="text-sm text-white/50 leading-relaxed mb-3">{card.desc}</p>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}
|
||||
{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
)
|
||||
})}
|
||||
@@ -49,6 +201,18 @@ export default function ProblemSlide({ lang }: ProblemSlideProps) {
|
||||
</p>
|
||||
</blockquote>
|
||||
</FadeInView>
|
||||
|
||||
{/* Source Modals */}
|
||||
{i.problem.cards.map((card, idx) => (
|
||||
<SourceModal
|
||||
key={idx}
|
||||
isOpen={activeModal === idx}
|
||||
onClose={() => setActiveModal(null)}
|
||||
cardIndex={idx}
|
||||
lang={lang}
|
||||
cardTitle={card.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
315
pitch-deck/components/slides/RegulatorySlide.tsx
Normal file
315
pitch-deck/components/slides/RegulatorySlide.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import { Shield, Scale, Wifi, Lock, Calendar, AlertTriangle, CheckCircle2, Clock } from 'lucide-react'
|
||||
|
||||
interface RegulatorySlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
type RegTab = 'dsgvo' | 'aiact' | 'cra' | 'nis2'
|
||||
|
||||
export default function RegulatorySlide({ lang }: RegulatorySlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
const [activeTab, setActiveTab] = useState<RegTab>('dsgvo')
|
||||
|
||||
const tabs: { id: RegTab; label: string; icon: typeof Shield }[] = [
|
||||
{ id: 'dsgvo', label: de ? 'DSGVO / GDPR' : 'GDPR', icon: Shield },
|
||||
{ id: 'aiact', label: 'AI Act', icon: Scale },
|
||||
{ id: 'cra', label: 'CRA', icon: Lock },
|
||||
{ id: 'nis2', label: 'NIS2', icon: Wifi },
|
||||
]
|
||||
|
||||
const regulations: Record<RegTab, {
|
||||
fullName: string
|
||||
status: string
|
||||
statusColor: string
|
||||
statusIcon: typeof CheckCircle2
|
||||
deadline: string
|
||||
affectedCompanies: string
|
||||
keyRequirements: string[]
|
||||
fines: string
|
||||
howWeHelp: string[]
|
||||
}> = {
|
||||
dsgvo: {
|
||||
fullName: de ? 'Datenschutz-Grundverordnung (EU 2016/679)' : 'General Data Protection Regulation (EU 2016/679)',
|
||||
status: de ? 'In Kraft seit Mai 2018' : 'In effect since May 2018',
|
||||
statusColor: 'text-emerald-400',
|
||||
statusIcon: CheckCircle2,
|
||||
deadline: de ? 'Bereits anzuwenden' : 'Already applicable',
|
||||
affectedCompanies: de ? 'Alle Unternehmen die personenbezogene Daten verarbeiten' : 'All companies processing personal data',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Verzeichnis von Verarbeitungstaetigkeiten (VVT)',
|
||||
'Datenschutz-Folgenabschaetzung (DSFA)',
|
||||
'Technische und organisatorische Massnahmen (TOM)',
|
||||
'Betroffenenrechte (Auskunft, Loeschung, Portabilitaet)',
|
||||
'Auftragsverarbeitungsvertraege (AVV)',
|
||||
'Datenschutzbeauftragter (ab 20 MA)',
|
||||
'Meldepflicht bei Datenpannen (72h)',
|
||||
]
|
||||
: [
|
||||
'Records of Processing Activities (RoPA)',
|
||||
'Data Protection Impact Assessment (DPIA)',
|
||||
'Technical & Organizational Measures (TOMs)',
|
||||
'Data Subject Rights (Access, Erasure, Portability)',
|
||||
'Data Processing Agreements (DPA)',
|
||||
'Data Protection Officer (from 20 employees)',
|
||||
'Breach Notification (72h)',
|
||||
],
|
||||
fines: de ? 'Bis zu 20 Mio. EUR oder 4% des weltweiten Jahresumsatzes' : 'Up to EUR 20M or 4% of global annual revenue',
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Automatische VVT-Erstellung aus Unternehmensdaten',
|
||||
'KI-gestuetzte DSFA-Durchfuehrung',
|
||||
'TOM-Generator mit Branchenvorlagen',
|
||||
'Self-Service-Portal fuer Betroffenenanfragen',
|
||||
'Automatische Dokumentation und Audit-Trail',
|
||||
]
|
||||
: [
|
||||
'Automatic RoPA generation from company data',
|
||||
'AI-powered DPIA execution',
|
||||
'TOM generator with industry templates',
|
||||
'Self-service portal for data subject requests',
|
||||
'Automatic documentation and audit trail',
|
||||
],
|
||||
},
|
||||
aiact: {
|
||||
fullName: de ? 'KI-Verordnung (EU 2024/1689)' : 'AI Act (EU 2024/1689)',
|
||||
status: de ? 'Schrittweise ab Aug 2025' : 'Phased from Aug 2025',
|
||||
statusColor: 'text-amber-400',
|
||||
statusIcon: Clock,
|
||||
deadline: de ? 'Aug 2025: Verbote · Aug 2026: Hochrisiko · Aug 2027: Vollstaendig' : 'Aug 2025: Prohibitions · Aug 2026: High-Risk · Aug 2027: Full',
|
||||
affectedCompanies: de ? 'Anbieter und Betreiber von KI-Systemen in der EU' : 'Providers and deployers of AI systems in the EU',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Risikoklassifizierung aller KI-Systeme (Art. 6)',
|
||||
'Konformitaetsbewertung fuer Hochrisiko-KI (Art. 43)',
|
||||
'Technische Dokumentation und Transparenz (Art. 11-13)',
|
||||
'Menschliche Aufsicht (Art. 14)',
|
||||
'Registrierung in EU-Datenbank (Art. 49)',
|
||||
'GPAI-Modell-Pflichten (Art. 51-56)',
|
||||
'Grundrechte-Folgenabschaetzung (Art. 27)',
|
||||
]
|
||||
: [
|
||||
'Risk classification of all AI systems (Art. 6)',
|
||||
'Conformity assessment for high-risk AI (Art. 43)',
|
||||
'Technical documentation and transparency (Art. 11-13)',
|
||||
'Human oversight (Art. 14)',
|
||||
'Registration in EU database (Art. 49)',
|
||||
'GPAI model obligations (Art. 51-56)',
|
||||
'Fundamental rights impact assessment (Art. 27)',
|
||||
],
|
||||
fines: de ? 'Bis zu 35 Mio. EUR oder 7% des weltweiten Jahresumsatzes' : 'Up to EUR 35M or 7% of global annual revenue',
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Automatische Risikoklassifizierung von KI-Systemen',
|
||||
'Konformitaets-Checklisten mit KI-Unterstuetzung',
|
||||
'Technische Dokumentation per Template-Engine',
|
||||
'Audit-Vorbereitung fuer Hochrisiko-Systeme',
|
||||
'Monitoring von Rechtsaenderungen',
|
||||
]
|
||||
: [
|
||||
'Automatic AI system risk classification',
|
||||
'Conformity checklists with AI assistance',
|
||||
'Technical documentation via template engine',
|
||||
'Audit preparation for high-risk systems',
|
||||
'Regulatory change monitoring',
|
||||
],
|
||||
},
|
||||
cra: {
|
||||
fullName: de ? 'Cyber Resilience Act (EU 2024/2847)' : 'Cyber Resilience Act (EU 2024/2847)',
|
||||
status: de ? 'In Kraft seit Dez 2024' : 'In effect since Dec 2024',
|
||||
statusColor: 'text-amber-400',
|
||||
statusIcon: Clock,
|
||||
deadline: de ? 'Sep 2026: Meldepflichten · Dez 2027: Vollstaendig anzuwenden' : 'Sep 2026: Reporting · Dec 2027: Fully applicable',
|
||||
affectedCompanies: de ? 'Alle Hersteller von Produkten mit digitalen Elementen (Hardware & Software)' : 'All manufacturers of products with digital elements (hardware & software)',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Security by Design fuer alle Produkte mit Software',
|
||||
'Schwachstellen-Management ueber gesamten Produktlebenszyklus',
|
||||
'Software Bill of Materials (SBOM) fuer jedes Produkt',
|
||||
'Kostenlose Sicherheitsupdates fuer Kunden',
|
||||
'Meldepflicht bei aktiv ausgenutzten Schwachstellen (24h)',
|
||||
'Konformitaetsbewertung durch Drittstelle (fuer kritische Produkte)',
|
||||
'CE-Kennzeichnung fuer Cybersecurity-Compliance',
|
||||
]
|
||||
: [
|
||||
'Security by design for all products with software',
|
||||
'Vulnerability management across entire product lifecycle',
|
||||
'Software Bill of Materials (SBOM) for every product',
|
||||
'Free security updates for customers',
|
||||
'Reporting of actively exploited vulnerabilities (24h)',
|
||||
'Third-party conformity assessment (for critical products)',
|
||||
'CE marking for cybersecurity compliance',
|
||||
],
|
||||
fines: de ? 'Bis zu 15 Mio. EUR oder 2,5% des weltweiten Jahresumsatzes' : 'Up to EUR 15M or 2.5% of global annual revenue',
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Automatische SBOM-Generierung aus Code-Repositories',
|
||||
'Kontinuierliches Schwachstellen-Scanning (Trivy, Grype)',
|
||||
'Security-Fixes durch 1000B Cloud-LLM implementiert',
|
||||
'CRA-konforme Dokumentation und Audit-Trail',
|
||||
'Risikoanalysen fuer Embedded-Software und Firmware',
|
||||
]
|
||||
: [
|
||||
'Automatic SBOM generation from code repositories',
|
||||
'Continuous vulnerability scanning (Trivy, Grype)',
|
||||
'Security fixes implemented by 1000B cloud LLM',
|
||||
'CRA-compliant documentation and audit trail',
|
||||
'Risk assessments for embedded software and firmware',
|
||||
],
|
||||
},
|
||||
nis2: {
|
||||
fullName: de ? 'NIS-2-Richtlinie (EU 2022/2555)' : 'NIS2 Directive (EU 2022/2555)',
|
||||
status: de ? 'Umsetzung in nationales Recht laeuft' : 'National transposition in progress',
|
||||
statusColor: 'text-amber-400',
|
||||
statusIcon: Clock,
|
||||
deadline: de ? 'NIS2UmsuCG: voraussichtlich 2025/2026' : 'NIS2 Implementation Act: expected 2025/2026',
|
||||
affectedCompanies: de ? '30.000+ Unternehmen in DE (Energie, Transport, Gesundheit, Digital, KRITIS)' : '30,000+ companies in DE (Energy, Transport, Healthcare, Digital, Critical Infrastructure)',
|
||||
keyRequirements: de
|
||||
? [
|
||||
'Risikomanagement-Massnahmen (Art. 21)',
|
||||
'Incident-Meldepflichten: 24h Fruehwarnung, 72h Bericht (Art. 23)',
|
||||
'Business Continuity und Krisenmanagement',
|
||||
'Supply-Chain-Security (Lieferkettenrisiken)',
|
||||
'Geschaeftsleiterhaftung (persoenliche Haftung)',
|
||||
'Registrierung beim BSI',
|
||||
'Regelmaessige Audits und Nachweise',
|
||||
]
|
||||
: [
|
||||
'Risk management measures (Art. 21)',
|
||||
'Incident reporting: 24h early warning, 72h report (Art. 23)',
|
||||
'Business continuity and crisis management',
|
||||
'Supply chain security',
|
||||
'Management liability (personal liability)',
|
||||
'Registration with national authority (BSI)',
|
||||
'Regular audits and evidence',
|
||||
],
|
||||
fines: de ? 'Bis zu 10 Mio. EUR oder 2% des weltweiten Jahresumsatzes' : 'Up to EUR 10M or 2% of global annual revenue',
|
||||
howWeHelp: de
|
||||
? [
|
||||
'Cybersecurity-Policy-Generator nach BSI-Grundschutz',
|
||||
'Incident-Response-Plaene mit KI-Unterstuetzung',
|
||||
'Supply-Chain-Risikoanalyse',
|
||||
'Automatische Audit-Dokumentation',
|
||||
'NIS2-Readiness-Assessment',
|
||||
]
|
||||
: [
|
||||
'Cybersecurity policy generator based on BSI standards',
|
||||
'AI-assisted incident response plans',
|
||||
'Supply chain risk analysis',
|
||||
'Automatic audit documentation',
|
||||
'NIS2 readiness assessment',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const reg = regulations[activeTab]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-8">
|
||||
<p className="text-xs font-mono text-indigo-400/60 uppercase tracking-widest mb-2">
|
||||
{de ? 'Anhang' : 'Appendix'}
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.annex.regulatory.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.annex.regulatory.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<FadeInView delay={0.15}>
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Content */}
|
||||
<FadeInView delay={0.2} key={activeTab}>
|
||||
<div className="grid md:grid-cols-12 gap-4">
|
||||
{/* Left: Overview */}
|
||||
<div className="md:col-span-5 space-y-4">
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<h3 className="text-sm font-bold text-white mb-1">{reg.fullName}</h3>
|
||||
<div className="space-y-2 mt-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<reg.statusIcon className={`w-4 h-4 ${reg.statusColor}`} />
|
||||
<span className="text-white/60">{reg.status}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="w-4 h-4 text-white/30 mt-0.5" />
|
||||
<span className="text-white/60">{reg.deadline}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5" />
|
||||
<span className="text-red-400/80">{reg.fines}</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard hover={false} className="p-4">
|
||||
<p className="text-xs font-semibold text-emerald-400 uppercase tracking-wider mb-2">
|
||||
{de ? 'Wie ComplAI hilft' : 'How ComplAI Helps'}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{reg.howWeHelp.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-white/60">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400 mt-0.5 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Right: Requirements */}
|
||||
<div className="md:col-span-7">
|
||||
<GlassCard hover={false} className="p-4 h-full">
|
||||
<p className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">
|
||||
{de ? 'Kernanforderungen' : 'Key Requirements'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{reg.keyRequirements.map((req, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-3 p-2 rounded-lg bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<span className="text-xs font-mono text-indigo-400/60 mt-0.5 shrink-0 w-4 text-right">{idx + 1}</span>
|
||||
<span className="text-xs text-white/70">{req}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-white/20 mt-3">
|
||||
{de ? 'Betroffene Unternehmen' : 'Affected companies'}: {reg.affectedCompanies}
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Language } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Server, ShieldCheck, Bot } from 'lucide-react'
|
||||
import { Server, ScanLine, Bot } from 'lucide-react'
|
||||
import GlassCard from '../ui/GlassCard'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
@@ -13,7 +13,7 @@ interface SolutionSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
|
||||
const icons = [Server, ShieldCheck, Bot]
|
||||
const icons = [Server, ScanLine, Bot]
|
||||
const colors = ['from-blue-500 to-cyan-500', 'from-indigo-500 to-purple-500', 'from-purple-500 to-pink-500']
|
||||
|
||||
export default function SolutionSlide({ lang }: SolutionSlideProps) {
|
||||
@@ -26,7 +26,7 @@ export default function SolutionSlide({ lang }: SolutionSlideProps) {
|
||||
<GradientText>{i.solution.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">
|
||||
<BrandName /> — {lang === 'de' ? 'Compliance auf Autopilot' : 'Compliance on Autopilot'}
|
||||
{i.solution.subtitle}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Language, PitchTeamMember } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { User } from 'lucide-react'
|
||||
import { User, Linkedin } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface TeamSlideProps {
|
||||
lang: Language
|
||||
@@ -34,14 +35,39 @@ export default function TeamSlide({ lang, team }: TeamSlideProps) {
|
||||
className="bg-white/[0.08] backdrop-blur-xl border border-white/10 rounded-3xl p-8"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
{/* Avatar */}
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
|
||||
flex items-center justify-center shrink-0 shadow-lg">
|
||||
<User className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
{/* Avatar — Foto oder Fallback */}
|
||||
{member.photo_url ? (
|
||||
<div className="w-20 h-20 rounded-2xl overflow-hidden shrink-0 shadow-lg">
|
||||
<Image
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
|
||||
flex items-center justify-center shrink-0 shadow-lg">
|
||||
<User className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-white mb-1">{member.name}</h3>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-xl font-bold text-white">{member.name}</h3>
|
||||
{member.linkedin_url && (
|
||||
<a
|
||||
href={member.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-white/30 hover:text-[#0A66C2] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-indigo-400 text-sm font-medium mb-3">
|
||||
{lang === 'de' ? member.role_de : member.role_en}
|
||||
</p>
|
||||
|
||||
@@ -17,9 +17,32 @@ interface TheAskSlideProps {
|
||||
|
||||
const COLORS = ['#6366f1', '#a78bfa', '#60a5fa', '#34d399', '#fbbf24']
|
||||
|
||||
function formatFundingAmount(amount: number): { target: number; suffix: string } {
|
||||
if (amount >= 1_000_000) {
|
||||
return { target: Math.round(amount / 100_000) / 10, suffix: ' Mio.' }
|
||||
}
|
||||
if (amount >= 1_000) {
|
||||
return { target: Math.round(amount / 1_000), suffix: 'k' }
|
||||
}
|
||||
return { target: amount, suffix: '' }
|
||||
}
|
||||
|
||||
function formatTargetDate(dateStr: string, lang: Language): string {
|
||||
if (!dateStr) return 'TBD'
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
const quarter = Math.ceil((d.getMonth() + 1) / 3)
|
||||
return `Q${quarter} ${d.getFullYear()}`
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
|
||||
const i = t(lang)
|
||||
const useOfFunds = funding?.use_of_funds || []
|
||||
const amount = funding?.amount_eur || 0
|
||||
const { target, suffix } = formatFundingAmount(amount)
|
||||
|
||||
const pieData = useOfFunds.map((item) => ({
|
||||
name: lang === 'de' ? item.label_de : item.label_en,
|
||||
@@ -35,7 +58,7 @@ export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.theAsk.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
{/* Main Number */}
|
||||
{/* Main Number — dynamisch aus funding.amount_eur */}
|
||||
<FadeInView delay={0.2} className="text-center mb-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
@@ -43,13 +66,13 @@ export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<p className="text-6xl md:text-8xl font-bold text-white mb-2">
|
||||
<AnimatedCounter target={200} suffix="k" duration={2000} />
|
||||
<AnimatedCounter target={target} suffix={suffix} duration={2000} decimals={suffix === ' Mio.' ? 1 : 0} />
|
||||
<span className="text-3xl md:text-4xl text-white/50 ml-2">EUR</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
</FadeInView>
|
||||
|
||||
{/* Details */}
|
||||
{/* Details — dynamisch aus funding-Objekt */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<GlassCard delay={0.5} className="text-center p-5">
|
||||
<FileText className="w-6 h-6 text-indigo-400 mx-auto mb-2" />
|
||||
@@ -59,12 +82,14 @@ export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
|
||||
<GlassCard delay={0.6} className="text-center p-5">
|
||||
<Calendar className="w-6 h-6 text-purple-400 mx-auto mb-2" />
|
||||
<p className="text-xs text-white/40 mb-1">{i.theAsk.targetDate}</p>
|
||||
<p className="text-lg font-bold text-white">Q3 2026</p>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatTargetDate(funding?.target_date, lang)}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={0.7} className="text-center p-5">
|
||||
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
||||
<p className="text-xs text-white/40 mb-1">{lang === 'de' ? 'Runway' : 'Runway'}</p>
|
||||
<p className="text-lg font-bold text-white">18 {lang === 'de' ? 'Monate' : 'Months'}</p>
|
||||
<p className="text-xs text-white/40 mb-1">{lang === 'de' ? 'Runde' : 'Round'}</p>
|
||||
<p className="text-lg font-bold text-white">{funding?.round_name || 'Pre-Seed'}</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +139,7 @@ export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white">{item.percentage}%</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{((funding.amount_eur * item.percentage) / 100).toLocaleString('de-DE')} EUR
|
||||
{((amount * item.percentage) / 100).toLocaleString('de-DE')} EUR
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { FMResult } from '@/lib/types'
|
||||
import { Maximize2, Minimize2, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface AnnualPLTableProps {
|
||||
results: FMResult[]
|
||||
lang: 'de' | 'en'
|
||||
}
|
||||
|
||||
type AccountingStandard = 'hgb' | 'usgaap'
|
||||
|
||||
interface AnnualRow {
|
||||
year: number
|
||||
revenue: number
|
||||
@@ -24,13 +29,397 @@ interface AnnualRow {
|
||||
employees: number
|
||||
}
|
||||
|
||||
interface MonthlyRow {
|
||||
month: number
|
||||
monthInYear: number
|
||||
revenue: number
|
||||
cogs: number
|
||||
grossProfit: number
|
||||
grossMarginPct: number
|
||||
personnel: number
|
||||
marketing: number
|
||||
infra: number
|
||||
totalCosts: number
|
||||
ebitda: number
|
||||
ebitdaMarginPct: number
|
||||
customers: number
|
||||
employees: number
|
||||
mrr: number
|
||||
cashBalance: number
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
||||
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`
|
||||
return Math.round(v).toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function fmtMonth(v: number): string {
|
||||
return Math.round(v).toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
const MONTH_NAMES_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
const MONTH_NAMES_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
function getLineItems(lang: 'de' | 'en', standard: AccountingStandard) {
|
||||
const de = lang === 'de'
|
||||
const hgb = standard === 'hgb'
|
||||
|
||||
return [
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Umsatzerloese' : 'Revenue (Umsatzerloese)')
|
||||
: (de ? 'Revenue' : 'Revenue'),
|
||||
key: 'revenue' as keyof AnnualRow,
|
||||
monthKey: 'revenue' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Herstellungskosten' : '- Cost of Production')
|
||||
: (de ? '- COGS' : '- Cost of Goods Sold'),
|
||||
key: 'cogs' as keyof AnnualRow,
|
||||
monthKey: 'cogs' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '= Rohertrag' : '= Gross Profit')
|
||||
: (de ? '= Gross Profit' : '= Gross Profit'),
|
||||
key: 'grossProfit' as keyof AnnualRow,
|
||||
monthKey: 'grossProfit' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? ' Rohertragsmarge' : ' Gross Margin')
|
||||
: (de ? ' Gross Margin' : ' Gross Margin'),
|
||||
key: 'grossMarginPct' as keyof AnnualRow,
|
||||
monthKey: 'grossMarginPct' as keyof MonthlyRow,
|
||||
isPercent: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Personalaufwand' : '- Personnel Expenses')
|
||||
: (de ? '- Personnel' : '- Personnel'),
|
||||
key: 'personnel' as keyof AnnualRow,
|
||||
monthKey: 'personnel' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Vertrieb & Marketing' : '- Sales & Marketing')
|
||||
: (de ? '- Sales & Marketing' : '- Sales & Marketing'),
|
||||
key: 'marketing' as keyof AnnualRow,
|
||||
monthKey: 'marketing' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- sonstige betriebl. Aufwendungen' : '- Other Operating Expenses')
|
||||
: (de ? '- Infrastructure' : '- Infrastructure'),
|
||||
key: 'infra' as keyof AnnualRow,
|
||||
monthKey: 'infra' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '= Betriebsaufwand gesamt' : '= Total Operating Expenses')
|
||||
: (de ? '= Total OpEx' : '= Total OpEx'),
|
||||
key: 'totalOpex' as keyof AnnualRow,
|
||||
monthKey: 'totalCosts' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Betriebsergebnis (EBITDA)' : 'Operating Result (EBITDA)')
|
||||
: 'EBITDA',
|
||||
key: 'ebitda' as keyof AnnualRow,
|
||||
monthKey: 'ebitda' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? ' EBITDA-Marge' : ' EBITDA Margin')
|
||||
: (de ? ' EBITDA Margin' : ' EBITDA Margin'),
|
||||
key: 'ebitdaMarginPct' as keyof AnnualRow,
|
||||
monthKey: 'ebitdaMarginPct' as keyof MonthlyRow,
|
||||
isPercent: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Kunden (Stichtag)' : 'Customers (Reporting Date)')
|
||||
: (de ? 'Kunden (Jahresende)' : 'Customers (Year End)'),
|
||||
key: 'customers' as keyof AnnualRow,
|
||||
monthKey: 'customers' as keyof MonthlyRow,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Mitarbeiter (VZAe)' : 'Employees (FTE)')
|
||||
: (de ? 'Mitarbeiter' : 'Employees'),
|
||||
key: 'employees' as keyof AnnualRow,
|
||||
monthKey: 'employees' as keyof MonthlyRow,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function AnnualTable({
|
||||
rows,
|
||||
lang,
|
||||
expandedYear,
|
||||
onToggleYear,
|
||||
monthlyData,
|
||||
isFullscreen,
|
||||
standard,
|
||||
}: {
|
||||
rows: AnnualRow[]
|
||||
lang: 'de' | 'en'
|
||||
expandedYear: number | null
|
||||
onToggleYear: (year: number) => void
|
||||
monthlyData: Map<number, MonthlyRow[]>
|
||||
isFullscreen: boolean
|
||||
standard: AccountingStandard
|
||||
}) {
|
||||
const de = lang === 'de'
|
||||
const monthNames = de ? MONTH_NAMES_DE : MONTH_NAMES_EN
|
||||
const lineItems = getLineItems(lang, standard)
|
||||
|
||||
const monthlyExtraItems: { label: string; key: keyof MonthlyRow; isBold?: boolean }[] = isFullscreen ? [
|
||||
{ label: 'MRR', key: 'mrr', isBold: true },
|
||||
{ label: de ? 'Cash-Bestand' : 'Cash Balance', key: 'cashBalance', isBold: true },
|
||||
] : []
|
||||
|
||||
const textSize = isFullscreen ? 'text-xs' : 'text-[11px]'
|
||||
const minColWidth = isFullscreen ? 'min-w-[70px]' : 'min-w-[80px]'
|
||||
|
||||
return (
|
||||
<table className={`w-full ${textSize}`}>
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className={`text-left py-2 pr-4 text-white/40 font-medium ${isFullscreen ? 'min-w-[220px]' : 'min-w-[180px]'}`}>
|
||||
{standard === 'hgb'
|
||||
? (de ? 'GuV-Position (HGB)' : 'P&L Line Item (HGB)')
|
||||
: (de ? 'GuV-Position (US GAAP)' : 'P&L Line Item (US GAAP)')
|
||||
}
|
||||
</th>
|
||||
{rows.map(r => (
|
||||
<th
|
||||
key={r.year}
|
||||
className={`text-right py-2 px-2 text-white/50 font-semibold ${minColWidth} cursor-pointer hover:text-indigo-400 transition-colors`}
|
||||
onClick={() => onToggleYear(r.year)}
|
||||
title={de ? 'Klicken fuer Monatsdetails' : 'Click for monthly details'}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{expandedYear === r.year ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 opacity-30" />
|
||||
)}
|
||||
{r.year}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((item) => (
|
||||
<tr
|
||||
key={item.key}
|
||||
className={item.isSeparator ? 'border-t border-white/10' : ''}
|
||||
>
|
||||
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{rows.map(r => {
|
||||
const val = r[item.key] as number
|
||||
return (
|
||||
<td
|
||||
key={r.year}
|
||||
className={`text-right py-1.5 px-2 font-mono
|
||||
${item.isBold ? 'font-semibold' : ''}
|
||||
${item.isPercent ? 'text-white/30 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
|
||||
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(1)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
{/* Monthly Drill-Down */}
|
||||
{expandedYear && monthlyData.has(expandedYear) && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={rows.length + 1} className="pt-4 pb-1">
|
||||
<div className="border-t border-indigo-500/30 pt-3">
|
||||
<p className="text-xs font-semibold text-indigo-400 mb-2">
|
||||
{de ? `Monatsdetails ${expandedYear}` : `Monthly Details ${expandedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white/10">
|
||||
<td className="text-left py-1 pr-4 text-white/40 font-medium text-[10px]">
|
||||
{de ? 'Monat' : 'Month'}
|
||||
</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => (
|
||||
<td key={m.monthInYear} className="text-right py-1 px-1 text-white/40 font-medium text-[10px]">
|
||||
{monthNames[m.monthInYear - 1]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{lineItems.map((item) => {
|
||||
const mKey = item.monthKey
|
||||
if (!mKey) return null
|
||||
return (
|
||||
<tr key={`monthly-${item.key}`} className={item.isSeparator ? 'border-t border-white/5' : ''}>
|
||||
<td className={`py-1 pr-4 text-[10px] ${item.isBold ? 'text-white/70 font-medium' : 'text-white/30'} ${item.isPercent ? 'italic' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => {
|
||||
const val = m[mKey] as number
|
||||
return (
|
||||
<td
|
||||
key={m.monthInYear}
|
||||
className={`text-right py-1 px-1 font-mono text-[10px]
|
||||
${item.isPercent ? 'text-white/20 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/60' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400/70' : ''}
|
||||
${!item.isPercent ? 'text-white/40' : ''}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(0)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmtMonth(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{monthlyExtraItems.map((item) => (
|
||||
<tr key={`monthly-extra-${item.key}`} className="border-t border-white/5">
|
||||
<td className="py-1 pr-4 text-[10px] text-indigo-300/70 font-medium">{item.label}</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => {
|
||||
const val = m[item.key] as number
|
||||
return (
|
||||
<td key={m.monthInYear} className="text-right py-1 px-1 font-mono text-[10px] text-indigo-300/50">
|
||||
{fmtMonth(Math.round(val))}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function FullscreenOverlay({
|
||||
children,
|
||||
onClose,
|
||||
lang,
|
||||
standard,
|
||||
onStandardChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
lang: 'de' | 'en'
|
||||
standard: AccountingStandard
|
||||
onStandardChange: (s: AccountingStandard) => void
|
||||
}) {
|
||||
const de = lang === 'de'
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-slate-950/98 backdrop-blur-xl overflow-auto p-6">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{de ? 'Gewinn- und Verlustrechnung' : 'Profit & Loss Statement'}
|
||||
</h2>
|
||||
<p className="text-xs text-white/40">
|
||||
{de
|
||||
? 'Klicke auf ein Jahr um die Monatsdetails zu sehen · ESC zum Schliessen'
|
||||
: 'Click on a year to see monthly details · ESC to close'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* HGB / US GAAP Toggle */}
|
||||
<div className="flex items-center bg-white/[0.06] border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => onStandardChange('hgb')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
standard === 'hgb'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
HGB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStandardChange('usgaap')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
standard === 'usgaap'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
US GAAP
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/[0.08] hover:bg-white/[0.12] border border-white/10 rounded-xl text-sm text-white/70 hover:text-white transition-all"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
{de ? 'Schliessen' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] border border-white/10 rounded-2xl p-6 overflow-x-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [expandedYear, setExpandedYear] = useState<number | null>(null)
|
||||
const [standard, setStandard] = useState<AccountingStandard>('hgb')
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null)
|
||||
const de = lang === 'de'
|
||||
|
||||
// Portal mount point
|
||||
useEffect(() => {
|
||||
setPortalRoot(document.body)
|
||||
}, [])
|
||||
|
||||
// Aggregate monthly results into annual
|
||||
const annualMap = new Map<number, FMResult[]>()
|
||||
for (const r of results) {
|
||||
@@ -66,77 +455,120 @@ export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const de = lang === 'de'
|
||||
// Build monthly data for drill-down
|
||||
const monthlyData = new Map<number, MonthlyRow[]>()
|
||||
for (const [year, months] of annualMap.entries()) {
|
||||
monthlyData.set(year, months.map(m => {
|
||||
const grossProfit = m.revenue_eur - m.cogs_eur
|
||||
const totalCosts = m.personnel_eur + m.marketing_eur + m.infra_eur
|
||||
const ebitda = grossProfit - totalCosts
|
||||
return {
|
||||
month: m.month,
|
||||
monthInYear: m.month_in_year,
|
||||
revenue: m.revenue_eur,
|
||||
cogs: m.cogs_eur,
|
||||
grossProfit,
|
||||
grossMarginPct: m.revenue_eur > 0 ? (grossProfit / m.revenue_eur) * 100 : 0,
|
||||
personnel: m.personnel_eur,
|
||||
marketing: m.marketing_eur,
|
||||
infra: m.infra_eur,
|
||||
totalCosts,
|
||||
ebitda,
|
||||
ebitdaMarginPct: m.revenue_eur > 0 ? (ebitda / m.revenue_eur) * 100 : 0,
|
||||
customers: m.total_customers,
|
||||
employees: m.employees_count,
|
||||
mrr: m.mrr_eur,
|
||||
cashBalance: m.cash_balance_eur,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const lineItems: { label: string; key: keyof AnnualRow; isBold?: boolean; isPercent?: boolean; isSeparator?: boolean; isNegative?: boolean }[] = [
|
||||
{ label: de ? 'Umsatzerloese' : 'Revenue', key: 'revenue', isBold: true },
|
||||
{ label: de ? '- Herstellungskosten (COGS)' : '- Cost of Goods Sold', key: 'cogs', isNegative: true },
|
||||
{ label: de ? '= Rohertrag (Gross Profit)' : '= Gross Profit', key: 'grossProfit', isBold: true, isSeparator: true },
|
||||
{ label: de ? ' Rohertragsmarge' : ' Gross Margin', key: 'grossMarginPct', isPercent: true },
|
||||
{ label: de ? '- Personalkosten' : '- Personnel', key: 'personnel', isNegative: true },
|
||||
{ label: de ? '- Marketing & Vertrieb' : '- Marketing & Sales', key: 'marketing', isNegative: true },
|
||||
{ label: de ? '- Infrastruktur' : '- Infrastructure', key: 'infra', isNegative: true },
|
||||
{ label: de ? '= OpEx gesamt' : '= Total OpEx', key: 'totalOpex', isBold: true, isSeparator: true, isNegative: true },
|
||||
{ label: 'EBITDA', key: 'ebitda', isBold: true, isSeparator: true },
|
||||
{ label: de ? ' EBITDA-Marge' : ' EBITDA Margin', key: 'ebitdaMarginPct', isPercent: true },
|
||||
{ label: de ? 'Kunden (Jahresende)' : 'Customers (Year End)', key: 'customers' },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', key: 'employees' },
|
||||
]
|
||||
const handleToggleYear = (year: number) => {
|
||||
setExpandedYear(prev => prev === year ? null : year)
|
||||
}
|
||||
|
||||
const tableContent = (
|
||||
<AnnualTable
|
||||
rows={rows}
|
||||
lang={lang}
|
||||
expandedYear={expandedYear}
|
||||
onToggleYear={handleToggleYear}
|
||||
monthlyData={monthlyData}
|
||||
isFullscreen={isFullscreen}
|
||||
standard={standard}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="overflow-x-auto"
|
||||
>
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 pr-4 text-white/40 font-medium min-w-[180px]">
|
||||
{de ? 'GuV-Position' : 'P&L Line Item'}
|
||||
</th>
|
||||
{rows.map(r => (
|
||||
<th key={r.year} className="text-right py-2 px-2 text-white/50 font-semibold min-w-[80px]">
|
||||
{r.year}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((item) => (
|
||||
<tr
|
||||
key={item.key}
|
||||
className={`${item.isSeparator ? 'border-t border-white/10' : ''} ${item.isBold ? '' : ''}`}
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* HGB / US GAAP Toggle (inline) */}
|
||||
<div className="flex items-center bg-white/[0.04] border border-white/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setStandard('hgb')}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||
standard === 'hgb'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/30 hover:text-white/50'
|
||||
}`}
|
||||
>
|
||||
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{rows.map(r => {
|
||||
const val = r[item.key] as number
|
||||
const isNeg = val < 0 || item.isNegative
|
||||
return (
|
||||
<td
|
||||
key={r.year}
|
||||
className={`text-right py-1.5 px-2 font-mono
|
||||
${item.isBold ? 'font-semibold' : ''}
|
||||
${item.isPercent ? 'text-white/30 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
|
||||
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(1)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
HGB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStandard('usgaap')}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||
standard === 'usgaap'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/30 hover:text-white/50'
|
||||
}`}
|
||||
>
|
||||
US GAAP
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] border border-white/10 rounded-lg text-[10px] text-white/50 hover:text-white/80 transition-all"
|
||||
title={de ? 'Vollbild' : 'Fullscreen'}
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
{de ? 'Vollbild' : 'Fullscreen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{tableContent}
|
||||
</div>
|
||||
<p className="text-[9px] text-white/20 mt-2 text-center">
|
||||
{de
|
||||
? 'Klicke auf ein Jahr fuer Monatsdetails'
|
||||
: 'Click on a year for monthly details'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Fullscreen via Portal — escapes parent stacking context */}
|
||||
{isFullscreen && portalRoot && createPortal(
|
||||
<FullscreenOverlay
|
||||
onClose={() => setIsFullscreen(false)}
|
||||
lang={lang}
|
||||
standard={standard}
|
||||
onStandardChange={setStandard}
|
||||
>
|
||||
<AnnualTable
|
||||
rows={rows}
|
||||
lang={lang}
|
||||
expandedYear={expandedYear}
|
||||
onToggleYear={handleToggleYear}
|
||||
monthlyData={monthlyData}
|
||||
isFullscreen={true}
|
||||
standard={standard}
|
||||
/>
|
||||
</FullscreenOverlay>,
|
||||
portalRoot
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ const SLIDE_ORDER: SlideId[] = [
|
||||
'financials',
|
||||
'the-ask',
|
||||
'ai-qa',
|
||||
'annex-assumptions',
|
||||
'annex-architecture',
|
||||
'annex-gtm',
|
||||
'annex-regulatory',
|
||||
'annex-engineering',
|
||||
'annex-aipipeline',
|
||||
]
|
||||
|
||||
export const TOTAL_SLIDES = SLIDE_ORDER.length
|
||||
|
||||
@@ -21,51 +21,55 @@ const translations = {
|
||||
'Finanzen',
|
||||
'The Ask',
|
||||
'KI Q&A',
|
||||
'Anhang: Annahmen',
|
||||
'Anhang: Architektur',
|
||||
'Anhang: Go-to-Market',
|
||||
'Anhang: Regulatorik',
|
||||
],
|
||||
cover: {
|
||||
tagline: 'Datensouveraenitaet meets KI-Compliance',
|
||||
tagline: 'Compliance & Code-Security fuer den Maschinenbau',
|
||||
subtitle: 'Pre-Seed · Q4 2026',
|
||||
cta: 'Pitch starten',
|
||||
},
|
||||
problem: {
|
||||
title: 'Das Problem',
|
||||
subtitle: 'Compliance-Komplexitaet ueberfordert den Mittelstand',
|
||||
subtitle: 'Maschinenbauer entwickeln Software — aber wer sichert Compliance und Code-Sicherheit?',
|
||||
cards: [
|
||||
{
|
||||
title: 'DSGVO',
|
||||
stat: '4.1 Mrd EUR',
|
||||
desc: 'Bussgelder seit 2018 in der EU. 83% der KMUs sind nicht vollstaendig konform.',
|
||||
desc: 'Bussgelder seit 2018. Maschinenbauer verarbeiten Kundendaten, Telemetrie und Wartungsprotokolle — oft ohne DSGVO-Prozesse.',
|
||||
},
|
||||
{
|
||||
title: 'AI Act',
|
||||
stat: 'Aug 2025',
|
||||
desc: 'Neue EU-Verordnung tritt in Kraft. Unternehmen muessen KI-Systeme klassifizieren und dokumentieren.',
|
||||
desc: 'Maschinen mit KI-Komponenten muessen klassifiziert werden. Embedded KI in Steuerungen und Predictive Maintenance erfordert Dokumentation.',
|
||||
},
|
||||
{
|
||||
title: 'NIS2',
|
||||
title: 'CRA & NIS2',
|
||||
stat: '30.000+',
|
||||
desc: 'Unternehmen in Deutschland neu betroffen. Cybersecurity-Anforderungen steigen massiv.',
|
||||
desc: 'Der Cyber Resilience Act verpflichtet Hersteller, Software in ihren Produkten abzusichern. NIS2 erweitert die Cybersecurity-Pflichten auf den Maschinenbau.',
|
||||
},
|
||||
],
|
||||
quote: 'Unternehmen brauchen keine weiteren Compliance-Tools — sie brauchen eine KI, die Compliance fuer sie erledigt.',
|
||||
quote: 'Maschinenbauer brauchen keine Compliance-Berater — sie brauchen eine KI, die ihren Code scannt, Risiken bewertet und Compliance dokumentiert.',
|
||||
},
|
||||
solution: {
|
||||
title: 'Die Loesung',
|
||||
subtitle: 'ComplAI — Compliance auf Autopilot',
|
||||
subtitle: 'ComplAI — Compliance & Code-Security auf Autopilot',
|
||||
pillars: [
|
||||
{
|
||||
title: 'Self-Hosted',
|
||||
desc: 'Eigene Hardware im Serverraum. Kein Byte verlaesst das Unternehmen. Volle Datensouveraenitaet.',
|
||||
title: 'Self-Hosted Vorarbeit',
|
||||
desc: 'Mac Mini oder Mac Studio im Serverraum scannt Code, analysiert Repositories und erstellt Compliance-Dokumente. Kein Byte verlaesst das Unternehmen.',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
title: 'Auto-Compliance',
|
||||
desc: 'KI erledigt DSGVO, AI Act und NIS2 automatisch. Dokumentation, Audits und Updates — alles KI-gesteuert.',
|
||||
icon: 'shield',
|
||||
title: 'Code-Security & DevSecOps',
|
||||
desc: 'Scannt Firmware und Software mit integrierten DevSecOps-Tools (Trivy, Semgrep, Gitleaks). Das 1000B Cloud-LLM implementiert Fixes und schreibt Risikoanalysen.',
|
||||
icon: 'scan',
|
||||
},
|
||||
{
|
||||
title: 'KI-Assistent',
|
||||
desc: 'Vollautonomer Kundensupport. Beantwortet Fragen, aendert Dokumente, bereitet Audits vor — 24/7.',
|
||||
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',
|
||||
},
|
||||
],
|
||||
@@ -81,29 +85,29 @@ const translations = {
|
||||
},
|
||||
howItWorks: {
|
||||
title: 'So funktioniert\'s',
|
||||
subtitle: 'In 4 Schritten zur vollstaendigen Compliance',
|
||||
subtitle: 'In 4 Schritten zu Compliance & Code-Security',
|
||||
steps: [
|
||||
{
|
||||
title: 'Hardware aufstellen',
|
||||
desc: 'Mac Mini oder Mac Studio im Serverraum anschliessen. Plug & Play — keine Cloud noetig.',
|
||||
desc: 'Mac Mini oder Mac Studio im Serverraum anschliessen. Plug & Play — scannt ab Tag 1 Ihre Repositories.',
|
||||
},
|
||||
{
|
||||
title: 'KI konfigurieren',
|
||||
desc: 'Branche, Groesse und Regularien angeben. Die KI erstellt automatisch alle Compliance-Dokumente.',
|
||||
title: 'Code-Repos verbinden',
|
||||
desc: 'Git-Repos, CI/CD Pipelines und Firmware-Projekte anbinden. Die lokale KI scannt automatisch auf Schwachstellen und Compliance-Luecken.',
|
||||
},
|
||||
{
|
||||
title: 'Compliance automatisieren',
|
||||
desc: 'Laufende Ueberwachung, automatische Updates bei Rechtsaenderungen und Audit-Vorbereitung.',
|
||||
title: 'Compliance & Security automatisieren',
|
||||
desc: 'Laufende Code-Analyse und Risikoanalysen bei jeder Aenderung. Bei kritischen Fixes schaltet sich das 1000B Cloud-LLM zu und implementiert Verbesserungen.',
|
||||
},
|
||||
{
|
||||
title: 'Audit bestehen',
|
||||
desc: 'Vollstaendige Dokumentation auf Knopfdruck. Behoerdenanfragen werden KI-gestuetzt beantwortet.',
|
||||
desc: 'Vollstaendige Dokumentation fuer DSGVO, AI Act, CRA und NIS2 auf Knopfdruck. Risikobeurteilungen fuer Ihre Software inklusive.',
|
||||
},
|
||||
],
|
||||
},
|
||||
market: {
|
||||
title: 'Marktchance',
|
||||
subtitle: 'Der Compliance-Markt waechst zweistellig',
|
||||
subtitle: 'Der Maschinenbau braucht Compliance & Code-Security',
|
||||
tam: 'TAM',
|
||||
sam: 'SAM',
|
||||
som: 'SOM',
|
||||
@@ -133,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',
|
||||
@@ -184,12 +188,38 @@ const translations = {
|
||||
send: 'Senden',
|
||||
thinking: 'Denke nach...',
|
||||
suggestions: [
|
||||
'Wie skaliert das Geschaeftsmodell?',
|
||||
'Was ist der unfaire Vorteil?',
|
||||
'Wie sieht die Exit-Strategie aus?',
|
||||
'Warum Self-Hosting statt Cloud?',
|
||||
'Wie funktioniert die Code-Security fuer Firmware?',
|
||||
'Warum koennen Proliance und DataGuard das nicht?',
|
||||
'Was kostet die Loesung fuer einen Maschinenbauer?',
|
||||
'Wie sieht die Risikoanalyse fuer unsere Software aus?',
|
||||
],
|
||||
},
|
||||
annex: {
|
||||
assumptions: {
|
||||
title: 'Annahmen & Sensitivitaet',
|
||||
subtitle: 'Drei Szenarien fuer robuste Planung',
|
||||
},
|
||||
architecture: {
|
||||
title: 'Technische Architektur',
|
||||
subtitle: 'Self-Hosted KI-Stack fuer maximale Datensouveraenitaet',
|
||||
},
|
||||
gtm: {
|
||||
title: 'Go-to-Market Strategie',
|
||||
subtitle: 'Vom Pilot zum skalierbaren Vertrieb',
|
||||
},
|
||||
regulatory: {
|
||||
title: 'Regulatorische Details',
|
||||
subtitle: 'Die vier Saeulen der EU-Compliance fuer Maschinenbauer',
|
||||
},
|
||||
engineering: {
|
||||
title: 'Engineering Deep Dive',
|
||||
subtitle: '761K Zeilen Code \u00b7 45 Container \u00b7 100% Self-Hosted',
|
||||
},
|
||||
aipipeline: {
|
||||
title: 'KI-Pipeline Deep Dive',
|
||||
subtitle: 'RAG \u00b7 Multi-Agent-System \u00b7 Document Intelligence \u00b7 Quality Assurance',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
nav: {
|
||||
@@ -211,51 +241,55 @@ const translations = {
|
||||
'Financials',
|
||||
'The Ask',
|
||||
'AI Q&A',
|
||||
'Appendix: Assumptions',
|
||||
'Appendix: Architecture',
|
||||
'Appendix: Go-to-Market',
|
||||
'Appendix: Regulatory',
|
||||
],
|
||||
cover: {
|
||||
tagline: 'Data Sovereignty meets AI Compliance',
|
||||
tagline: 'Compliance & Code Security for Machine Manufacturers',
|
||||
subtitle: 'Pre-Seed · Q4 2026',
|
||||
cta: 'Start Pitch',
|
||||
},
|
||||
problem: {
|
||||
title: 'The Problem',
|
||||
subtitle: 'Compliance complexity overwhelms SMEs',
|
||||
subtitle: 'Machine manufacturers develop software — but who ensures compliance and code security?',
|
||||
cards: [
|
||||
{
|
||||
title: 'GDPR',
|
||||
stat: 'EUR 4.1B',
|
||||
desc: 'in fines since 2018 across the EU. 83% of SMEs are not fully compliant.',
|
||||
desc: 'in fines since 2018. Machine manufacturers process customer data, telemetry and maintenance logs — often without GDPR processes.',
|
||||
},
|
||||
{
|
||||
title: 'AI Act',
|
||||
stat: 'Aug 2025',
|
||||
desc: 'New EU regulation takes effect. Companies must classify and document AI systems.',
|
||||
desc: 'Machines with AI components must be classified. Embedded AI in controllers and predictive maintenance requires documentation.',
|
||||
},
|
||||
{
|
||||
title: 'NIS2',
|
||||
title: 'CRA & NIS2',
|
||||
stat: '30,000+',
|
||||
desc: 'companies newly affected in Germany. Cybersecurity requirements increase massively.',
|
||||
desc: 'The Cyber Resilience Act obligates manufacturers to secure software in their products. NIS2 extends cybersecurity obligations to machine manufacturing.',
|
||||
},
|
||||
],
|
||||
quote: 'Companies don\'t need more compliance tools — they need an AI that handles compliance for them.',
|
||||
quote: 'Machine manufacturers don\'t need compliance consultants — they need an AI that scans their code, assesses risks and documents compliance.',
|
||||
},
|
||||
solution: {
|
||||
title: 'The Solution',
|
||||
subtitle: 'ComplAI — Compliance on Autopilot',
|
||||
subtitle: 'ComplAI — Compliance & Code Security on Autopilot',
|
||||
pillars: [
|
||||
{
|
||||
title: 'Self-Hosted',
|
||||
desc: 'Own hardware in your server room. No data leaves the company. Full data sovereignty.',
|
||||
title: 'Self-Hosted Preprocessing',
|
||||
desc: 'Mac Mini or Mac Studio in your server room scans code, analyzes repositories and creates compliance documents. No data leaves the company.',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
title: 'Auto-Compliance',
|
||||
desc: 'AI handles GDPR, AI Act and NIS2 automatically. Documentation, audits and updates — all AI-powered.',
|
||||
icon: 'shield',
|
||||
title: 'Code Security & DevSecOps',
|
||||
desc: 'Scans firmware and software with integrated DevSecOps tools (Trivy, Semgrep, Gitleaks). The 1000B cloud LLM implements fixes and writes risk assessments.',
|
||||
icon: 'scan',
|
||||
},
|
||||
{
|
||||
title: 'AI Assistant',
|
||||
desc: 'Fully autonomous customer support. Answers questions, modifies documents, prepares audits — 24/7.',
|
||||
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',
|
||||
},
|
||||
],
|
||||
@@ -271,29 +305,29 @@ const translations = {
|
||||
},
|
||||
howItWorks: {
|
||||
title: 'How It Works',
|
||||
subtitle: 'Full compliance in 4 steps',
|
||||
subtitle: 'Compliance & code security in 4 steps',
|
||||
steps: [
|
||||
{
|
||||
title: 'Set Up Hardware',
|
||||
desc: 'Connect Mac Mini or Mac Studio in your server room. Plug & Play — no cloud needed.',
|
||||
desc: 'Connect Mac Mini or Mac Studio in your server room. Plug & Play — scans your repositories from day one.',
|
||||
},
|
||||
{
|
||||
title: 'Configure AI',
|
||||
desc: 'Specify industry, size, and regulations. The AI automatically creates all compliance documents.',
|
||||
title: 'Connect Code Repos',
|
||||
desc: 'Connect Git repos, CI/CD pipelines and firmware projects. The local AI automatically scans for vulnerabilities and compliance gaps.',
|
||||
},
|
||||
{
|
||||
title: 'Automate Compliance',
|
||||
desc: 'Continuous monitoring, automatic updates for regulatory changes and audit preparation.',
|
||||
title: 'Automate Compliance & Security',
|
||||
desc: 'Continuous code analysis and risk assessments on every change. For critical fixes, the 1000B cloud LLM steps in and implements improvements.',
|
||||
},
|
||||
{
|
||||
title: 'Pass Audits',
|
||||
desc: 'Complete documentation at the push of a button. Authority inquiries answered AI-powered.',
|
||||
desc: 'Complete documentation for GDPR, AI Act, CRA and NIS2 at the push of a button. Risk assessments for your software included.',
|
||||
},
|
||||
],
|
||||
},
|
||||
market: {
|
||||
title: 'Market Opportunity',
|
||||
subtitle: 'The compliance market grows double-digit',
|
||||
subtitle: 'Machine manufacturing needs compliance & code security',
|
||||
tam: 'TAM',
|
||||
sam: 'SAM',
|
||||
som: 'SOM',
|
||||
@@ -323,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',
|
||||
@@ -374,12 +408,38 @@ const translations = {
|
||||
send: 'Send',
|
||||
thinking: 'Thinking...',
|
||||
suggestions: [
|
||||
'How does the business model scale?',
|
||||
'What is the unfair advantage?',
|
||||
'What does the exit strategy look like?',
|
||||
'Why self-hosting instead of cloud?',
|
||||
'How does code security work for firmware?',
|
||||
'Why can\'t Proliance and DataGuard do this?',
|
||||
'What does the solution cost for a machine manufacturer?',
|
||||
'What does the risk assessment for our software look like?',
|
||||
],
|
||||
},
|
||||
annex: {
|
||||
assumptions: {
|
||||
title: 'Assumptions & Sensitivity',
|
||||
subtitle: 'Three scenarios for robust planning',
|
||||
},
|
||||
architecture: {
|
||||
title: 'Technical Architecture',
|
||||
subtitle: 'Self-hosted AI stack for maximum data sovereignty',
|
||||
},
|
||||
gtm: {
|
||||
title: 'Go-to-Market Strategy',
|
||||
subtitle: 'From pilot to scalable sales',
|
||||
},
|
||||
regulatory: {
|
||||
title: 'Regulatory Details',
|
||||
subtitle: 'The four pillars of EU compliance for machine manufacturers',
|
||||
},
|
||||
engineering: {
|
||||
title: 'Engineering Deep Dive',
|
||||
subtitle: '761K Lines of Code \u00b7 45 Containers \u00b7 100% Self-Hosted',
|
||||
},
|
||||
aipipeline: {
|
||||
title: 'AI Pipeline Deep Dive',
|
||||
subtitle: 'RAG \u00b7 Multi-Agent System \u00b7 Document Intelligence \u00b7 Quality Assurance',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -214,3 +214,9 @@ export type SlideId =
|
||||
| 'financials'
|
||||
| 'the-ask'
|
||||
| 'ai-qa'
|
||||
| 'annex-assumptions'
|
||||
| 'annex-architecture'
|
||||
| 'annex-gtm'
|
||||
| 'annex-regulatory'
|
||||
| 'annex-engineering'
|
||||
| 'annex-aipipeline'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user