Merge remote-tracking branch 'origin/main' into coolify

This commit is contained in:
Sharang Parnerkar
2026-03-07 23:10:41 +01:00
40 changed files with 350 additions and 6852 deletions
+15 -8
View File
@@ -6,19 +6,26 @@
| Geraet | Rolle | Aufgaben | | Geraet | Rolle | Aufgaben |
|--------|-------|----------| |--------|-------|----------|
| **MacBook** | Client | Claude Terminal, Browser (Frontend-Tests) | | **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
| **Mac Mini** | Server | Docker, alle Services, Code-Ausfuehrung, Tests, Git | | **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 ```bash
ssh macmini # 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
# Projektverzeichnis: # 2. Committen und pushen:
cd /Users/benjaminadmin/Projekte/breakpilot-core 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>" ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
``` ```
-5
View File
@@ -46,11 +46,6 @@ ERPNEXT_DB_ROOT_PASSWORD=erpnext_root
ERPNEXT_DB_PASSWORD=erpnext_secret ERPNEXT_DB_PASSWORD=erpnext_secret
ERPNEXT_ADMIN_PASSWORD=admin ERPNEXT_ADMIN_PASSWORD=admin
# Woodpecker CI
WOODPECKER_HOST=http://macmini:8090
WOODPECKER_ADMIN=pilotadmin
WOODPECKER_AGENT_SECRET=woodpecker-secret
# Gitea Runner # Gitea Runner
GITEA_RUNNER_TOKEN= GITEA_RUNNER_TOKEN=
-422
View File
@@ -1,422 +0,0 @@
# Woodpecker CI Main Pipeline
# BreakPilot Core - CI/CD Pipeline
#
# Plattform: ARM64 (Apple Silicon Mac Mini)
#
# Services:
# Go: consent-service
# Python: backend-core, voice-service (+ BQAS), embedding-service, night-scheduler
# Node.js: admin-core
#
# Strategie:
# - Lint bei PRs
# - Tests laufen bei JEDEM Push/PR
# - Test-Ergebnisse werden an Dashboard gesendet
# - Builds/Scans laufen nur bei Tags oder manuell
# - Deployment nur manuell (Sicherheit)
when:
- event: [push, pull_request, manual, tag]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
variables:
- &golang_image golang:1.23-alpine
- &python_image python:3.12-slim
- &nodejs_image node:20-alpine
- &docker_image docker:27-cli
steps:
# ========================================
# STAGE 1: Lint (nur bei PRs)
# ========================================
go-lint:
image: golangci/golangci-lint:v1.55-alpine
commands:
- cd consent-service && golangci-lint run --timeout 5m ./...
when:
event: pull_request
python-lint:
image: *python_image
commands:
- pip install --quiet ruff
- |
for svc in backend-core voice-service night-scheduler embedding-service; do
if [ -d "$svc" ]; then
echo "=== Linting $svc ==="
ruff check "$svc/" --output-format=github || true
fi
done
when:
event: pull_request
nodejs-lint:
image: *nodejs_image
commands:
- |
if [ -d "admin-core" ]; then
cd admin-core
npm ci --silent 2>/dev/null || npm install --silent
npx next lint || true
fi
when:
event: pull_request
# ========================================
# STAGE 2: Unit Tests mit JSON-Ausgabe
# Ergebnisse werden im Workspace gespeichert (.ci-results/)
# ========================================
test-go-consent:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "consent-service" ]; then
echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json
echo "WARNUNG: consent-service Verzeichnis nicht gefunden"
exit 0
fi
cd consent-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json
TEST_EXIT=$?
set -e
JSON_FILE="../.ci-results/test-consent.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json
cat ../.ci-results/results-consent.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-python-voice:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service" ]; then
echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json
echo "WARNUNG: voice-service Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --ignore=tests/bqas --json-report --json-report-file=../.ci-results/test-voice.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-voice.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json
cat ../.ci-results/results-voice.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-bqas-golden:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-golden.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json
cat ../.ci-results/results-bqas-golden.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-bqas-rag:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-rag.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json
cat ../.ci-results/results-bqas-rag.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
# ========================================
# STAGE 3: Test-Ergebnisse an Dashboard senden
# ========================================
report-test-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Test-Ergebnisse an Dashboard ==="
echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}"
ls -la .ci-results/ || echo "Verzeichnis nicht gefunden"
PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}"
for f in .ci-results/results-*.json; do
[ -f "$f" ] || continue
echo "Sending: $f"
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"repo\": \"breakpilot-core\",
\"status\": \"${PIPELINE_STATUS}\",
\"test_results\": $(cat "$f")
}" || echo "WARNUNG: Konnte $f nicht senden"
done
echo "=== Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- test-go-consent
- test-python-voice
- test-bqas-golden
- test-bqas-rag
# ========================================
# STAGE 4: Build & Security (nur Tags/manuell)
# ========================================
build-consent-service:
image: *docker_image
commands:
- |
if [ -d ./consent-service ]; then
docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service
docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest
echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}"
else
echo "consent-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-backend-core:
image: *docker_image
commands:
- |
if [ -d ./backend-core ]; then
docker build -t breakpilot/backend-core:${CI_COMMIT_SHA:0:8} ./backend-core
docker tag breakpilot/backend-core:${CI_COMMIT_SHA:0:8} breakpilot/backend-core:latest
echo "Built breakpilot/backend-core:${CI_COMMIT_SHA:0:8}"
else
echo "backend-core Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-admin-core:
image: *docker_image
commands:
- |
if [ -d ./admin-core ]; then
docker build -t breakpilot/admin-core:${CI_COMMIT_SHA:0:8} ./admin-core
docker tag breakpilot/admin-core:${CI_COMMIT_SHA:0:8} breakpilot/admin-core:latest
echo "Built breakpilot/admin-core:${CI_COMMIT_SHA:0:8}"
else
echo "admin-core Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-voice-service:
image: *docker_image
commands:
- |
if [ -d ./voice-service ]; then
docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service
docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest
echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}"
else
echo "voice-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-embedding-service:
image: *docker_image
commands:
- |
if [ -d ./embedding-service ]; then
docker build -t breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} ./embedding-service
docker tag breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} breakpilot/embedding-service:latest
echo "Built breakpilot/embedding-service:${CI_COMMIT_SHA:0:8}"
else
echo "embedding-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
build-night-scheduler:
image: *docker_image
commands:
- |
if [ -d ./night-scheduler ]; then
docker build -t breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} ./night-scheduler
docker tag breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} breakpilot/night-scheduler:latest
echo "Built breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8}"
else
echo "night-scheduler Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
generate-sbom:
image: *golang_image
commands:
- |
echo "Installing syft for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
for svc in consent-service backend-core voice-service embedding-service night-scheduler; do
if [ -d "./$svc" ]; then
syft dir:./$svc -o cyclonedx-json > sbom-$svc.json
echo "SBOM generated for $svc"
fi
done
when:
- event: tag
- event: manual
vulnerability-scan:
image: *golang_image
commands:
- |
echo "Installing grype for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
for f in sbom-*.json; do
[ -f "$f" ] || continue
echo "=== Scanning $f ==="
grype sbom:"$f" -o table --fail-on critical || true
done
when:
- event: tag
- event: manual
depends_on:
- generate-sbom
# ========================================
# STAGE 5: Deploy (nur manuell)
# ========================================
deploy-production:
image: *docker_image
commands:
- echo "Deploying breakpilot-core to production..."
- docker compose -f docker-compose.yml pull || true
- docker compose -f docker-compose.yml up -d --remove-orphans || true
when:
event: manual
depends_on:
- build-consent-service
- build-backend-core
- build-admin-core
- build-voice-service
- build-embedding-service
- build-night-scheduler
@@ -1,912 +0,0 @@
'use client'
/**
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
*
* Google Alerts & Feed-Ueberwachung Dashboard
* Provides inbox management, topic configuration, rule builder, and relevance profiles
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface AlertItem {
id: string
title: string
url: string
snippet: string
topic_name: string
relevance_score: number | null
relevance_decision: string | null
status: string
fetched_at: string
published_at: string | null
matched_rule: string | null
tags: string[]
}
interface Topic {
id: string
name: string
feed_url: string
feed_type: string
is_active: boolean
fetch_interval_minutes: number
last_fetched_at: string | null
alert_count: number
}
interface Rule {
id: string
name: string
topic_id: string | null
conditions: Array<{
field: string
operator: string
value: string | number
}>
action_type: string
action_config: Record<string, unknown>
priority: number
is_active: boolean
}
interface Profile {
priorities: string[]
exclusions: string[]
positive_examples: Array<{ title: string; url: string }>
negative_examples: Array<{ title: string; url: string }>
policies: {
keep_threshold: number
drop_threshold: number
}
}
interface Stats {
total_alerts: number
new_alerts: number
kept_alerts: number
review_alerts: number
dropped_alerts: number
total_topics: number
active_topics: number
total_rules: number
}
// Tab type
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
export default function AlertsPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [stats, setStats] = useState<Stats | null>(null)
const [alerts, setAlerts] = useState<AlertItem[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inboxFilter, setInboxFilter] = useState<string>('all')
const API_BASE = '/api/alerts'
const fetchData = useCallback(async () => {
try {
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
fetch(`${API_BASE}/stats`),
fetch(`${API_BASE}/inbox?limit=50`),
fetch(`${API_BASE}/topics`),
fetch(`${API_BASE}/rules`),
fetch(`${API_BASE}/profile`),
])
if (statsRes.ok) setStats(await statsRes.json())
if (alertsRes.ok) {
const data = await alertsRes.json()
setAlerts(data.items || [])
}
if (topicsRes.ok) {
const data = await topicsRes.json()
setTopics(data.topics || data.items || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.rules || data.items || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
// Set demo data
setStats({
total_alerts: 147,
new_alerts: 23,
kept_alerts: 89,
review_alerts: 12,
dropped_alerts: 23,
total_topics: 5,
active_topics: 4,
total_rules: 8,
})
setAlerts([
{
id: 'demo_1',
title: 'Neue Studie zur digitalen Bildung an Schulen',
url: 'https://example.com/artikel1',
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
topic_name: 'Digitale Bildung',
relevance_score: 0.85,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date().toISOString(),
published_at: null,
matched_rule: null,
tags: ['bildung', 'digital'],
},
{
id: 'demo_2',
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
url: 'https://example.com/artikel2',
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
topic_name: 'Inklusion',
relevance_score: 0.72,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date(Date.now() - 3600000).toISOString(),
published_at: null,
matched_rule: null,
tags: ['inklusion'],
},
])
setTopics([
{
id: 'topic_1',
name: 'Digitale Bildung',
feed_url: 'https://google.com/alerts/feeds/123',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date().toISOString(),
alert_count: 47,
},
{
id: 'topic_2',
name: 'Inklusion',
feed_url: 'https://google.com/alerts/feeds/456',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
alert_count: 32,
},
])
setRules([
{
id: 'rule_1',
name: 'Stellenanzeigen ausschliessen',
topic_id: null,
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
action_type: 'drop',
action_config: {},
priority: 10,
is_active: true,
},
])
setProfile({
priorities: ['Inklusion', 'digitale Bildung'],
exclusions: ['Stellenanzeigen', 'Werbung'],
positive_examples: [],
negative_examples: [],
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const formatTimeAgo = (dateStr: string | null) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
const getScoreBadge = (score: number | null) => {
if (score === null) return null
const pct = Math.round(score * 100)
let cls = 'bg-slate-100 text-slate-600'
if (pct >= 70) cls = 'bg-green-100 text-green-800'
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
else cls = 'bg-red-100 text-red-800'
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
}
const getDecisionBadge = (decision: string | null) => {
if (!decision) return null
const styles: Record<string, string> = {
KEEP: 'bg-green-100 text-green-800',
REVIEW: 'bg-amber-100 text-amber-800',
DROP: 'bg-red-100 text-red-800',
}
return (
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
{decision}
</span>
)
}
const filteredAlerts = alerts.filter((alert) => {
if (inboxFilter === 'all') return true
if (inboxFilter === 'new') return alert.status === 'new'
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
return true
})
const tabs: { id: TabId; label: string; badge?: number }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
{ id: 'topics', label: 'Topics' },
{ id: 'rules', label: 'Regeln' },
{ id: 'profile', label: 'Profil' },
{ id: 'audit', label: 'Audit' },
{ id: 'documentation', label: 'Dokumentation' },
]
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
</div>
)
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Alerts Monitoring"
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
audience={['Marketing', 'Admins', 'DSB']}
architecture={{
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
databases: ['PostgreSQL', 'Valkey Cache'],
}}
relatedPages={[
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
<div className="text-sm text-slate-500">Alerts gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
<div className="text-sm text-slate-500">Neue Alerts</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
<div className="text-sm text-slate-500">Relevant</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
<div className="text-sm text-slate-500">Zur Pruefung</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-slate-200 px-4">
<nav className="flex gap-4 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
activeTab === tab.id
? 'border-green-600 text-green-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
{tab.badge}
</span>
)}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Dashboard Tab */}
{activeTab === 'dashboard' && (
<div className="space-y-6">
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
<div className="space-y-3">
{topics.slice(0, 5).map((topic) => (
<div key={topic.id} className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200">
<div>
<div className="font-medium text-slate-900">{topic.name}</div>
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
))}
{topics.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
)}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
<div className="space-y-3">
{alerts.slice(0, 5).map((alert) => (
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{alert.topic_name}</span>
{getScoreBadge(alert.relevance_score)}
</div>
</div>
))}
{alerts.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
)}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)}
{/* Inbox Tab */}
{activeTab === 'inbox' && (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'new', 'keep', 'review'].map((filter) => (
<button
key={filter}
onClick={() => setInboxFilter(filter)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
inboxFilter === filter
? 'bg-green-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' && 'Alle'}
{filter === 'new' && 'Neu'}
{filter === 'keep' && 'Relevant'}
{filter === 'review' && 'Pruefung'}
</button>
))}
</div>
{/* Alerts Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-slate-50">
<td className="p-4">
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-green-600">
{alert.title}
</a>
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
</td>
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
))}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
Keine Alerts gefunden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Topics Tab */}
{activeTab === 'topics' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
+ Topic hinzufuegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex justify-between items-start mb-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
<div className="text-sm">
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
<span className="text-slate-500"> Alerts</span>
</div>
<div className="text-xs text-slate-500">
{formatTimeAgo(topic.last_fetched_at)}
</div>
</div>
</div>
))}
{topics.length === 0 && (
<div className="col-span-full text-center py-8 text-slate-500">
Keine Topics konfiguriert
</div>
)}
</div>
</div>
)}
{/* Rules Tab */}
{activeTab === 'rules' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
/>
</div>
</div>
))}
{rules.length === 0 && (
<div className="p-8 text-center text-slate-500">
Keine Regeln konfiguriert
</div>
)}
</div>
</div>
)}
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="max-w-2xl space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Prioritaeten (wichtige Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
rows={4}
defaultValue={profile?.priorities?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ausschluesse (unerwuenschte Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
rows={4}
defaultValue={profile?.exclusions?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert KEEP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
defaultValue={profile?.policies?.keep_threshold || 0.7}
>
<option value={0.8}>80% (sehr streng)</option>
<option value={0.7}>70% (empfohlen)</option>
<option value={0.6}>60% (weniger streng)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert DROP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
defaultValue={profile?.policies?.drop_threshold || 0.3}
>
<option value={0.4}>40% (strenger)</option>
<option value={0.3}>30% (empfohlen)</option>
<option value={0.2}>20% (lockerer)</option>
</select>
</div>
</div>
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
Profil speichern
</button>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="space-y-6">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Database Info */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
Datenbank
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Tabellen</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Indizes</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Backups</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
</div>
</div>
</div>
{/* API Security */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
API Sicherheit
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Authentifizierung</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Rate Limiting</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Input Validation</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
</div>
</div>
</div>
{/* Logging */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Logging & Monitoring
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Structured Logging</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Metriken</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Health Checks</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
</div>
</div>
</div>
</div>
{/* Privacy Notes */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
<ul className="space-y-1">
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
DSGVO-konforme Datenverarbeitung
</li>
</ul>
</div>
</div>
)}
{/* Documentation Tab */}
{activeTab === 'documentation' && (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
{/* Header */}
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
</div>
{/* Audit Box */}
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
<p className="text-sm text-blue-800">
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
</p>
</div>
{/* Ziel des Systems */}
<h2>Ziel des Alert-Systems</h2>
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
<ul>
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
</ul>
{/* Architecture Diagram */}
<h2>Systemarchitektur</h2>
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-xs">{`
┌─────────────────────────────────────────────────────────────────────┐
│ BreakPilot Alerts Frontend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
└───────────────────────────────┬─────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Ingestion Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ └───────────────────┼───────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Deduplication (URL-Hash + SimHash) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Rule Engine │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LLM Relevance Scorer │ │
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Action Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`}</pre>
</div>
{/* API Endpoints */}
<h2>API Endpoints</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
</tbody>
</table>
</div>
{/* Rule Engine */}
<h2>Rule Engine - Operatoren</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains &quot;Inklusion&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains &quot;Werbung&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals &quot;new&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex &quot;\d&#123;4&#125;&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
</tbody>
</table>
</div>
{/* Scoring */}
<h2>LLM Relevanz-Scoring</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
</tbody>
</table>
</div>
{/* Contact */}
<h2>Kontakt & Support</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
</tbody>
</table>
</div>
{/* Footer */}
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -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: &quot;{intent.example}&quot;
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* DSGVO Tab */}
{activeTab === 'dsgvo' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
{/* Key Principles */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
</ul>
</div>
{/* Data Categories Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{DSGVO_CATEGORIES.map((cat) => (
<tr key={cat.category}>
<td className="px-4 py-3">
<span className="mr-2">{cat.icon}</span>
<span className="font-medium">{cat.category}</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{cat.risk.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Audit Log Info */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">Erlaubt:</span>
<ul className="list-disc list-inside text-slate-600 mt-1">
<li>ref_id (truncated)</li>
<li>content_type</li>
<li>size_bytes</li>
<li>ttl_hours</li>
</ul>
</div>
<div>
<span className="text-red-600 font-medium">Verboten:</span>
<ul className="list-disc list-inside text-slate-600 mt-1">
<li>user_name</li>
<li>content / transcript</li>
<li>email</li>
<li>student_name</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* API Tab */}
{activeTab === 'api' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
{/* REST Endpoints */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{API_ENDPOINTS.map((ep, idx) => (
<tr key={idx}>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{ep.method}
</span>
</td>
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* WebSocket Protocol */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="bg-white rounded-lg p-3 border border-slate-200">
<div className="font-medium text-slate-700 mb-2">Client Server</div>
<ul className="list-disc list-inside text-slate-600 space-y-1">
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
</ul>
</div>
<div className="bg-white rounded-lg p-3 border border-slate-200">
<div className="font-medium text-slate-700 mb-2">Server Client</div>
<ul className="list-disc list-inside text-slate-600 space-y-1">
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
</ul>
</div>
</div>
</div>
{/* Example curl commands */}
<div className="bg-slate-900 rounded-lg p-4 text-sm">
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
-H "Content-Type: application/json" \\
-d '{
"namespace_id": "ns-12345678abcdef12345678abcdef12",
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
"device_type": "pwa"
}'`}</pre>
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -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: 'Jitsi Meet', status: 'unknown' },
{ name: 'Mailpit', status: 'unknown' }, { name: 'Mailpit', status: 'unknown' },
{ name: 'Gitea', status: 'unknown' }, { name: 'Gitea', status: 'unknown' },
{ name: 'Woodpecker CI', status: 'unknown' },
{ name: 'Backend Core', 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 stopped_containers: number
} }
type TabType = 'overview' | 'woodpecker' | 'pipelines' | 'deployments' | 'setup' | 'scheduler' type TabType = 'overview' | '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
}
// ============================================================================ // ============================================================================
// Helper Components // Helper Components
@@ -168,10 +137,6 @@ export default function CICDPage() {
const [containerFilter, setContainerFilter] = useState<'all' | 'running' | 'stopped'>('all') const [containerFilter, setContainerFilter] = useState<'all' | 'running' | 'stopped'>('all')
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
// Woodpecker State
const [woodpeckerStatus, setWoodpeckerStatus] = useState<WoodpeckerStatus | null>(null)
const [triggeringWoodpecker, setTriggeringWoodpecker] = useState(false)
// General State // General State
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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 () => { const loadAllData = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
await Promise.all([loadPipelineData(), loadContainerData(), loadWoodpeckerData()]) await Promise.all([loadPipelineData(), loadContainerData()])
setLoading(false) setLoading(false)
}, [loadPipelineData, loadContainerData, loadWoodpeckerData]) }, [loadPipelineData, loadContainerData])
useEffect(() => { useEffect(() => {
loadAllData() 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" /> <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> </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: ( { id: 'pipelines', name: 'Gitea Pipelines', icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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' && ( {activeTab === 'overview' && (
<div className="space-y-6"> <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 */} {/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <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'}`}> <div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
@@ -679,299 +508,6 @@ export default function CICDPage() {
</div> </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 */} {/* 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' }, { 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 ===== // ===== 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 with Actions CI/CD', 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', 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' }, { 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 ===== // ===== DEVELOPMENT =====
@@ -120,11 +119,6 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== GAME (Breakpilot Drive) ===== // ===== 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: '-' }, { 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) ===== // ===== BQAS (Quality Assurance) =====
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' }, { 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: '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: '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: '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) // 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"> <div className="mt-4 pt-4 border-t border-blue-200">
<p className="text-sm text-blue-600"> <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> </p>
</div> </div>
</div> </div>
@@ -1185,9 +1185,6 @@ export default function TestDashboardPage() {
const DEMO_SERVICES: ServiceTestInfo[] = [ 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: '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: '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: '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: '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' }, { 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 }
)
}
}
-2
View File
@@ -16,7 +16,6 @@ const SERVICES: ServiceConfig[] = [
// Core Services // Core Services
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' }, { name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/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: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' }, { name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
{ name: 'Edu Search', port: 8088, endpoint: '/health', 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> = { const serviceMap: Record<number, string> = {
8000: 'backend', 8000: 'backend',
8081: 'consent-service', 8081: 'consent-service',
8091: 'voice-service',
8086: 'klausur-service', 8086: 'klausur-service',
8025: 'mailpit', 8025: 'mailpit',
8088: 'edu-search-service', 8088: 'edu-search-service',
@@ -1,271 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
// Woodpecker API configuration
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-core:8000'
export interface PipelineStep {
name: string
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
exit_code: number
error?: string
}
export interface Pipeline {
id: number
number: number
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
event: string
branch: string
commit: string
message: string
author: string
created: number
started: number
finished: number
steps: PipelineStep[]
errors?: string[]
repo_name?: string
}
export interface WoodpeckerStatusResponse {
status: 'online' | 'offline'
pipelines: Pipeline[]
lastUpdate: string
error?: string
}
async function fetchFromBackendProxy(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
// Use backend-core proxy that reads Woodpecker sqlite DB directly
const url = `${BACKEND_URL}/api/v1/woodpecker/pipelines?repo=${repoId}&limit=${limit}`
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) {
return {
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: `Backend Woodpecker Proxy Fehler (${response.status})`
}
}
const data = await response.json()
return {
status: data.status || 'online',
pipelines: (data.pipelines || []).map((p: any) => ({
id: p.id,
number: p.number,
status: p.status,
event: p.event,
branch: p.branch || 'main',
commit: p.commit || '',
message: p.message || '',
author: p.author || '',
created: p.created,
started: p.started,
finished: p.finished,
repo_name: p.repo_name,
steps: (p.steps || []).map((s: any) => ({
name: s.name,
state: s.state,
exit_code: s.exit_code || 0,
error: s.error
})),
})),
lastUpdate: data.lastUpdate || new Date().toISOString(),
}
}
async function fetchFromWoodpeckerAPI(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines?per_page=${limit}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
}
)
if (!response.ok) {
return {
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: `Woodpecker API nicht erreichbar (${response.status})`
}
}
const rawPipelines = await response.json()
const pipelines: Pipeline[] = rawPipelines.map((p: any) => {
const errors: string[] = []
const steps: PipelineStep[] = []
if (p.workflows) {
for (const workflow of p.workflows) {
if (workflow.children) {
for (const child of workflow.children) {
steps.push({
name: child.name,
state: child.state,
exit_code: child.exit_code,
error: child.error
})
if (child.state === 'failure' && child.error) {
errors.push(`${child.name}: ${child.error}`)
}
}
}
}
}
return {
id: p.id,
number: p.number,
status: p.status,
event: p.event,
branch: p.branch,
commit: p.commit?.substring(0, 7) || '',
message: p.message || '',
author: p.author,
created: p.created,
started: p.started,
finished: p.finished,
steps,
errors: errors.length > 0 ? errors : undefined
}
})
return {
status: 'online',
pipelines,
lastUpdate: new Date().toISOString()
}
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const repoId = searchParams.get('repo') || '0'
const limit = parseInt(searchParams.get('limit') || '10')
try {
// If WOODPECKER_TOKEN is set, use the Woodpecker API directly
// Otherwise, use the backend proxy that reads the sqlite DB
if (WOODPECKER_TOKEN) {
return NextResponse.json(await fetchFromWoodpeckerAPI(repoId, limit))
} else {
return NextResponse.json(await fetchFromBackendProxy(repoId, limit))
}
} catch (error) {
console.error('Woodpecker API error:', error)
return NextResponse.json({
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: 'Fehler beim Abrufen des Woodpecker Status'
} as WoodpeckerStatusResponse)
}
}
// Trigger a new pipeline
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { repoId = '1', branch = 'main' } = body
if (!WOODPECKER_TOKEN) {
return NextResponse.json(
{ error: 'WOODPECKER_TOKEN nicht konfiguriert - Pipeline-Start nicht moeglich' },
{ status: 503 }
)
}
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ branch }),
}
)
if (!response.ok) {
return NextResponse.json(
{ error: 'Pipeline konnte nicht gestartet werden' },
{ status: 500 }
)
}
const pipeline = await response.json()
return NextResponse.json({
success: true,
pipeline: {
id: pipeline.id,
number: pipeline.number,
status: pipeline.status
}
})
} catch (error) {
console.error('Pipeline trigger error:', error)
return NextResponse.json(
{ error: 'Fehler beim Starten der Pipeline' },
{ status: 500 }
)
}
}
// Get pipeline logs
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { repoId = '1', pipelineNumber, stepId } = body
if (!pipelineNumber || !stepId) {
return NextResponse.json(
{ error: 'pipelineNumber und stepId erforderlich' },
{ status: 400 }
)
}
if (!WOODPECKER_TOKEN) {
return NextResponse.json(
{ error: 'WOODPECKER_TOKEN nicht konfiguriert' },
{ status: 503 }
)
}
const response = await fetch(
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines/${pipelineNumber}/logs/${stepId}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
return NextResponse.json(
{ error: 'Logs nicht verfuegbar' },
{ status: response.status }
)
}
const logs = await response.json()
return NextResponse.json({ logs })
} catch (error) {
console.error('Pipeline logs error:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Logs' },
{ status: 500 }
)
}
}
-81
View File
@@ -1,81 +0,0 @@
import { NextResponse } from 'next/server'
/**
* Server-side proxy for Mailpit API
* Avoids CORS and mixed-content issues by fetching from server
*/
// Use internal Docker hostname when running in container
const getMailpitHost = (): string => {
return process.env.BACKEND_URL ? 'mailpit' : 'localhost'
}
export async function GET() {
const host = getMailpitHost()
const mailpitUrl = `http://${host}:8025/api/v1/info`
try {
const response = await fetch(mailpitUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
})
if (!response.ok) {
return NextResponse.json(
{ error: 'Mailpit API error', status: response.status },
{ status: response.status }
)
}
const data = await response.json()
// Transform Mailpit response to our expected format
return NextResponse.json({
stats: {
totalAccounts: 1,
activeAccounts: 1,
totalEmails: data.Messages || 0,
unreadEmails: data.Unread || 0,
totalTasks: 0,
pendingTasks: 0,
overdueTasks: 0,
aiAnalyzedCount: 0,
lastSyncTime: new Date().toISOString(),
},
accounts: [{
id: 'mailpit-dev',
email: 'dev@mailpit.local',
displayName: 'Mailpit (Development)',
imapHost: 'mailpit',
imapPort: 1143,
smtpHost: 'mailpit',
smtpPort: 1025,
status: 'active' as const,
lastSync: new Date().toISOString(),
emailCount: data.Messages || 0,
unreadCount: data.Unread || 0,
createdAt: new Date().toISOString(),
}],
syncStatus: {
running: false,
accountsInProgress: [],
lastCompleted: new Date().toISOString(),
errors: [],
},
mailpitInfo: {
version: data.Version,
databaseSize: data.DatabaseSize,
uptime: data.RuntimeStats?.Uptime,
}
})
} catch (error) {
console.error('Failed to fetch from Mailpit:', error)
return NextResponse.json(
{
error: 'Failed to connect to Mailpit',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 503 }
)
}
}
@@ -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) const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
useEffect(() => { useEffect(() => {
// Optional: Fetch live status from API // Live status fetching not yet implemented
// 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)
*/
}, []) }, [])
return status return status
@@ -246,7 +228,7 @@ export function DevOpsPipelineSidebar({
<div className="pt-2 border-t border-slate-200 dark:border-gray-700"> <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"> <div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'ci-cd' && ( {currentTool === 'ci-cd' && (
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span> <span>Verwalten Sie Gitea Actions Pipelines und Deployments</span>
)} )}
{currentTool === 'tests' && ( {currentTool === 'tests' && (
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span> <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"> <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' && ( {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' && ( {currentTool === 'tests' && (
+2 -93
View File
@@ -4,7 +4,7 @@
* 3 Categories: Communication, Infrastructure, Development * 3 Categories: Communication, Infrastructure, Development
*/ */
export type CategoryId = 'communication' | 'infrastructure' | 'development' export type CategoryId = 'infrastructure'
export interface NavModule { export interface NavModule {
id: string id: string
@@ -27,51 +27,6 @@ export interface NavCategory {
} }
export const navigation: 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) // Infrastruktur & DevOps (Orange)
// ========================================================================= // =========================================================================
@@ -83,15 +38,6 @@ export const navigation: NavCategory[] = [
colorClass: 'infrastructure', colorClass: 'infrastructure',
description: 'GPU, Security, CI/CD & Monitoring', description: 'GPU, Security, CI/CD & Monitoring',
modules: [ 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', id: 'middleware',
name: 'Middleware', name: 'Middleware',
@@ -123,7 +69,7 @@ export const navigation: NavCategory[] = [
id: 'ci-cd', id: 'ci-cd',
name: 'CI/CD Dashboard', name: 'CI/CD Dashboard',
href: '/infrastructure/ci-cd', href: '/infrastructure/ci-cd',
description: 'Gitea & Woodpecker Pipelines', description: 'Gitea Actions Pipelines',
purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.', purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.',
audience: ['DevOps', 'Entwickler'], audience: ['DevOps', 'Entwickler'],
subgroup: 'DevOps Pipeline', 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) // Meta modules (always visible)
+4 -27
View File
@@ -2,7 +2,7 @@
* Shared Types & Constants for Infrastructure/DevOps Modules * Shared Types & Constants for Infrastructure/DevOps Modules
* *
* Diese Datei enthaelt gemeinsame Typen und Konstanten fuer die DevOps-Pipeline: * 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 * - Tests: Test Dashboard & Backlog
* - SBOM: Software Bill of Materials & Lizenz-Checks * - SBOM: Software Bill of Materials & Lizenz-Checks
* - Security: DevSecOps Scans & Vulnerabilities * - Security: DevSecOps Scans & Vulnerabilities
@@ -230,24 +230,6 @@ export interface LogExtractionResponse {
// Webhook Types // 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 // LLM Integration Types
// ============================================================================= // =============================================================================
@@ -346,18 +328,14 @@ export interface PipelineLiveStatus {
export const INFRASTRUCTURE_API_ENDPOINTS = { export const INFRASTRUCTURE_API_ENDPOINTS = {
/** CI/CD Endpoints */ /** CI/CD Endpoints */
CI_CD: { CI_CD: {
PIPELINES: '/api/admin/infrastructure/woodpecker', PIPELINES: '/api/v1/security/sbom/pipeline/history',
TRIGGER: '/api/admin/infrastructure/woodpecker/trigger', STATUS: '/api/v1/security/sbom/pipeline/status',
LOGS: '/api/admin/infrastructure/woodpecker/logs', TRIGGER: '/api/v1/security/sbom/pipeline/trigger',
}, },
/** Log Extraction Endpoints */ /** Log Extraction Endpoints */
LOG_EXTRACT: { LOG_EXTRACT: {
EXTRACT: '/api/infrastructure/log-extract/extract', EXTRACT: '/api/infrastructure/log-extract/extract',
}, },
/** Webhook Endpoints */
WEBHOOKS: {
WOODPECKER: '/api/webhooks/woodpecker',
},
/** LLM Endpoints */ /** LLM Endpoints */
LLM: { LLM: {
ANALYZE: '/api/ai/analyze', ANALYZE: '/api/ai/analyze',
@@ -375,7 +353,6 @@ export const INFRASTRUCTURE_API_ENDPOINTS = {
*/ */
export const DEVOPS_ARCHITECTURE = { export const DEVOPS_ARCHITECTURE = {
services: [ services: [
{ name: 'Woodpecker CI', port: 8000, description: 'CI/CD Pipeline Server' },
{ name: 'Gitea', port: 3003, description: 'Git Repository Server' }, { name: 'Gitea', port: 3003, description: 'Git Repository Server' },
{ name: 'Syft', type: 'CLI', description: 'SBOM Generator' }, { name: 'Syft', type: 'CLI', description: 'SBOM Generator' },
{ name: 'Grype', type: 'CLI', description: 'Vulnerability Scanner' }, { name: 'Grype', type: 'CLI', description: 'Vulnerability Scanner' },
-3
View File
@@ -25,8 +25,6 @@ from email_template_api import (
) )
from system_api import router as system_router from system_api import router as system_router
from security_api import router as security_router from security_api import router as security_router
from woodpecker_proxy_api import router as woodpecker_router
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Middleware imports # Middleware imports
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -106,7 +104,6 @@ app.include_router(system_router) # already has paths defined in r
# Security / DevSecOps dashboard # Security / DevSecOps dashboard
app.include_router(security_router, prefix="/api") app.include_router(security_router, prefix="/api")
app.include_router(woodpecker_router, prefix="/api")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Startup / Shutdown events # Startup / Shutdown events
-133
View File
@@ -1,133 +0,0 @@
"""
Woodpecker CI Proxy API
Liest Pipeline-Daten direkt aus der Woodpecker SQLite-Datenbank.
Wird als Fallback verwendet, wenn kein WOODPECKER_TOKEN konfiguriert ist.
"""
import sqlite3
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Query
router = APIRouter(prefix="/v1/woodpecker", tags=["Woodpecker CI"])
WOODPECKER_DB = Path("/woodpecker-data/woodpecker.sqlite")
def get_db():
if not WOODPECKER_DB.exists():
return None
conn = sqlite3.connect(f"file:{WOODPECKER_DB}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
@router.get("/status")
async def get_status():
conn = get_db()
if not conn:
return {"status": "offline", "error": "Woodpecker DB nicht gefunden"}
try:
repos = [dict(r) for r in conn.execute(
"SELECT id, name, full_name, active FROM repos ORDER BY id"
).fetchall()]
total_pipelines = conn.execute("SELECT COUNT(*) FROM pipelines").fetchone()[0]
success = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='success'").fetchone()[0]
failure = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='failure'").fetchone()[0]
latest = conn.execute("SELECT MAX(created) FROM pipelines").fetchone()[0]
return {
"status": "online",
"repos": repos,
"stats": {
"total_pipelines": total_pipelines,
"success": success,
"failure": failure,
"success_rate": round(success / total_pipelines * 100, 1) if total_pipelines > 0 else 0,
},
"last_activity": datetime.fromtimestamp(latest).isoformat() if latest else None,
}
finally:
conn.close()
@router.get("/pipelines")
async def get_pipelines(
repo: int = Query(default=0, description="Repo ID (0 = alle)"),
limit: int = Query(default=10, ge=1, le=100),
):
conn = get_db()
if not conn:
return {"status": "offline", "pipelines": [], "lastUpdate": datetime.now().isoformat()}
try:
base_sql = """SELECT p.id, p.repo_id, p.number, p.status, p.event, p.branch,
p."commit", p.message, p.author, p.created, p.started, p.finished,
r.name as repo_name
FROM pipelines p
JOIN repos r ON r.id = p.repo_id"""
if repo > 0:
rows = conn.execute(
base_sql + " WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?",
(repo, limit)
).fetchall()
else:
rows = conn.execute(
base_sql + " ORDER BY p.id DESC LIMIT ?",
(limit,)
).fetchall()
pipelines = []
for r in rows:
p = dict(r)
# Get steps directly (steps.pipeline_id links to pipelines.id)
steps = [dict(s) for s in conn.execute(
"""SELECT s.name, s.state, s.exit_code, s.error
FROM steps s
WHERE s.pipeline_id = ?
ORDER BY s.pid""",
(p["id"],)
).fetchall()]
p["steps"] = steps
p["commit"] = (p.get("commit") or "")[:7]
msg = p.get("message") or ""
p["message"] = msg.split("\n")[0][:100]
pipelines.append(p)
return {
"status": "online",
"pipelines": pipelines,
"lastUpdate": datetime.now().isoformat(),
}
finally:
conn.close()
@router.get("/repos")
async def get_repos():
conn = get_db()
if not conn:
return []
try:
repos = []
for r in conn.execute("SELECT id, name, full_name, active FROM repos ORDER BY id").fetchall():
repo = dict(r)
latest = conn.execute(
'SELECT status, created FROM pipelines WHERE repo_id = ? ORDER BY id DESC LIMIT 1',
(repo["id"],)
).fetchone()
if latest:
repo["last_status"] = latest["status"]
repo["last_activity"] = datetime.fromtimestamp(latest["created"]).isoformat()
repos.append(repo)
return repos
finally:
conn.close()
+10 -354
View File
@@ -19,22 +19,10 @@ volumes:
valkey_data: valkey_data:
qdrant_data: qdrant_data:
minio_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 # CI/CD
gitea_data: gitea_data:
gitea_config: gitea_config:
gitea_runner_data: gitea_runner_data:
woodpecker_data:
# ERP # ERP
erpnext_db_data: erpnext_db_data:
erpnext_redis_queue_data: erpnext_redis_queue_data:
@@ -42,7 +30,6 @@ volumes:
erpnext_sites: erpnext_sites:
erpnext_logs: erpnext_logs:
# Services # Services
voice_session_data:
embedding_models: embedding_models:
services: services:
@@ -72,10 +59,12 @@ services:
- "8443:8443" # Jitsi Meet - "8443:8443" # Jitsi Meet
- "3008:3008" # Admin Core - "3008:3008" # Admin Core
- "3010:3010" # Portal Dashboard - "3010:3010" # Portal Dashboard
- "8011:8011" # Compliance Docs (MkDocs)
volumes: volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro
- vault_certs:/etc/nginx/certs:ro - vault_certs:/etc/nginx/certs:ro
- ./nginx/html:/usr/share/nginx/html/portal:ro - ./nginx/html:/usr/share/nginx/html/portal:ro
- /Users/benjaminadmin/rag-originals:/data/rag-originals:ro
depends_on: depends_on:
vault-agent: vault-agent:
condition: service_started condition: service_started
@@ -193,26 +182,6 @@ services:
networks: networks:
- breakpilot-network - 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 # VECTOR DB & OBJECT STORAGE
# ========================================================= # =========================================================
@@ -268,7 +237,6 @@ services:
expose: expose:
- "8000" - "8000"
volumes: volumes:
- woodpecker_data:/woodpecker-data:ro
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcore,public DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcore,public
@@ -385,8 +353,12 @@ services:
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag} MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag}
MINIO_SECURE: "false" MINIO_SECURE: "false"
EMBEDDING_SERVICE_URL: http://embedding-service:8087 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} JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
ENVIRONMENT: ${ENVIRONMENT:-development} ENVIRONMENT: ${ENVIRONMENT:-development}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
qdrant: qdrant:
condition: service_healthy condition: service_healthy
@@ -414,7 +386,7 @@ services:
- embedding_models:/root/.cache/huggingface - embedding_models:/root/.cache/huggingface
environment: environment:
EMBEDDING_BACKEND: ${EMBEDDING_BACKEND:-local} 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} LOCAL_RERANKER_MODEL: ${LOCAL_RERANKER_MODEL:-cross-encoder/ms-marco-MiniLM-L-6-v2}
PDF_EXTRACTION_BACKEND: ${PDF_EXTRACTION_BACKEND:-pymupdf} PDF_EXTRACTION_BACKEND: ${PDF_EXTRACTION_BACKEND:-pymupdf}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
@@ -423,7 +395,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 4G memory: 8G
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import httpx; r=httpx.get('http://127.0.0.1:8087/health'); r.raise_for_status()"] test: ["CMD", "python", "-c", "import httpx; r=httpx.get('http://127.0.0.1:8087/health'); r.raise_for_status()"]
interval: 30s interval: 30s
@@ -447,7 +419,7 @@ services:
- "8099:8099" - "8099:8099"
environment: environment:
PORT: 8099 PORT: 8099
CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,minio:9000,backend-core:8000,rag-service:8097,embedding-service:8087,voice-service:8091" CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,minio:9000,backend-core:8000,rag-service:8097,embedding-service:8087"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -460,199 +432,6 @@ services:
networks: networks:
- breakpilot-network - 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 # DEVOPS & CI/CD
# ========================================================= # =========================================================
@@ -724,88 +503,6 @@ services:
networks: networks:
- breakpilot-network - breakpilot-network
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
container_name: bp-core-woodpecker-server
ports:
- "8090:8000"
volumes:
- woodpecker_data:/var/lib/woodpecker
environment:
WOODPECKER_OPEN: "true"
WOODPECKER_HOST: ${WOODPECKER_HOST:-http://macmini:8090}
WOODPECKER_ADMIN: ${WOODPECKER_ADMIN:-pilotadmin}
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: http://macmini:3003
WOODPECKER_GITEA_CLIENT: ${WOODPECKER_GITEA_CLIENT:-}
WOODPECKER_GITEA_SECRET: ${WOODPECKER_GITEA_SECRET:-}
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-woodpecker-secret}
WOODPECKER_DATABASE_DRIVER: sqlite3
WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
WOODPECKER_LOG_LEVEL: warn
WOODPECKER_PLUGINS_PRIVILEGED: "plugins/docker"
WOODPECKER_PLUGINS_TRUSTED_CLONE: "true"
extra_hosts:
- "macmini:192.168.178.100"
depends_on:
gitea:
condition: service_healthy
restart: unless-stopped
networks:
- breakpilot-network
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
container_name: bp-core-woodpecker-agent
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WOODPECKER_SERVER: woodpecker-server:9000
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-woodpecker-secret}
WOODPECKER_MAX_WORKFLOWS: "2"
WOODPECKER_LOG_LEVEL: warn
WOODPECKER_BACKEND: docker
DOCKER_HOST: unix:///var/run/docker.sock
WOODPECKER_BACKEND_DOCKER_EXTRA_HOSTS: "macmini:192.168.178.100"
WOODPECKER_BACKEND_DOCKER_NETWORK: breakpilot-network
depends_on:
- woodpecker-server
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# WORKFLOW ENGINE
# =========================================================
camunda:
image: camunda/camunda-bpm-platform:7.21.0
container_name: bp-core-camunda
ports:
- "8089:8080"
environment:
DB_DRIVER: org.postgresql.Driver
DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-breakpilot_db}
DB_USERNAME: ${POSTGRES_USER:-breakpilot}
DB_PASSWORD: ${POSTGRES_PASSWORD:-breakpilot123}
DB_VALIDATE_ON_BORROW: "true"
WAIT_FOR: postgres:5432
CAMUNDA_BPM_ADMIN_USER_ID: ${CAMUNDA_ADMIN_USER:-admin}
CAMUNDA_BPM_ADMIN_USER_PASSWORD: ${CAMUNDA_ADMIN_PASSWORD:-admin}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/camunda/api/engine"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 5
profiles:
- bpmn
restart: unless-stopped
networks:
- breakpilot-network
# ========================================================= # =========================================================
# DOCUMENTATION & UTILITIES # DOCUMENTATION & UTILITIES
# ========================================================= # =========================================================
@@ -840,45 +537,6 @@ services:
networks: networks:
- breakpilot-network - breakpilot-network
# =========================================================
# VOICE SERVICE
# =========================================================
voice-service:
build:
context: ./voice-service
dockerfile: Dockerfile
container_name: bp-core-voice-service
platform: linux/arm64
expose:
- "8091"
volumes:
- voice_session_data:/app/data/sessions
environment:
PORT: 8091
DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
VALKEY_URL: redis://valkey:6379/0
KLAUSUR_SERVICE_URL: http://bp-lehrer-klausur-service:8086
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
OLLAMA_VOICE_MODEL: ${OLLAMA_VOICE_MODEL:-llama3.2}
ENVIRONMENT: ${ENVIRONMENT:-development}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8091/health"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# ========================================================= # =========================================================
# NIGHT SCHEDULER # NIGHT SCHEDULER
# ========================================================= # =========================================================
@@ -922,8 +580,6 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
BACKEND_URL: http://backend-core:8000 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} OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@@ -1178,7 +834,7 @@ services:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db} 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_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: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:
+2 -2
View File
@@ -38,7 +38,7 @@ BreakPilot ist eine modulare Bildungs- und Compliance-Plattform, aufgeteilt in d
│ Jitsi (5x) │ │ BreakPilot Drive│ │ │ │ Jitsi (5x) │ │ BreakPilot Drive│ │ │
│ Night Scheduler │ │ │ │ │ │ Night Scheduler │ │ │ │ │
│ Health Agg. │ │ │ │ │ │ Health Agg. │ │ │ │ │
│ Gitea/Woodpecker│ │ │ │ │ │ Gitea Actions │ │ │ │ │
│ ERP (optional) │ │ │ │ │ │ ERP (optional) │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │ │ │ │
@@ -67,7 +67,7 @@ Stellt gemeinsam genutzte Infrastruktur bereit. Beide Teams (Lehrer + Compliance
| Frontend | Admin Core (Next.js, Port 3008) | | Frontend | Admin Core (Next.js, Port 3008) |
| Networking | Nginx (Reverse Proxy + TLS) | | Networking | Nginx (Reverse Proxy + TLS) |
| Monitoring | Health Aggregator | | 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) | | Kommunikation | Jitsi Meet (5 Container), Synapse (Matrix Chat) |
| ERP | ERPNext (optional, 9 Container) | | ERP | ERPNext (optional, 9 Container) |
+73 -150
View File
@@ -17,39 +17,32 @@
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ Entwickler-MacBook │ │ Entwickler-MacBook │
│ │ │ │
│ breakpilot-pwa/ │ breakpilot-core/
│ ├── studio-v2/ (Next.js Frontend) │ ├── admin-core/ (Next.js Admin, Port 3008)
│ ├── admin-v2/ (Next.js Admin) │ ├── backend-core/ (Python FastAPI, Port 8000)
│ ├── backend/ (Python FastAPI) │ ├── consent-service/ (Go Service, Port 8081)
│ ├── consent-service/ (Go Service) │ ├── billing-service/ (Go Service, Port 8083)
│ ├── klausur-service/ (Python FastAPI) │
│ ├── voice-service/ (Python FastAPI) │
│ ├── ai-compliance-sdk/ (Go Service) │
│ └── docs-src/ (MkDocs) │ │ └── 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 │ │ Docker Compose │
│ ├── website (Port 3000) │ ├── admin-core (Port 3008)
│ ├── studio-v2 (Port 3001) │ ├── backend-core (Port 8000)
│ ├── admin-v2 (Port 3002) │
│ ├── backend (Port 8000) │
│ ├── consent-service (Port 8081) │ │ ├── consent-service (Port 8081) │
│ ├── klausur-service (Port 8086) │ │ ├── billing-service (Port 8083) │
│ ├── voice-service (Port 8082) │ ├── gitea (Port 3003) + gitea-runner (Gitea Actions)
│ ├── ai-compliance-sdk (Port 8090) │ ├── docs (Port 8011)
│ ├── docs (Port 8009) │ ├── postgres, valkey, qdrant, minio
── postgres ── vault, nginx, night-scheduler, health
│ ├── valkey (Redis) │
│ ├── qdrant │
│ └── minio │
│ │ │ │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
@@ -67,8 +60,8 @@ rsync -avz --delete \
--exclude '__pycache__' \ --exclude '__pycache__' \
--exclude 'venv' \ --exclude 'venv' \
--exclude '.pytest_cache' \ --exclude '.pytest_cache' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/ \ /Users/benjaminadmin/Projekte/breakpilot-core/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/ macmini:/Users/benjaminadmin/Projekte/breakpilot-core/
``` ```
### 2. Container bauen ### 2. Container bauen
@@ -76,7 +69,7 @@ rsync -avz --delete \
```bash ```bash
# Einzelnen Service bauen # Einzelnen Service bauen
ssh macmini "/usr/local/bin/docker compose \ 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>" build --no-cache <service-name>"
# Beispiele: # Beispiele:
@@ -88,7 +81,7 @@ ssh macmini "/usr/local/bin/docker compose \
```bash ```bash
# Container neu starten # Container neu starten
ssh macmini "/usr/local/bin/docker compose \ 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>" up -d <service-name>"
``` ```
@@ -97,7 +90,7 @@ ssh macmini "/usr/local/bin/docker compose \
```bash ```bash
# Container-Logs anzeigen # Container-Logs anzeigen
ssh macmini "/usr/local/bin/docker compose \ 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>" logs -f <service-name>"
``` ```
@@ -109,15 +102,15 @@ ssh macmini "/usr/local/bin/docker compose \
# 1. Sync # 1. Sync
rsync -avz --delete \ rsync -avz --delete \
--exclude 'node_modules' --exclude '.next' --exclude '.git' \ --exclude 'node_modules' --exclude '.next' --exclude '.git' \
/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ \ /Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/ \
macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ macmini:/Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/
# 2. Build & Deploy # 2. Build & Deploy
ssh macmini "/usr/local/bin/docker compose \ 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 && \ build --no-cache studio-v2 && \
/usr/local/bin/docker compose \ /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" up -d studio-v2"
``` ```
@@ -126,10 +119,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash ```bash
# Build mit requirements.txt # Build mit requirements.txt
ssh macmini "/usr/local/bin/docker compose \ 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 && \ build klausur-service && \
/usr/local/bin/docker compose \ /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" up -d klausur-service"
``` ```
@@ -138,10 +131,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash ```bash
# Multi-stage Build (Go → Alpine) # Multi-stage Build (Go → Alpine)
ssh macmini "/usr/local/bin/docker compose \ 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 && \ build --no-cache consent-service && \
/usr/local/bin/docker compose \ /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" up -d consent-service"
``` ```
@@ -150,10 +143,10 @@ ssh macmini "/usr/local/bin/docker compose \
```bash ```bash
# Build & Deploy # Build & Deploy
ssh macmini "/usr/local/bin/docker compose \ 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 && \ build --no-cache docs && \
/usr/local/bin/docker compose \ /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" up -d docs"
# Verfügbar unter: http://macmini:8009 # Verfügbar unter: http://macmini:8009
@@ -178,10 +171,10 @@ curl -s http://macmini:8090/health
```bash ```bash
# Letzte 100 Zeilen # 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 # Live-Logs folgen
ssh macmini "docker logs -f breakpilot-pwa-backend-1" ssh macmini "docker logs -f breakpilot-core-backend-1"
``` ```
## Rollback ## Rollback
@@ -190,15 +183,15 @@ ssh macmini "docker logs -f breakpilot-pwa-backend-1"
```bash ```bash
# 1. Aktuelles Image taggen # 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 # 2. Altes Image deployen
ssh macmini "/usr/local/bin/docker compose \ 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" up -d backend"
# 3. Bei Problemen: Backup wiederherstellen # 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 ## Troubleshooting
@@ -207,13 +200,13 @@ ssh macmini "docker tag breakpilot-pwa-backend:backup breakpilot-pwa-backend:lat
```bash ```bash
# 1. Logs prüfen # 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 # 2. Container manuell starten für Debug-Output
ssh macmini "docker compose -f .../docker-compose.yml run --rm <service>" ssh macmini "docker compose -f .../docker-compose.yml run --rm <service>"
# 3. In Container einloggen # 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 ### Port bereits belegt
@@ -276,127 +269,57 @@ services:
- `.env` Datei auf dem Server pflegen - `.env` Datei auf dem Server pflegen
- Secrets über HashiCorp Vault (siehe unten) - Secrets über HashiCorp Vault (siehe unten)
## Woodpecker CI - Automatisierte OAuth Integration ## Gitea Actions
### Überblick ### Ü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?" | Komponente | Container | Beschreibung |
Diese Automatisierung ist eine DevSecOps Best Practice: |------------|-----------|--------------|
| 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 ### Pipeline-Konfiguration
- **Disaster Recovery**: Verlorene Credentials können automatisch regeneriert werden
- **Security**: Secrets werden zentral in Vault verwaltet
- **Onboarding**: Neue Entwickler müssen nichts manuell konfigurieren
### Architektur Workflows liegen im Repo unter `.gitea/workflows/`:
``` ```yaml
┌─────────────────────────────────────────────────────────────────┐ # .gitea/workflows/main.yml
│ Mac Mini Server │ on:
│ │ push:
│ ┌───────────────┐ OAuth 2.0 ┌───────────────┐ │ branches: [main]
│ │ Gitea │ ←─────────────────────────→│ Woodpecker │ │
│ │ (Port 3003) │ Client ID + Secret │ (Port 8090) │ │ jobs:
│ └───────────────┘ └───────────────┘ │ build:
│ │ │ │ runs-on: ubuntu-latest
│ │ OAuth App │ Env Vars│ steps:
│ │ (DB: oauth2_application) │ │ - uses: actions/checkout@v4
│ │ │ │ - name: Build & Test
│ ▼ ▼ │ run: docker compose build
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 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) │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
``` ```
### Credentials-Speicherorte ### Runner-Token erneuern
| 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:
```bash ```bash
# Option 1: Automatisches Regenerieren (empfohlen) # Runner-Token in Gitea UI generieren:
./scripts/sync-woodpecker-credentials.sh --regenerate # https://macmini:3003 → Settings → Actions → Runners → New Runner
# Option 2: Manuelles Vorgehen # Token in .env setzen:
# 1. Credentials aus Vault laden GITEA_RUNNER_TOKEN=<neues_token>
vault kv get secret/cicd/woodpecker
# 2. .env aktualisieren # Runner neu starten:
WOODPECKER_GITEA_CLIENT=<client_id> ssh macmini "/usr/local/bin/docker compose \
WOODPECKER_GITEA_SECRET=<client_secret> -f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
up -d --force-recreate gitea-runner"
# 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"
``` ```
### Das Sync-Script ### Pipeline-Status prüfen
Das Script `scripts/sync-woodpecker-credentials.sh` automatisiert den gesamten Prozess:
```bash ```bash
# Credentials aus Vault laden und .env aktualisieren # Runner-Logs
./scripts/sync-woodpecker-credentials.sh ssh macmini "/usr/local/bin/docker logs -f bp-core-gitea-runner"
# Neue Credentials generieren (OAuth App in Gitea + Vault + .env) # Laufende Jobs
./scripts/sync-woodpecker-credentials.sh --regenerate ssh macmini "/usr/local/bin/docker exec bp-core-gitea-runner act_runner list"
```
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
``` ```
+1 -1
View File
@@ -33,7 +33,7 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
| Pitch Deck | bp-core-pitch-deck | 3012 | Investor-Praesentation | | Pitch Deck | bp-core-pitch-deck | 3012 | Investor-Praesentation |
| Mailpit | bp-core-mailpit | 8025 | E-Mail (Entwicklung) | | Mailpit | bp-core-mailpit | 8025 | E-Mail (Entwicklung) |
| Gitea | bp-core-gitea | 3003 | Git-Server | | 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 | | Jitsi (5 Container) | bp-core-jitsi-* | 8443 | Videokonferenzen |
## Nginx Routing-Tabelle ## Nginx Routing-Tabelle
+45 -10
View File
@@ -32,7 +32,7 @@ server {
# Jitsi WebSocket endpoints # Jitsi WebSocket endpoints
location /xmpp-websocket { 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_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -46,7 +46,7 @@ server {
} }
location /colibri-ws { 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_pass http://$upstream_jvb;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -60,7 +60,7 @@ server {
} }
location /http-bind { 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_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -71,7 +71,7 @@ server {
# Jitsi static assets # Jitsi static assets
location ~ ^/(css|images|fonts|sounds|static|libs|lang|connection_optimization)/ { 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_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; 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) { 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_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -91,7 +91,7 @@ server {
} }
location /jitsi/ { location /jitsi/ {
set $upstream_jitsi bp-core-jitsi-web:80; set $upstream_jitsi bp-lehrer-jitsi-web:80;
rewrite ^/jitsi(/.*)$ $1 break; rewrite ^/jitsi(/.*)$ $1 break;
proxy_pass http://$upstream_jitsi; proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -248,6 +248,15 @@ server {
proxy_set_header X-Forwarded-Proto https; 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) # Admin Lehrer Frontend (fallback for everything else)
location / { location / {
set $upstream_admin_lehrer bp-lehrer-admin:3000; set $upstream_admin_lehrer bp-lehrer-admin:3000;
@@ -564,7 +573,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s; proxy_read_timeout 600s;
proxy_send_timeout 600s;
} }
} }
@@ -620,6 +630,31 @@ server {
} }
} }
# =========================================================
# COMPLIANCE: Docs (MkDocs) on port 8011
# =========================================================
server {
listen 8011 ssl;
http2 on;
server_name macmini localhost;
ssl_certificate /etc/nginx/certs/macmini.crt;
ssl_certificate_key /etc/nginx/certs/macmini.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
location / {
set $upstream_docs bp-compliance-docs:80;
proxy_pass http://$upstream_docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
# ========================================================= # =========================================================
# CORE: Jitsi Meet on port 8443 # CORE: Jitsi Meet on port 8443
# ========================================================= # =========================================================
@@ -635,7 +670,7 @@ server {
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
location /xmpp-websocket { 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_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -649,7 +684,7 @@ server {
} }
location /colibri-ws { 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_pass http://$upstream_jvb;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -663,7 +698,7 @@ server {
} }
location / { location / {
set $upstream_jitsi bp-core-jitsi-web:80; set $upstream_jitsi bp-lehrer-jitsi-web:80;
proxy_pass http://$upstream_jitsi; proxy_pass http://$upstream_jitsi;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
-18
View File
@@ -679,24 +679,6 @@
</div> </div>
</a> </a>
<a class="card card-compliance" href="https://macmini:3007/dashboard">
<div class="stripe stripe-compliance"></div>
<div class="card-body">
<h3>Compliance Dashboard</h3>
<p>Kataloge, Statistiken, Verwaltung</p>
<div class="url">macmini:3007/dashboard</div>
</div>
</a>
<a class="card card-compliance" href="https://macmini:3007/dashboard">
<div class="stripe stripe-compliance"></div>
<div class="card-body">
<h3>Katalogverwaltung</h3>
<p>SDK-Kataloge &amp; Auswahltabellen</p>
<div class="url">macmini:3007/dashboard</div>
</div>
</a>
<a class="card card-compliance" href="https://macmini:3010/compliance-hub/"> <a class="card card-compliance" href="https://macmini:3010/compliance-hub/">
<div class="stripe stripe-compliance"></div> <div class="stripe stripe-compliance"></div>
<div class="card-body"> <div class="card-body">
+2
View File
@@ -180,9 +180,11 @@ export async function POST(request: NextRequest) {
model: OLLAMA_MODEL, model: OLLAMA_MODEL,
messages, messages,
stream: true, stream: true,
think: false,
options: { options: {
temperature: 0.4, temperature: 0.4,
num_predict: 4096, num_predict: 4096,
num_ctx: 8192,
}, },
}), }),
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
+44 -8
View File
@@ -88,6 +88,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false) const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map()) const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -126,6 +127,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
setInput('') setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }]) setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true) setIsStreaming(true)
setIsWaiting(true)
abortRef.current = new AbortController() abortRef.current = new AbortController()
@@ -152,24 +154,31 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
const reader = res.body!.getReader() const reader = res.body!.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let content = '' let content = ''
let firstChunk = true
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
content += decoder.decode(value, { stream: true }) content += decoder.decode(value, { stream: true })
const currentText = content
setMessages(prev => { if (firstChunk) {
const updated = [...prev] firstChunk = false
updated[updated.length - 1] = { role: 'assistant', content: currentText } setIsWaiting(false)
return updated 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) { } catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return if (err instanceof Error && err.name === 'AbortError') return
console.error('Chat error:', err) console.error('Chat error:', err)
setIsWaiting(false)
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: lang === 'de' { role: 'assistant', content: lang === 'de'
@@ -179,6 +188,7 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
]) ])
} finally { } finally {
setIsStreaming(false) setIsStreaming(false)
setIsWaiting(false)
abortRef.current = null abortRef.current = null
} }
} }
@@ -344,6 +354,32 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
</div> </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) => ( {messages.map((msg, idx) => (
<div <div
key={idx} key={idx}
@@ -17,20 +17,20 @@ interface CompetitionSlideProps {
const securityFeatures = { const securityFeatures = {
de: [ de: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrierte Security-Tools scannen Firmware und Software Ihrer Maschinen kontinuierlich auf Schwachstellen' }, { 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: 'Firmware & Code Scanning', desc: 'Semgrep + Gitleaks analysieren Embedded-Code, Steuerungs-Software und CI/CD Pipelines automatisch' }, { icon: ScanLine, title: 'DevSecOps Security Suite', desc: 'Semgrep, Gitleaks, Trivy, Grype — 6 integrierte Tools scannen Code, Container und Dependencies kontinuierlich' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scannen Container-Images und Abhaengigkeiten auf CVEs — CRA-konform dokumentiert' }, { icon: Bug, title: 'SBOM + CI/CD Evidence', desc: 'CycloneDX/SPDX SBOM-Generator + automatische Compliance-Nachweise direkt aus der Build-Pipeline' },
{ icon: Package, title: 'SBOM-Generator (CRA/NIS2)', desc: 'CycloneDX/SPDX Software Bill of Materials — Pflicht fuer Hersteller unter dem Cyber Resilience Act' }, { icon: Package, title: 'Multi-Framework SDK', desc: 'Consent-Banner und Compliance-Widgets fuer React, Vue, Angular, iOS, Android, Flutter' },
{ icon: FileSearch, title: 'Software-Risikoanalyse', desc: 'Automatisierte Risikoklassifizierung fuer Embedded-Software, Firmware und AI-Act-konforme Steuerungssysteme' }, { icon: FileSearch, title: 'IPFS Dokumenten-Archivierung', desc: 'Dezentrale, manipulationssichere Archivierung mit kryptographischem Nachweis der Unveraendertheit' },
{ icon: Code2, title: 'KI-Code-Assistent (1000b)', desc: 'BSI-zertifiziertes Cloud-LLM implementiert Security-Fixes, schreibt Risikoanalysen und ist fuer Mitarbeiter nutzbar' }, { icon: Code2, title: 'KI-Compliance-Advisor (RAG)', desc: '2.274 indexierte Rechtstexte, KI-gestuetzte Dokumentenerstellung, Scope Engine L1-L4' },
], ],
en: [ en: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrated security tools continuously scan firmware and software of your machines for vulnerabilities' }, { 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: 'Firmware & Code Scanning', desc: 'Semgrep + Gitleaks analyze embedded code, controller software and CI/CD pipelines automatically' }, { icon: ScanLine, title: 'DevSecOps Security Suite', desc: 'Semgrep, Gitleaks, Trivy, Grype — 6 integrated tools continuously scan code, containers and dependencies' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scan container images and dependencies for CVEs — CRA-compliant documentation' }, { icon: Bug, title: 'SBOM + CI/CD Evidence', desc: 'CycloneDX/SPDX SBOM generator + automatic compliance evidence directly from the build pipeline' },
{ icon: Package, title: 'SBOM Generator (CRA/NIS2)', desc: 'CycloneDX/SPDX Software Bill of Materials — mandatory for manufacturers under the Cyber Resilience Act' }, { icon: Package, title: 'Multi-Framework SDK', desc: 'Consent banners and compliance widgets for React, Vue, Angular, iOS, Android, Flutter' },
{ icon: FileSearch, title: 'Software Risk Assessment', desc: 'Automated risk classification for embedded software, firmware and AI Act compliant control systems' }, { icon: FileSearch, title: 'IPFS Document Archiving', desc: 'Decentralized, tamper-proof archiving with cryptographic proof of integrity' },
{ icon: Code2, title: 'AI Code Assistant (1000B)', desc: 'BSI-certified cloud LLM implements security fixes, writes risk assessments and is available for employee use' }, { icon: Code2, title: 'AI Compliance Advisor (RAG)', desc: '2,274 indexed legal texts, AI-powered document generation, Scope Engine L1-L4' },
], ],
} }
@@ -30,7 +30,7 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
const heroStats = [ const heroStats = [
{ {
value: '691K', value: '761K',
label: de ? 'Zeilen Code' : 'Lines of Code', label: de ? 'Zeilen Code' : 'Lines of Code',
sub: 'Go · Python · TypeScript', sub: 'Go · Python · TypeScript',
color: 'text-indigo-400', color: 'text-indigo-400',
@@ -60,9 +60,9 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
] ]
const languageBreakdown = [ const languageBreakdown = [
{ lang: 'TypeScript / TSX', pct: 58, loc: '403K', color: 'bg-blue-500', icon: Braces }, { lang: 'TypeScript / TSX', pct: 54, loc: '408K', color: 'bg-blue-500', icon: Braces },
{ lang: 'Python', pct: 23, loc: '160K', color: 'bg-yellow-500', icon: Terminal }, { lang: 'Python', pct: 28, loc: '213K', color: 'bg-yellow-500', icon: Terminal },
{ lang: 'Go', pct: 18, loc: '127K', color: 'bg-cyan-500', icon: Code2 }, { lang: 'Go', pct: 18, loc: '141K', color: 'bg-cyan-500', icon: Code2 },
] ]
const devopsStack = [ const devopsStack = [
@@ -120,8 +120,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
color: 'text-emerald-400', color: 'text-emerald-400',
dotColor: 'bg-emerald-400', dotColor: 'bg-emerald-400',
services: de services: de
? ['Compliance Dashboard', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS Gateway', 'Security Scanner (Go)'] ? ['Compliance Dashboard (57 Module)', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS/IPFS Gateway', 'TTS Service']
: ['Compliance Dashboard', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS Gateway', 'Security Scanner (Go)'], : ['Compliance Dashboard (57 Modules)', 'Developer Portal', 'AI SDK (Go)', 'Document Crawler', 'DSMS/IPFS Gateway', 'TTS Service'],
}, },
] ]
+44 -7
View File
@@ -15,6 +15,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false) const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -29,6 +30,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
setInput('') setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }]) setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true) setIsStreaming(true)
setIsWaiting(true)
try { try {
const res = await fetch('/api/chat', { const res = await fetch('/api/chat', {
@@ -46,22 +48,29 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
const reader = res.body!.getReader() const reader = res.body!.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let content = '' let content = ''
let firstChunk = true
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
content += decoder.decode(value, { stream: true }) content += decoder.decode(value, { stream: true })
setMessages(prev => {
const updated = [...prev] if (firstChunk) {
updated[updated.length - 1] = { role: 'assistant', content } firstChunk = false
return updated 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) { } catch (err) {
console.error('Chat error:', err) console.error('Chat error:', err)
setIsWaiting(false)
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: lang === 'de' { role: 'assistant', content: lang === 'de'
@@ -71,6 +80,7 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
]) ])
} finally { } finally {
setIsStreaming(false) setIsStreaming(false)
setIsWaiting(false)
} }
} }
@@ -135,6 +145,33 @@ export default function ChatInterface({ lang }: ChatInterfaceProps) {
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </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 ref={messagesEndRef} />
</div> </div>
+8 -8
View File
@@ -68,8 +68,8 @@ const translations = {
icon: 'scan', icon: 'scan',
}, },
{ {
title: 'Compliance-KI', title: 'Compliance-KI (57 Module)',
desc: 'Macht Ihr Unternehmen UND Ihre Produkte compliant. DSGVO, AI Act, CRA und NIS2 — automatisiert. BSI-zertifiziertes 1000B LLM in Deutschland gehostet.', 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', icon: 'bot',
}, },
], ],
@@ -137,7 +137,7 @@ const translations = {
}, },
competition: { competition: {
title: 'Wettbewerb', title: 'Wettbewerb',
subtitle: 'Was uns differenziert', subtitle: '44 Features vs. ~15-25 bei Wettbewerbern — 9 einzigartige USPs',
feature: 'Feature', feature: 'Feature',
selfHosted: 'Self-Hosted', selfHosted: 'Self-Hosted',
integratedAI: 'Integrierte KI', integratedAI: 'Integrierte KI',
@@ -213,7 +213,7 @@ const translations = {
}, },
engineering: { engineering: {
title: 'Engineering Deep Dive', title: 'Engineering Deep Dive',
subtitle: '691K Zeilen Code \u00b7 45 Container \u00b7 100% Self-Hosted', subtitle: '761K Zeilen Code \u00b7 45 Container \u00b7 100% Self-Hosted',
}, },
aipipeline: { aipipeline: {
title: 'KI-Pipeline Deep Dive', title: 'KI-Pipeline Deep Dive',
@@ -288,8 +288,8 @@ const translations = {
icon: 'scan', icon: 'scan',
}, },
{ {
title: 'Compliance AI', title: 'Compliance AI (57 Modules)',
desc: 'Makes your company AND your products compliant. GDPR, AI Act, CRA and NIS2 — automated. BSI-certified 1000B LLM hosted in Germany.', 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', icon: 'bot',
}, },
], ],
@@ -357,7 +357,7 @@ const translations = {
}, },
competition: { competition: {
title: 'Competition', title: 'Competition',
subtitle: 'What differentiates us', subtitle: '44 features vs. ~15-25 competitors — 9 unique USPs',
feature: 'Feature', feature: 'Feature',
selfHosted: 'Self-Hosted', selfHosted: 'Self-Hosted',
integratedAI: 'Integrated AI', integratedAI: 'Integrated AI',
@@ -433,7 +433,7 @@ const translations = {
}, },
engineering: { engineering: {
title: 'Engineering Deep Dive', title: 'Engineering Deep Dive',
subtitle: '691K Lines of Code \u00b7 45 Containers \u00b7 100% Self-Hosted', subtitle: '761K Lines of Code \u00b7 45 Containers \u00b7 100% Self-Hosted',
}, },
aipipeline: { aipipeline: {
title: 'AI Pipeline Deep Dive', title: 'AI Pipeline Deep Dive',
+61 -22
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
from typing import Optional from typing import Optional
import httpx import httpx
@@ -8,44 +9,82 @@ from config import settings
logger = logging.getLogger("rag-service.embedding") logger = logging.getLogger("rag-service.embedding")
_TIMEOUT = httpx.Timeout(timeout=120.0, connect=10.0) _TIMEOUT = httpx.Timeout(timeout=120.0, connect=10.0)
_EMBED_TIMEOUT = httpx.Timeout(timeout=300.0, connect=10.0)
# Ollama config for embeddings (bge-m3, 1024-dim)
_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
_OLLAMA_EMBED_MODEL = os.getenv("OLLAMA_EMBED_MODEL", "bge-m3")
# Batch size for Ollama embedding requests
_EMBED_BATCH_SIZE = int(os.getenv("EMBED_BATCH_SIZE", "32"))
class EmbeddingClient: class EmbeddingClient:
"""HTTP client for the embedding-service (port 8087).""" """
Hybrid client:
- Embeddings via Ollama (bge-m3, 1024-dim) for Qdrant compatibility
- Chunking + PDF extraction via embedding-service (port 8087)
"""
def __init__(self) -> None: def __init__(self) -> None:
self._base_url: str = settings.EMBEDDING_SERVICE_URL.rstrip("/") self._embed_svc_url: str = settings.EMBEDDING_SERVICE_URL.rstrip("/")
self._ollama_url: str = _OLLAMA_URL.rstrip("/")
self._embed_model: str = _OLLAMA_EMBED_MODEL
def _url(self, path: str) -> str: def _svc_url(self, path: str) -> str:
return f"{self._base_url}{path}" return f"{self._embed_svc_url}{path}"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Embeddings # Embeddings (via Ollama)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def generate_embeddings(self, texts: list[str]) -> list[list[float]]: async def generate_embeddings(self, texts: list[str]) -> list[list[float]]:
""" """
Send a batch of texts to the embedding service and return a list of Generate embeddings via Ollama's bge-m3 model.
embedding vectors. Processes in batches to avoid timeout on large uploads.
""" """
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: all_embeddings: list[list[float]] = []
response = await client.post(
self._url("/api/v1/embeddings"), for i in range(0, len(texts), _EMBED_BATCH_SIZE):
json={"texts": texts}, batch = texts[i : i + _EMBED_BATCH_SIZE]
) batch_embeddings = []
response.raise_for_status()
data = response.json() async with httpx.AsyncClient(timeout=_EMBED_TIMEOUT) as client:
return data.get("embeddings", []) for text in batch:
response = await client.post(
f"{self._ollama_url}/api/embeddings",
json={
"model": self._embed_model,
"prompt": text,
},
)
response.raise_for_status()
data = response.json()
embedding = data.get("embedding", [])
if not embedding:
raise ValueError(
f"Ollama returned empty embedding for model {self._embed_model}"
)
batch_embeddings.append(embedding)
all_embeddings.extend(batch_embeddings)
if i + _EMBED_BATCH_SIZE < len(texts):
logger.info(
"Embedding progress: %d/%d", len(all_embeddings), len(texts)
)
return all_embeddings
async def generate_single_embedding(self, text: str) -> list[float]: async def generate_single_embedding(self, text: str) -> list[float]:
"""Convenience wrapper for a single text.""" """Convenience wrapper for a single text."""
results = await self.generate_embeddings([text]) results = await self.generate_embeddings([text])
if not results: if not results:
raise ValueError("Embedding service returned empty result") raise ValueError("Ollama returned empty result")
return results[0] return results[0]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Reranking # Reranking (via embedding-service)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def rerank_documents( async def rerank_documents(
@@ -60,7 +99,7 @@ class EmbeddingClient:
""" """
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
response = await client.post( response = await client.post(
self._url("/api/v1/rerank"), self._svc_url("/rerank"),
json={ json={
"query": query, "query": query,
"documents": documents, "documents": documents,
@@ -72,7 +111,7 @@ class EmbeddingClient:
return data.get("results", []) return data.get("results", [])
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Chunking # Chunking (via embedding-service)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def chunk_text( async def chunk_text(
@@ -88,7 +127,7 @@ class EmbeddingClient:
""" """
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
response = await client.post( response = await client.post(
self._url("/api/v1/chunk"), self._svc_url("/chunk"),
json={ json={
"text": text, "text": text,
"strategy": strategy, "strategy": strategy,
@@ -101,7 +140,7 @@ class EmbeddingClient:
return data.get("chunks", []) return data.get("chunks", [])
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# PDF extraction # PDF extraction (via embedding-service)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def extract_pdf(self, pdf_bytes: bytes) -> str: async def extract_pdf(self, pdf_bytes: bytes) -> str:
@@ -111,7 +150,7 @@ class EmbeddingClient:
""" """
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
response = await client.post( response = await client.post(
self._url("/api/v1/extract-pdf"), self._svc_url("/extract-pdf"),
files={"file": ("document.pdf", pdf_bytes, "application/pdf")}, files={"file": ("document.pdf", pdf_bytes, "application/pdf")},
) )
response.raise_for_status() response.raise_for_status()
+4 -3
View File
@@ -167,12 +167,13 @@ class QdrantClientWrapper:
) )
qdrant_filter = qmodels.Filter(must=must_conditions) qdrant_filter = qmodels.Filter(must=must_conditions)
results = self.client.search( results = self.client.query_points(
collection_name=collection, collection_name=collection,
query_vector=query_vector, query=query_vector,
limit=limit, limit=limit,
query_filter=qdrant_filter, query_filter=qdrant_filter,
score_threshold=score_threshold, score_threshold=score_threshold,
with_payload=True,
) )
return [ return [
@@ -181,7 +182,7 @@ class QdrantClientWrapper:
"score": hit.score, "score": hit.score,
"payload": hit.payload or {}, "payload": hit.payload or {},
} }
for hit in results for hit in results.points
] ]
# ------------------------------------------------------------------ # ------------------------------------------------------------------