[split-required] Split remaining 500-680 LOC files (final batch)

website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -0,0 +1,116 @@
export function PlatformCards() {
const platforms = [
{
name: 'WebGL',
icon: '🌐',
status: 'Aktiv',
size: '~15 MB',
features: ['Browser-basiert', 'Sofort spielbar', 'Admin Panel Embed']
},
{
name: 'iOS',
icon: '📱',
status: 'Bereit',
size: '~80 MB',
features: ['iPhone & iPad', 'App Store', 'Push Notifications']
},
{
name: 'Android',
icon: '🤖',
status: 'Bereit',
size: '~60 MB',
features: ['Play Store', 'AAB Format', 'Wide Device Support']
},
]
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{platforms.map((platform) => (
<div key={platform.name} className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border border-green-100">
<div className="flex items-center gap-3 mb-3">
<span className="text-3xl">{platform.icon}</span>
<div>
<h4 className="font-bold text-gray-900">{platform.name}</h4>
<p className="text-sm text-gray-500">{platform.size}</p>
</div>
<span className="ml-auto px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
{platform.status}
</span>
</div>
<ul className="space-y-1">
{platform.features.map((feature, i) => (
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
<span className="text-green-500"></span> {feature}
</li>
))}
</ul>
</div>
))}
</div>
)
}
export function WorkflowDiagram() {
const jobs = [
{ name: 'version', icon: '🏷️', runner: 'ubuntu' },
{ name: 'build-webgl', icon: '🌐', runner: 'ubuntu' },
{ name: 'build-ios', icon: '📱', runner: 'macos' },
{ name: 'build-android', icon: '🤖', runner: 'ubuntu' },
{ name: 'deploy', icon: '🚀', runner: 'ubuntu' },
]
return (
<div className="mt-6 bg-gray-900 rounded-lg p-6">
<h3 className="text-white font-semibold mb-4">Workflow Jobs</h3>
<div className="flex flex-wrap gap-4">
{jobs.map((job, i) => (
<div key={job.name} className="flex items-center gap-2">
<div className="bg-gray-800 rounded-lg p-3 text-center min-w-[100px]">
<span className="text-2xl">{job.icon}</span>
<p className="text-white text-sm font-medium mt-1">{job.name}</p>
<p className="text-gray-500 text-xs">{job.runner}</p>
</div>
{i < jobs.length - 1 && (
<span className="text-gray-600 text-xl"></span>
)}
</div>
))}
</div>
</div>
)
}
export function SecretsChecklist() {
const secrets = [
{ name: 'UNITY_LICENSE', desc: 'Unity Personal/Pro License', required: true },
{ name: 'UNITY_EMAIL', desc: 'Unity Account Email', required: true },
{ name: 'UNITY_PASSWORD', desc: 'Unity Account Password', required: true },
{ name: 'IOS_BUILD_CERTIFICATE_BASE64', desc: 'Apple Distribution Certificate', required: false },
{ name: 'IOS_PROVISION_PROFILE_BASE64', desc: 'iOS Provisioning Profile', required: false },
{ name: 'ANDROID_KEYSTORE_BASE64', desc: 'Android Signing Keystore', required: false },
{ name: 'AWS_ACCESS_KEY_ID', desc: 'AWS fuer S3/CloudFront', required: false },
]
return (
<div className="mt-6 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b">
<h3 className="font-semibold text-gray-800">GitHub Secrets Checkliste</h3>
</div>
<ul className="divide-y">
{secrets.map((secret) => (
<li key={secret.name} className="px-4 py-3 flex items-center justify-between">
<div>
<code className="text-sm bg-gray-100 px-2 py-1 rounded">{secret.name}</code>
<p className="text-sm text-gray-500 mt-1">{secret.desc}</p>
</div>
<span className={`text-xs px-2 py-1 rounded ${
secret.required ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'
}`}>
{secret.required ? 'Pflicht' : 'Optional'}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { StepInfo, WizardStep, STEPS } from './types'
export function WizardStepper({ steps, currentStep, onStepClick }: {
steps: StepInfo[]
currentStep: WizardStep
onStepClick: (step: WizardStep) => void
}) {
const currentIndex = steps.findIndex(s => s.id === currentStep)
return (
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isCompleted = index < currentIndex
const isClickable = index <= currentIndex + 1
return (
<button
key={step.id}
onClick={() => isClickable && onStepClick(step.id)}
disabled={!isClickable}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-green-600 text-white'
: isCompleted
? 'bg-green-100 text-green-700 hover:bg-green-200'
: isClickable
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
isActive ? 'bg-white/20' : isCompleted ? 'bg-green-200' : 'bg-gray-200'
}`}>
{isCompleted ? '✓' : index + 1}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
)
})}
</div>
)
}
export function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
<div className="prose prose-sm max-w-none mb-6">
{content.split('\n\n').map((paragraph, i) => (
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
{paragraph.split('**').map((part, j) =>
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
)}
</p>
))}
</div>
<div className="bg-green-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-green-800 mb-2">Tipps:</h3>
<ul className="space-y-1">
{tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-green-700">
<span className="text-green-500 mt-0.5"></span>
{tip}
</li>
))}
</ul>
</div>
</div>
)
}
export function Sidebar({ currentStepIndex }: { currentStepIndex: number }) {
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<span className="text-xs font-semibold text-green-600">
Schritt {currentStepIndex + 1} von {STEPS.length}
</span>
<span className="text-xs font-semibold text-green-600">
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-green-100">
<div
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-green-600 transition-all duration-300"
/>
</div>
</div>
</div>
{/* Pipeline Overview */}
<div className="bg-gradient-to-br from-green-500 to-emerald-600 rounded-xl shadow p-6 text-white">
<h3 className="font-semibold mb-4">Pipeline Flow</h3>
<div className="text-sm space-y-2 font-mono">
<div className="bg-white/10 rounded px-2 py-1">Git Push/Tag</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">GitHub Actions</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">Unity Build</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">Deploy / Upload</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
<ul className="space-y-2 text-sm">
<li className="text-gray-600"><span className="text-green-600">YAML:</span> ci/build-all-platforms.yml</li>
<li className="text-gray-600"><span className="text-green-600">C#:</span> Assets/Editor/BuildScript.cs</li>
<li className="text-gray-600"><span className="text-green-600">JSON:</span> Assets/Resources/version.json</li>
<li className="text-gray-600"><span className="text-green-600">Plist:</span> ci/ios-export-options.plist</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,205 @@
export type WizardStep =
| 'welcome'
| 'platforms'
| 'github-actions'
| 'webgl-build'
| 'ios-build'
| 'android-build'
| 'deployment'
| 'version-sync'
| 'summary'
export interface StepInfo {
id: WizardStep
title: string
description: string
}
export const STEPS: StepInfo[] = [
{ id: 'welcome', title: 'Willkommen', description: 'Build Pipeline Uebersicht' },
{ id: 'platforms', title: 'Plattformen', description: 'WebGL, iOS, Android' },
{ id: 'github-actions', title: 'GitHub Actions', description: 'CI/CD Workflow' },
{ id: 'webgl-build', title: 'WebGL', description: 'Browser Build' },
{ id: 'ios-build', title: 'iOS', description: 'App Store Build' },
{ id: 'android-build', title: 'Android', description: 'Play Store Build' },
{ id: 'deployment', title: 'Deployment', description: 'Store Upload' },
{ id: 'version-sync', title: 'Versioning', description: 'Version Management' },
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
]
export const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
'welcome': {
title: 'Multi-Platform Build Pipeline',
content: `Breakpilot Drive wird fuer drei Plattformen gebaut:
**WebGL** - Browser-basiert, in Admin Panel eingebettet
**iOS** - iPhone/iPad via App Store
**Android** - Smartphones/Tablets via Google Play
Die Build-Pipeline nutzt **GitHub Actions** mit **game-ci/unity-builder**
fuer automatisierte, reproduzierbare Builds.`,
tips: ['WebGL ist die primaere Plattform fuer schnelles Testing', 'Mobile Builds nur bei Tags (Releases)', 'Alle Builds werden als Artifacts gespeichert']
},
'platforms': {
title: 'Unterstuetzte Plattformen',
content: `Jede Plattform hat spezifische Anforderungen:
**WebGL (HTML5/WASM)**
- Brotli-Kompression
- 512MB Memory
- Kein Threading (Browser-Limitation)
**iOS (iPhone/iPad)**
- Min. iOS 14.0
- ARM64 Architektur
- App Store Distribution
**Android**
- Min. Android 7.0 (API 24)
- Target: Android 14 (API 34)
- ARM64, AAB fuer Play Store`,
tips: ['WebGL laeuft in allen modernen Browsern', 'iOS erfordert Apple Developer Account ($99/Jahr)', 'Android AAB ist Pflicht fuer Play Store']
},
'github-actions': {
title: 'GitHub Actions Workflow',
content: `Der CI/CD Workflow ist in Jobs aufgeteilt:
**1. version** - Ermittelt Version aus Git Tag
**2. build-webgl** - Baut Browser-Version
**3. build-ios** - Baut Xcode Projekt
**4. build-ios-ipa** - Erstellt signierte IPA
**5. build-android** - Baut AAB/APK
**6. deploy-webgl** - Deployed zu CDN
**7. upload-ios** - Laedt zu App Store Connect
**8. upload-android** - Laedt zu Google Play
Trigger:
- **Tags (v*)**: Alle Plattformen + Upload
- **Push main**: Nur WebGL
- **Manual**: Auswahlbar`,
tips: ['Unity License muss als Secret hinterlegt sein', 'Signing-Zertifikate als Base64 Secrets', 'Cache beschleunigt Builds erheblich']
},
'webgl-build': {
title: 'WebGL Build',
content: `WebGL ist die schnellste Build-Variante:
**Build-Einstellungen:**
- Kompression: Brotli (beste Kompression)
- Memory: 512MB (ausreichend fuer Spiel)
- Exceptions: Nur explizite (Performance)
- Linker: WASM (WebAssembly)
**Output:**
- index.html
- Build/*.wasm.br (komprimiert)
- Build/*.data.br (Assets)
- Build/*.js (Loader)
**Deployment:**
- S3 + CloudFront CDN
- Cache: 1 Jahr fuer Assets, 1h fuer HTML`,
tips: ['Brotli-Kompression spart ~70% Bandbreite', 'Erste Ladung ~10-15MB, danach gecached', 'Server muss Brotli-Headers unterstuetzen']
},
'ios-build': {
title: 'iOS Build',
content: `iOS Build erfolgt in zwei Schritten:
**Schritt 1: Unity Build**
- Erstellt Xcode Projekt
- Setzt iOS-spezifische Einstellungen
- Output: Unity-iPhone.xcodeproj
**Schritt 2: Xcode Build**
- Importiert Signing-Zertifikate
- Archiviert Projekt
- Exportiert signierte IPA
**Voraussetzungen:**
- Apple Developer Account
- Distribution Certificate (.p12)
- Provisioning Profile
- App Store Connect API Key`,
tips: ['Zertifikate alle 1 Jahr erneuern', 'Provisioning Profile fuer jede App ID', 'TestFlight fuer Beta-Tests nutzen']
},
'android-build': {
title: 'Android Build',
content: `Android Build erzeugt AAB oder APK:
**AAB (App Bundle)** - Fuer Play Store
- Google optimiert fuer jedes Geraet
- Kleinere Downloads
- Pflicht seit 2021
**APK** - Fuer direkten Download
- Debug-Builds fuer Testing
- Sideloading moeglich
**Signing:**
- Keystore (.jks/.keystore)
- Key Alias und Passwoerter
- Play App Signing empfohlen
**Voraussetzungen:**
- Google Play Console Account ($25 einmalig)
- Keystore fuer App-Signatur`,
tips: ['Keystore NIEMALS verlieren (keine Veroeffentlichung mehr)', 'Play App Signing: Google verwaltet Upload-Key', 'Internal Testing fuer schnelle Tests']
},
'deployment': {
title: 'Store Deployment',
content: `Automatisches Deployment zu den Stores:
**WebGL -> CDN (S3/CloudFront)**
- Sync zu S3 Bucket
- CloudFront Invalidation
- Versionierte URLs
**iOS -> App Store Connect**
- Upload via altool
- API Key Authentifizierung
- TestFlight Auto-Distribution
**Android -> Google Play**
- Upload via r0adkll/upload-google-play
- Service Account Auth
- Internal Track zuerst`,
tips: ['WebGL ist sofort live nach Deploy', 'iOS: Review dauert 1-3 Tage', 'Android: Review dauert wenige Stunden']
},
'version-sync': {
title: 'Version Synchronisation',
content: `Versionen werden zentral verwaltet:
**version.json** (Runtime)
- version: Semantische Version
- build_number: Inkrementell
- platform: Build-Target
- commit_hash: Git SHA
- min_api_version: API Kompatibilitaet
**VersionManager.cs** (Unity)
- Laedt version.json zur Laufzeit
- Prueft API-Kompatibilitaet
- Zeigt Update-Hinweise
**Git Tags**
- v1.0.0 -> Version 1.0.0
- Trigger fuer Release-Builds`,
tips: ['build_number aus GitHub Run Number', 'min_api_version fuer erzwungene Updates', 'Semantic Versioning: MAJOR.MINOR.PATCH']
},
'summary': {
title: 'Zusammenfassung & Naechste Schritte',
content: `Du hast gelernt:
✓ Build-Targets: WebGL, iOS, Android
✓ GitHub Actions Workflow
✓ Platform-spezifische Einstellungen
✓ Store Deployment Prozess
✓ Version Management
**Naechste Schritte:**
1. GitHub Secrets konfigurieren
2. Apple/Google Developer Accounts einrichten
3. Keystore und Zertifikate erstellen
4. Ersten Release-Tag erstellen`,
tips: ['Dokumentation in BreakpilotDrive/ci/', 'BuildScript.cs fuer lokale Builds', 'version.json wird automatisch aktualisiert']
},
}

View File

@@ -10,455 +10,9 @@
import { useState } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
// ========================================
// Types
// ========================================
type WizardStep =
| 'welcome'
| 'platforms'
| 'github-actions'
| 'webgl-build'
| 'ios-build'
| 'android-build'
| 'deployment'
| 'version-sync'
| 'summary'
interface StepInfo {
id: WizardStep
title: string
description: string
}
// ========================================
// Step Configuration
// ========================================
const STEPS: StepInfo[] = [
{ id: 'welcome', title: 'Willkommen', description: 'Build Pipeline Uebersicht' },
{ id: 'platforms', title: 'Plattformen', description: 'WebGL, iOS, Android' },
{ id: 'github-actions', title: 'GitHub Actions', description: 'CI/CD Workflow' },
{ id: 'webgl-build', title: 'WebGL', description: 'Browser Build' },
{ id: 'ios-build', title: 'iOS', description: 'App Store Build' },
{ id: 'android-build', title: 'Android', description: 'Play Store Build' },
{ id: 'deployment', title: 'Deployment', description: 'Store Upload' },
{ id: 'version-sync', title: 'Versioning', description: 'Version Management' },
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
]
// ========================================
// Educational Content
// ========================================
const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
'welcome': {
title: 'Multi-Platform Build Pipeline',
content: `Breakpilot Drive wird fuer drei Plattformen gebaut:
**WebGL** - Browser-basiert, in Admin Panel eingebettet
**iOS** - iPhone/iPad via App Store
**Android** - Smartphones/Tablets via Google Play
Die Build-Pipeline nutzt **GitHub Actions** mit **game-ci/unity-builder**
fuer automatisierte, reproduzierbare Builds.`,
tips: [
'WebGL ist die primaere Plattform fuer schnelles Testing',
'Mobile Builds nur bei Tags (Releases)',
'Alle Builds werden als Artifacts gespeichert'
]
},
'platforms': {
title: 'Unterstuetzte Plattformen',
content: `Jede Plattform hat spezifische Anforderungen:
**WebGL (HTML5/WASM)**
- Brotli-Kompression
- 512MB Memory
- Kein Threading (Browser-Limitation)
**iOS (iPhone/iPad)**
- Min. iOS 14.0
- ARM64 Architektur
- App Store Distribution
**Android**
- Min. Android 7.0 (API 24)
- Target: Android 14 (API 34)
- ARM64, AAB fuer Play Store`,
tips: [
'WebGL laeuft in allen modernen Browsern',
'iOS erfordert Apple Developer Account ($99/Jahr)',
'Android AAB ist Pflicht fuer Play Store'
]
},
'github-actions': {
title: 'GitHub Actions Workflow',
content: `Der CI/CD Workflow ist in Jobs aufgeteilt:
**1. version** - Ermittelt Version aus Git Tag
**2. build-webgl** - Baut Browser-Version
**3. build-ios** - Baut Xcode Projekt
**4. build-ios-ipa** - Erstellt signierte IPA
**5. build-android** - Baut AAB/APK
**6. deploy-webgl** - Deployed zu CDN
**7. upload-ios** - Laedt zu App Store Connect
**8. upload-android** - Laedt zu Google Play
Trigger:
- **Tags (v*)**: Alle Plattformen + Upload
- **Push main**: Nur WebGL
- **Manual**: Auswahlbar`,
tips: [
'Unity License muss als Secret hinterlegt sein',
'Signing-Zertifikate als Base64 Secrets',
'Cache beschleunigt Builds erheblich'
]
},
'webgl-build': {
title: 'WebGL Build',
content: `WebGL ist die schnellste Build-Variante:
**Build-Einstellungen:**
- Kompression: Brotli (beste Kompression)
- Memory: 512MB (ausreichend fuer Spiel)
- Exceptions: Nur explizite (Performance)
- Linker: WASM (WebAssembly)
**Output:**
- index.html
- Build/*.wasm.br (komprimiert)
- Build/*.data.br (Assets)
- Build/*.js (Loader)
**Deployment:**
- S3 + CloudFront CDN
- Cache: 1 Jahr fuer Assets, 1h fuer HTML`,
tips: [
'Brotli-Kompression spart ~70% Bandbreite',
'Erste Ladung ~10-15MB, danach gecached',
'Server muss Brotli-Headers unterstuetzen'
]
},
'ios-build': {
title: 'iOS Build',
content: `iOS Build erfolgt in zwei Schritten:
**Schritt 1: Unity Build**
- Erstellt Xcode Projekt
- Setzt iOS-spezifische Einstellungen
- Output: Unity-iPhone.xcodeproj
**Schritt 2: Xcode Build**
- Importiert Signing-Zertifikate
- Archiviert Projekt
- Exportiert signierte IPA
**Voraussetzungen:**
- Apple Developer Account
- Distribution Certificate (.p12)
- Provisioning Profile
- App Store Connect API Key`,
tips: [
'Zertifikate alle 1 Jahr erneuern',
'Provisioning Profile fuer jede App ID',
'TestFlight fuer Beta-Tests nutzen'
]
},
'android-build': {
title: 'Android Build',
content: `Android Build erzeugt AAB oder APK:
**AAB (App Bundle)** - Fuer Play Store
- Google optimiert fuer jedes Geraet
- Kleinere Downloads
- Pflicht seit 2021
**APK** - Fuer direkten Download
- Debug-Builds fuer Testing
- Sideloading moeglich
**Signing:**
- Keystore (.jks/.keystore)
- Key Alias und Passwoerter
- Play App Signing empfohlen
**Voraussetzungen:**
- Google Play Console Account ($25 einmalig)
- Keystore fuer App-Signatur`,
tips: [
'Keystore NIEMALS verlieren (keine Veroeffentlichung mehr)',
'Play App Signing: Google verwaltet Upload-Key',
'Internal Testing fuer schnelle Tests'
]
},
'deployment': {
title: 'Store Deployment',
content: `Automatisches Deployment zu den Stores:
**WebGL -> CDN (S3/CloudFront)**
- Sync zu S3 Bucket
- CloudFront Invalidation
- Versionierte URLs
**iOS -> App Store Connect**
- Upload via altool
- API Key Authentifizierung
- TestFlight Auto-Distribution
**Android -> Google Play**
- Upload via r0adkll/upload-google-play
- Service Account Auth
- Internal Track zuerst`,
tips: [
'WebGL ist sofort live nach Deploy',
'iOS: Review dauert 1-3 Tage',
'Android: Review dauert wenige Stunden'
]
},
'version-sync': {
title: 'Version Synchronisation',
content: `Versionen werden zentral verwaltet:
**version.json** (Runtime)
- version: Semantische Version
- build_number: Inkrementell
- platform: Build-Target
- commit_hash: Git SHA
- min_api_version: API Kompatibilitaet
**VersionManager.cs** (Unity)
- Laedt version.json zur Laufzeit
- Prueft API-Kompatibilitaet
- Zeigt Update-Hinweise
**Git Tags**
- v1.0.0 -> Version 1.0.0
- Trigger fuer Release-Builds`,
tips: [
'build_number aus GitHub Run Number',
'min_api_version fuer erzwungene Updates',
'Semantic Versioning: MAJOR.MINOR.PATCH'
]
},
'summary': {
title: 'Zusammenfassung & Naechste Schritte',
content: `Du hast gelernt:
✓ Build-Targets: WebGL, iOS, Android
✓ GitHub Actions Workflow
✓ Platform-spezifische Einstellungen
✓ Store Deployment Prozess
✓ Version Management
**Naechste Schritte:**
1. GitHub Secrets konfigurieren
2. Apple/Google Developer Accounts einrichten
3. Keystore und Zertifikate erstellen
4. Ersten Release-Tag erstellen`,
tips: [
'Dokumentation in BreakpilotDrive/ci/',
'BuildScript.cs fuer lokale Builds',
'version.json wird automatisch aktualisiert'
]
},
}
// ========================================
// Components
// ========================================
function WizardStepper({ steps, currentStep, onStepClick }: {
steps: StepInfo[]
currentStep: WizardStep
onStepClick: (step: WizardStep) => void
}) {
const currentIndex = steps.findIndex(s => s.id === currentStep)
return (
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isCompleted = index < currentIndex
const isClickable = index <= currentIndex + 1
return (
<button
key={step.id}
onClick={() => isClickable && onStepClick(step.id)}
disabled={!isClickable}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-green-600 text-white'
: isCompleted
? 'bg-green-100 text-green-700 hover:bg-green-200'
: isClickable
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
isActive ? 'bg-white/20' : isCompleted ? 'bg-green-200' : 'bg-gray-200'
}`}>
{isCompleted ? '✓' : index + 1}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
)
})}
</div>
)
}
function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
<div className="prose prose-sm max-w-none mb-6">
{content.split('\n\n').map((paragraph, i) => (
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
{paragraph.split('**').map((part, j) =>
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
)}
</p>
))}
</div>
<div className="bg-green-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-green-800 mb-2">Tipps:</h3>
<ul className="space-y-1">
{tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-green-700">
<span className="text-green-500 mt-0.5"></span>
{tip}
</li>
))}
</ul>
</div>
</div>
)
}
function PlatformCards() {
const platforms = [
{
name: 'WebGL',
icon: '🌐',
status: 'Aktiv',
size: '~15 MB',
features: ['Browser-basiert', 'Sofort spielbar', 'Admin Panel Embed']
},
{
name: 'iOS',
icon: '📱',
status: 'Bereit',
size: '~80 MB',
features: ['iPhone & iPad', 'App Store', 'Push Notifications']
},
{
name: 'Android',
icon: '🤖',
status: 'Bereit',
size: '~60 MB',
features: ['Play Store', 'AAB Format', 'Wide Device Support']
},
]
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{platforms.map((platform) => (
<div key={platform.name} className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border border-green-100">
<div className="flex items-center gap-3 mb-3">
<span className="text-3xl">{platform.icon}</span>
<div>
<h4 className="font-bold text-gray-900">{platform.name}</h4>
<p className="text-sm text-gray-500">{platform.size}</p>
</div>
<span className="ml-auto px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
{platform.status}
</span>
</div>
<ul className="space-y-1">
{platform.features.map((feature, i) => (
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
<span className="text-green-500"></span> {feature}
</li>
))}
</ul>
</div>
))}
</div>
)
}
function WorkflowDiagram() {
const jobs = [
{ name: 'version', icon: '🏷️', runner: 'ubuntu' },
{ name: 'build-webgl', icon: '🌐', runner: 'ubuntu' },
{ name: 'build-ios', icon: '📱', runner: 'macos' },
{ name: 'build-android', icon: '🤖', runner: 'ubuntu' },
{ name: 'deploy', icon: '🚀', runner: 'ubuntu' },
]
return (
<div className="mt-6 bg-gray-900 rounded-lg p-6">
<h3 className="text-white font-semibold mb-4">Workflow Jobs</h3>
<div className="flex flex-wrap gap-4">
{jobs.map((job, i) => (
<div key={job.name} className="flex items-center gap-2">
<div className="bg-gray-800 rounded-lg p-3 text-center min-w-[100px]">
<span className="text-2xl">{job.icon}</span>
<p className="text-white text-sm font-medium mt-1">{job.name}</p>
<p className="text-gray-500 text-xs">{job.runner}</p>
</div>
{i < jobs.length - 1 && (
<span className="text-gray-600 text-xl"></span>
)}
</div>
))}
</div>
</div>
)
}
function SecretsChecklist() {
const secrets = [
{ name: 'UNITY_LICENSE', desc: 'Unity Personal/Pro License', required: true },
{ name: 'UNITY_EMAIL', desc: 'Unity Account Email', required: true },
{ name: 'UNITY_PASSWORD', desc: 'Unity Account Password', required: true },
{ name: 'IOS_BUILD_CERTIFICATE_BASE64', desc: 'Apple Distribution Certificate', required: false },
{ name: 'IOS_PROVISION_PROFILE_BASE64', desc: 'iOS Provisioning Profile', required: false },
{ name: 'ANDROID_KEYSTORE_BASE64', desc: 'Android Signing Keystore', required: false },
{ name: 'AWS_ACCESS_KEY_ID', desc: 'AWS fuer S3/CloudFront', required: false },
]
return (
<div className="mt-6 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b">
<h3 className="font-semibold text-gray-800">GitHub Secrets Checkliste</h3>
</div>
<ul className="divide-y">
{secrets.map((secret) => (
<li key={secret.name} className="px-4 py-3 flex items-center justify-between">
<div>
<code className="text-sm bg-gray-100 px-2 py-1 rounded">{secret.name}</code>
<p className="text-sm text-gray-500 mt-1">{secret.desc}</p>
</div>
<span className={`text-xs px-2 py-1 rounded ${
secret.required ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'
}`}>
{secret.required ? 'Pflicht' : 'Optional'}
</span>
</li>
))}
</ul>
</div>
)
}
// ========================================
// Main Component
// ========================================
import { STEPS, EDUCATION_CONTENT, WizardStep } from './_components/types'
import { WizardStepper, EducationCard, Sidebar } from './_components/WizardComponents'
import { PlatformCards, WorkflowDiagram, SecretsChecklist } from './_components/StepContent'
export default function BuildPipelineWizardPage() {
const [currentStep, setCurrentStep] = useState<WizardStep>('welcome')
@@ -541,61 +95,7 @@ export default function BuildPipelineWizardPage() {
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<span className="text-xs font-semibold text-green-600">
Schritt {currentStepIndex + 1} von {STEPS.length}
</span>
<span className="text-xs font-semibold text-green-600">
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-green-100">
<div
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-green-600 transition-all duration-300"
/>
</div>
</div>
</div>
{/* Pipeline Overview */}
<div className="bg-gradient-to-br from-green-500 to-emerald-600 rounded-xl shadow p-6 text-white">
<h3 className="font-semibold mb-4">Pipeline Flow</h3>
<div className="text-sm space-y-2 font-mono">
<div className="bg-white/10 rounded px-2 py-1">Git Push/Tag</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">GitHub Actions</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">Unity Build</div>
<div className="text-center text-green-200"></div>
<div className="bg-white/10 rounded px-2 py-1">Deploy / Upload</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
<ul className="space-y-2 text-sm">
<li className="text-gray-600">
<span className="text-green-600">YAML:</span> ci/build-all-platforms.yml
</li>
<li className="text-gray-600">
<span className="text-green-600">C#:</span> Assets/Editor/BuildScript.cs
</li>
<li className="text-gray-600">
<span className="text-green-600">JSON:</span> Assets/Resources/version.json
</li>
<li className="text-gray-600">
<span className="text-green-600">Plist:</span> ci/ios-export-options.plist
</li>
</ul>
</div>
</div>
<Sidebar currentStepIndex={currentStepIndex} />
</div>
</AdminLayout>
)

View File

@@ -0,0 +1,177 @@
import { ActiveMeeting, RecentRoom, CommunicationStats } from './types'
import { formatDuration, formatTimeAgo, getRoomTypeBadge } from './helpers'
export function ActiveMeetingsSection({
activeMeetings,
loading,
onRefresh,
}: {
activeMeetings: ActiveMeeting[]
loading: boolean
onRefresh: () => void
}) {
return (
<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>
<button
onClick={onRefresh}
disabled={loading}
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50"
>
{loading ? 'Lade...' : 'Aktualisieren'}
</button>
</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>
)
}
export function ChatRoomsAndUsage({
recentRooms,
stats,
}: {
recentRooms: RecentRoom[]
stats: CommunicationStats | null
}) {
return (
<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-Räume</h3>
{recentRooms.length === 0 ? (
<div className="text-center py-6 text-slate-500">
<p>Keine aktiven Räume</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-Räume</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>
)
}

View File

@@ -0,0 +1,96 @@
import { CommunicationStats } from './types'
import { getStatusBadge, formatDuration } from './helpers'
export function MatrixCard({ stats }: { stats: CommunicationStats | null }) {
return (
<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">Räume</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>
)
}
export function JitsiCard({ stats }: { stats: CommunicationStats | null }) {
return (
<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">Ø 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>
)
}

View File

@@ -0,0 +1,116 @@
import { CommunicationStats } from './types'
import {
calculateEstimatedTraffic,
calculateHourlyEstimate,
calculateMonthlyEstimate,
getResourceRecommendation,
} from './helpers'
export function TrafficSection({ stats }: { stats: CommunicationStats | null }) {
return (
<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(stats, '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(stats, 'out').toFixed(1)} MB
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Geschätzt/Stunde</div>
<div className="text-2xl font-bold text-blue-600">
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate(stats).toFixed(2)} GB
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-xs text-slate-500 mb-1">Geschätzt/Monat</div>
<div className="text-2xl font-bold text-emerald-600">
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate(stats).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 Größe</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 geschätzt</span>
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
</div>
</div>
</div>
</div>
{/* SysEleven Resource Recommendations */}
<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(stats)}</strong></p>
<p className="mt-1 text-xs text-emerald-600">
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
Ø Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
Calls heute: {stats?.jitsi?.meetings_today || 0}
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { CommunicationStats } from './types'
export function 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`
}
}
export function 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`
}
}
export function 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`
}
export function 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
export function calculateEstimatedTraffic(stats: CommunicationStats | null, 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
// Estimates: ~2KB per message, ~1.5 Mbps per video participant
const messageTrafficMB = messages * 0.002
const videoTrafficMB = callMinutes * participants * 0.011 // ~660 KB/min per participant
if (direction === 'in') {
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
}
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
}
export function calculateHourlyEstimate(stats: CommunicationStats | null): number {
const activeParticipants = stats?.jitsi?.total_participants || 0
return activeParticipants * 0.675
}
export function calculateMonthlyEstimate(stats: CommunicationStats | null): 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
}
export function getResourceRecommendation(stats: CommunicationStats | null): string {
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
const monthlyGB = calculateMonthlyEstimate(stats)
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)'
}
}

View File

@@ -0,0 +1,64 @@
export 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'
}
export 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'
}
export 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
}
}
export interface CommunicationStats {
matrix: MatrixStats
jitsi: JitsiStats
traffic?: TrafficStats
last_updated: string
}
export interface ActiveMeeting {
room_name: string
display_name: string
participants: number
started_at: string
duration_minutes: number
}
export interface RecentRoom {
room_id: string
name: string
member_count: number
last_activity: string
room_type: 'class' | 'parent' | 'staff' | 'general'
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { CommunicationStats, ActiveMeeting, RecentRoom } from './types'
const API_BASE = '/api/admin/communication'
export function useCommunicationStats() {
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 fetchStats = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/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])
return { stats, activeMeetings, recentRooms, loading, error, fetchStats }
}

View File

@@ -8,578 +8,34 @@
*/
import AdminLayout from '@/components/admin/AdminLayout'
import { useEffect, useState, useCallback } from 'react'
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'
}
import { useCommunicationStats } from './_components/useCommunicationStats'
import { MatrixCard, JitsiCard } from './_components/ServiceCards'
import { TrafficSection } from './_components/TrafficSection'
import { ActiveMeetingsSection, ChatRoomsAndUsage } from './_components/MeetingsAndRooms'
export default function CommunicationPage() {
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 API_BASE = '/api/admin/communication'
const fetchStats = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/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
// Estimates: ~2KB per message, ~1.5 Mbps per video participant
const messageTrafficMB = messages * 0.002
const videoTrafficMB = callMinutes * participants * 0.011 // ~660 KB/min per participant
if (direction === 'in') {
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4 // Incoming is less (mostly receiving)
}
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6 // Outgoing is more
}
const calculateHourlyEstimate = (): number => {
const activeParticipants = stats?.jitsi?.total_participants || 0
// ~1.5 Mbps per participant = ~0.675 GB/hour per participant
return activeParticipants * 0.675
}
const calculateMonthlyEstimate = (): number => {
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
// Extrapolate: assume 22 working days, similar usage pattern
const monthlyMinutes = dailyCallMinutes * 22
// ~11 MB/min for video conference with participants
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)'
}
}
const { stats, activeMeetings, recentRooms, loading, error, fetchStats } = useCommunicationStats()
return (
<AdminLayout title="Kommunikation" description="Matrix & Jitsi Monitoring">
{/* 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">Räume</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">Ø 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>
<MatrixCard stats={stats} />
<JitsiCard stats={stats} />
</div>
{/* Traffic & Bandwidth Statistics for SysEleven Planning */}
<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">Geschätzt/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">Geschätzt/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 Größe</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 geschätzt</span>
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
</div>
</div>
</div>
</div>
{/* SysEleven Resource Recommendations */}
<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} |
Ø Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
Calls heute: {stats?.jitsi?.meetings_today || 0}
</p>
</div>
</div>
</div>
<TrafficSection stats={stats} />
{/* 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>
<button
onClick={fetchStats}
disabled={loading}
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50"
>
{loading ? 'Lade...' : 'Aktualisieren'}
</button>
</div>
<ActiveMeetingsSection
activeMeetings={activeMeetings}
loading={loading}
onRefresh={fetchStats}
/>
{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 */}
<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-Räume</h3>
{recentRooms.length === 0 ? (
<div className="text-center py-6 text-slate-500">
<p>Keine aktiven Räume</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-Räume</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>
{/* Recent Chat Rooms & Usage */}
<ChatRoomsAndUsage recentRooms={recentRooms} stats={stats} />
{/* Connection Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">

View File

@@ -0,0 +1,77 @@
interface Evidence {
id: string
control_id: string
evidence_type: string
title: string
description: string
artifact_url: string | null
file_size_bytes: number | null
status: string
collected_at: string
}
const EVIDENCE_TYPE_ICONS: Record<string, string> = {
scan_report: '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',
policy_document: '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',
config_snapshot: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
test_result: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
screenshot: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
external_link: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
manual_upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
}
const STATUS_STYLES: Record<string, string> = {
valid: 'bg-green-100 text-green-700',
expired: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
failed: 'bg-red-100 text-red-700',
}
function formatFileSize(bytes: number | null) {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
interface EvidenceCardProps {
evidence: Evidence
controlTitle: string
}
export function EvidenceCard({ evidence: ev, controlTitle }: EvidenceCardProps) {
const defaultIcon = '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'
return (
<div className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPE_ICONS[ev.evidence_type] || defaultIcon} />
</svg>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_STYLES[ev.status] || 'bg-slate-100 text-slate-700'}`}>
{ev.status}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{controlTitle}</span>
</div>
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
{ev.description && <p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
<span>{ev.evidence_type.replace('_', ' ')}</span>
<span>{formatFileSize(ev.file_size_bytes)}</span>
</div>
{ev.artifact_url && (
<a href={ev.artifact_url} target="_blank" rel="noopener noreferrer"
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate">
{ev.artifact_url}
</a>
)}
<div className="mt-3 text-xs text-slate-400">
Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}
</div>
</div>
)
}

View File

@@ -0,0 +1,232 @@
import { useRef, useState } from 'react'
interface Control {
id: string
control_id: string
title: string
}
interface NewEvidenceData {
control_id: string
evidence_type: string
title: string
description: string
artifact_url: string
}
const EVIDENCE_TYPES = [
{ value: 'scan_report', label: 'Scan Report' },
{ value: 'policy_document', label: 'Policy Dokument' },
{ value: 'config_snapshot', label: 'Config Snapshot' },
{ value: 'test_result', label: 'Test Ergebnis' },
{ value: 'screenshot', label: 'Screenshot' },
{ value: 'external_link', label: 'Externer Link' },
{ value: 'manual_upload', label: 'Manueller Upload' },
]
function formatFileSize(bytes: number | null) {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
interface UploadModalProps {
controls: Control[]
newEvidence: NewEvidenceData
setNewEvidence: (data: NewEvidenceData) => void
uploading: boolean
onUpload: (file: File) => void
onClose: () => void
}
export function UploadModal({
controls,
newEvidence,
setNewEvidence,
uploading,
onUpload,
onClose,
}: UploadModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datei hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newEvidence.evidence_type}
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. Semgrep Scan Report 2026-01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
<input
type="file"
ref={fileInputRef}
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
{selectedFile && (
<p className="mt-1 text-sm text-slate-500">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={() => selectedFile && onUpload(selectedFile)}
disabled={uploading || !selectedFile}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Hochladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)
}
interface LinkModalProps {
controls: Control[]
newEvidence: NewEvidenceData
setNewEvidence: (data: NewEvidenceData) => void
uploading: boolean
onSubmit: () => void
onClose: () => void
}
export function LinkModal({
controls,
newEvidence,
setNewEvidence,
uploading,
onSubmit,
onClose,
}: LinkModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Link/Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. GitHub Branch Protection Settings"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
value={newEvidence.artifact_url}
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={onSubmit}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Speichern...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -2,18 +2,14 @@
/**
* Evidence Management Page
*
* Features:
* - List evidence by control
* - File upload
* - URL/Link adding
* - Evidence status tracking
*/
import { useState, useEffect, useRef, Suspense } from 'react'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
import { UploadModal, LinkModal } from './_components/EvidenceModals'
import { EvidenceCard } from './_components/EvidenceCard'
interface Evidence {
id: string
@@ -34,56 +30,32 @@ interface Evidence {
collected_at: string
}
interface Control {
id: string
control_id: string
title: string
}
interface Control { id: string; control_id: string; title: string }
const EVIDENCE_TYPES = [
{ value: 'scan_report', label: 'Scan Report', icon: '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' },
{ value: 'policy_document', label: 'Policy Dokument', icon: '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' },
{ value: 'config_snapshot', label: 'Config Snapshot', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
{ value: 'test_result', label: 'Test Ergebnis', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ value: 'screenshot', label: 'Screenshot', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' },
{ value: 'external_link', label: 'Externer Link', icon: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' },
{ value: 'manual_upload', label: 'Manueller Upload', icon: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' },
const EVIDENCE_TYPE_OPTIONS = [
{ value: 'scan_report', label: 'Scan Report' },
{ value: 'policy_document', label: 'Policy Dokument' },
{ value: 'config_snapshot', label: 'Config Snapshot' },
{ value: 'test_result', label: 'Test Ergebnis' },
{ value: 'screenshot', label: 'Screenshot' },
{ value: 'external_link', label: 'Externer Link' },
{ value: 'manual_upload', label: 'Manueller Upload' },
]
const STATUS_STYLES: Record<string, string> = {
valid: 'bg-green-100 text-green-700',
expired: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
failed: 'bg-red-100 text-red-700',
}
function EvidencePageContent({ initialControlId }: { initialControlId: string | null }) {
const [evidence, setEvidence] = useState<Evidence[]>([])
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [filterControlId, setFilterControlId] = useState(initialControlId || '')
const [filterType, setFilterType] = useState('')
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [linkModalOpen, setLinkModalOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [newEvidence, setNewEvidence] = useState({
control_id: initialControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [newEvidence, setNewEvidence] = useState({ control_id: initialControlId || '', evidence_type: 'manual_upload', title: '', description: '', artifact_url: '' })
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [filterControlId, filterType])
useEffect(() => { loadData() }, [filterControlId, filterType])
const loadData = async () => {
setLoading(true)
@@ -91,431 +63,98 @@ function EvidencePageContent({ initialControlId }: { initialControlId: string |
const params = new URLSearchParams()
if (filterControlId) params.append('control_id', filterControlId)
if (filterType) params.append('evidence_type', filterType)
const [evidenceRes, controlsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/evidence?${params}`),
fetch(`${BACKEND_URL}/api/v1/compliance/controls`),
])
if (evidenceRes.ok) {
const data = await evidenceRes.json()
setEvidence(data.evidence || [])
}
if (controlsRes.ok) {
const data = await controlsRes.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
if (evidenceRes.ok) { const data = await evidenceRes.json(); setEvidence(data.evidence || []) }
if (controlsRes.ok) { const data = await controlsRes.json(); setControls(data.controls || []) }
} catch (error) { console.error('Failed to load data:', error) }
finally { setLoading(false) }
}
const handleFileUpload = async () => {
if (!selectedFile || !newEvidence.control_id || !newEvidence.title) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
const resetForm = () => { setNewEvidence({ control_id: filterControlId || '', evidence_type: 'manual_upload', title: '', description: '', artifact_url: '' }) }
const handleFileUpload = async (file: File) => {
if (!newEvidence.control_id || !newEvidence.title) { alert('Bitte alle Pflichtfelder ausfuellen'); return }
setUploading(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const params = new URLSearchParams({
control_id: newEvidence.control_id,
evidence_type: newEvidence.evidence_type,
title: newEvidence.title,
})
if (newEvidence.description) {
params.append('description', newEvidence.description)
}
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (res.ok) {
setUploadModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Upload fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Upload failed:', error)
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
const formData = new FormData(); formData.append('file', file)
const params = new URLSearchParams({ control_id: newEvidence.control_id, evidence_type: newEvidence.evidence_type, title: newEvidence.title })
if (newEvidence.description) params.append('description', newEvidence.description)
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence/upload?${params}`, { method: 'POST', body: formData })
if (res.ok) { setUploadModalOpen(false); resetForm(); loadData() } else { alert(`Upload fehlgeschlagen: ${await res.text()}`) }
} catch { alert('Upload fehlgeschlagen') } finally { setUploading(false) }
}
const handleLinkSubmit = async () => {
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) { alert('Bitte alle Pflichtfelder ausfuellen'); return }
setUploading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
control_id: newEvidence.control_id,
evidence_type: 'external_link',
title: newEvidence.title,
description: newEvidence.description,
artifact_url: newEvidence.artifact_url,
source: 'manual',
}),
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ control_id: newEvidence.control_id, evidence_type: 'external_link', title: newEvidence.title, description: newEvidence.description, artifact_url: newEvidence.artifact_url, source: 'manual' }),
})
if (res.ok) {
setLinkModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Failed to add link:', error)
alert('Fehler beim Hinzufuegen')
} finally {
setUploading(false)
}
if (res.ok) { setLinkModalOpen(false); resetForm(); loadData() } else { alert(`Fehler: ${await res.text()}`) }
} catch { alert('Fehler beim Hinzufuegen') } finally { setUploading(false) }
}
const resetForm = () => {
setNewEvidence({
control_id: filterControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
setSelectedFile(null)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const getControlTitle = (controlUuid: string) => {
const control = controls.find((c) => c.id === controlUuid)
return control?.control_id || controlUuid
}
const getControlTitle = (controlUuid: string) => controls.find((c) => c.id === controlUuid)?.control_id || controlUuid
return (
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
{/* Header */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<Link
href="/admin/compliance"
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<Link href="/admin/compliance" className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Zurueck
</Link>
<div className="flex-1" />
<button
onClick={() => { resetForm(); setLinkModalOpen(true) }}
className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50"
>
Link hinzufuegen
</button>
<button
onClick={() => { resetForm(); setUploadModalOpen(true) }}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Datei hochladen
</button>
<button onClick={() => { resetForm(); setLinkModalOpen(true) }} className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50">Link hinzufuegen</button>
<button onClick={() => { resetForm(); setUploadModalOpen(true) }} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Datei hochladen</button>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterControlId}
onChange={(e) => setFilterControlId(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<select value={filterControlId} onChange={(e) => setFilterControlId(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500">
<option value="">Alle Controls</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
{controls.map((c) => <option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>)}
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500">
<option value="">Alle Typen</option>
{EVIDENCE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
{EVIDENCE_TYPE_OPTIONS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<span className="text-sm text-slate-500">{evidence.length} Nachweise</span>
</div>
</div>
{/* Evidence List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" /></div>
) : evidence.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-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>
<svg className="w-16 h-16 text-slate-300 mx-auto mb-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>
<p className="text-slate-500 mb-4">Keine Nachweise gefunden</p>
<button
onClick={() => setUploadModalOpen(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Ersten Nachweis hinzufuegen
</button>
<button onClick={() => setUploadModalOpen(true)} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Ersten Nachweis hinzufuegen</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{evidence.map((ev) => (
<div key={ev.id} className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPES.find((t) => t.value === ev.evidence_type)?.icon || '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>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_STYLES[ev.status] || 'bg-slate-100 text-slate-700'}`}>
{ev.status}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{getControlTitle(ev.control_id)}</span>
</div>
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
{ev.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
<span>{ev.evidence_type.replace('_', ' ')}</span>
<span>{formatFileSize(ev.file_size_bytes)}</span>
</div>
{ev.artifact_url && (
<a
href={ev.artifact_url}
target="_blank"
rel="noopener noreferrer"
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate"
>
{ev.artifact_url}
</a>
)}
<div className="mt-3 text-xs text-slate-400">
Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}
</div>
</div>
))}
{evidence.map((ev) => <EvidenceCard key={ev.id} evidence={ev} controlTitle={getControlTitle(ev.control_id)} />)}
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datei hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newEvidence.evidence_type}
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. Semgrep Scan Report 2026-01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
<input
type="file"
ref={fileInputRef}
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
{selectedFile && (
<p className="mt-1 text-sm text-slate-500">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setUploadModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleFileUpload}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Hochladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)}
{/* Link Modal */}
{linkModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Link/Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. GitHub Branch Protection Settings"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
value={newEvidence.artifact_url}
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setLinkModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleLinkSubmit}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Speichern...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)}
{uploadModalOpen && <UploadModal controls={controls} newEvidence={newEvidence} setNewEvidence={setNewEvidence} uploading={uploading} onUpload={handleFileUpload} onClose={() => setUploadModalOpen(false)} />}
{linkModalOpen && <LinkModal controls={controls} newEvidence={newEvidence} setNewEvidence={setNewEvidence} uploading={uploading} onSubmit={handleLinkSubmit} onClose={() => setLinkModalOpen(false)} />}
</AdminLayout>
)
}
function EvidencePageWithParams() {
const searchParams = useSearchParams()
const initialControlId = searchParams.get('control')
return <EvidencePageContent initialControlId={initialControlId} />
return <EvidencePageContent initialControlId={searchParams.get('control')} />
}
export default function EvidencePage() {
return (
<Suspense fallback={
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
</AdminLayout>
}>
<Suspense fallback={<AdminLayout title="Evidence Management" description="Nachweise & Artefakte"><div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" /></div></AdminLayout>}>
<EvidencePageWithParams />
</Suspense>
)

View File

@@ -0,0 +1,131 @@
import { TestResult, FullTestResults, EDUCATION_CONTENT } from './types'
export function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}
export function TestResultCard({ result }: { result: TestResult }) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}
export function TestSummaryCard({ results }: { results: FullTestResults }) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { WizardStep } from './types'
export function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,152 @@
// ==============================================
// Types
// ==============================================
export interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
export interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
export interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
// ==============================================
// Constants
// ==============================================
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export const INITIAL_STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
]
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
'welcome': {
title: 'Willkommen zum Middleware-Test-Wizard',
content: [
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
'• Warum jede Komponente wichtig ist',
'• Welche Angriffe sie verhindert',
'• Wie Sie Probleme erkennen und beheben',
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
],
},
'request-id': {
title: 'Request-ID & Distributed Tracing',
content: [
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
'Request-IDs sind essentiell fuer:',
'• Fehlersuche in verteilten Systemen',
'• Performance-Analyse',
'• Audit-Trails fuer Compliance',
],
},
'security-headers': {
title: 'Security Headers - Erste Verteidigungslinie',
content: [
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
'• Strict-Transport-Security - Erzwingt HTTPS',
'OWASP empfiehlt diese Headers als Mindeststandard.',
],
},
'rate-limiter': {
title: 'Rate Limiting - Schutz vor Ueberflutung',
content: [
'Ohne Rate Limiting kann ein Angreifer:',
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
'• Teure API-Aufrufe missbrauchen',
'BreakPilot limitiert:',
'• 100 Anfragen/Minute pro IP (allgemein)',
'• 20 Anfragen/Minute fuer Auth-Endpoints',
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
],
},
'pii-redactor': {
title: 'PII Redaktion - DSGVO Pflicht',
content: [
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
'• Telefonnummern: Direkter Personenbezug',
'Der PII Redactor erkennt automatisch:',
'• Email-Adressen → [EMAIL_REDACTED]',
'• IP-Adressen → [IP_REDACTED]',
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
],
},
'input-gate': {
title: 'Input Gate - Der Tuersteher',
content: [
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
'• Content-Type: Erlaubt nur erwartete Formate',
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
],
},
'cors': {
title: 'CORS - Kontrollierte Zugriffe',
content: [
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
'BreakPilot erlaubt nur:',
'• https://breakpilot.app (Produktion)',
'• http://localhost:3000 (Development)',
],
},
'summary': {
title: 'Test-Zusammenfassung',
content: [
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
'• Anzahl bestandener Tests',
'• Fehlgeschlagene Tests mit Details',
'• Empfehlungen zur Behebung',
],
},
}

View File

@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import {
WizardStep,
TestCategoryResult,
FullTestResults,
BACKEND_URL,
INITIAL_STEPS,
} from './types'
export function useTestWizard() {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentStepData = steps[currentStep]
const isTestStep = currentStepData?.category !== undefined
const isWelcome = currentStepData?.id === 'welcome'
const isSummary = currentStepData?.id === 'summary'
const runCategoryTest = async (category: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: TestCategoryResult = await response.json()
setCategoryResults((prev) => ({ ...prev, [category]: result }))
// Update step status
setSteps((prev) =>
prev.map((step) =>
step.category === category
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
: step
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const runAllTests = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const results: FullTestResults = await response.json()
setFullResults(results)
// Update all step statuses
setSteps((prev) =>
prev.map((step) => {
if (step.category) {
const catResult = results.categories.find((c) => c.category === step.category)
if (catResult) {
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
}
}
return step
})
)
// Store category results
const newCategoryResults: Record<string, TestCategoryResult> = {}
results.categories.forEach((cat) => {
newCategoryResults[cat.category] = cat
})
setCategoryResults(newCategoryResults)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const goToNext = () => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}
const goToPrev = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}
const handleStepClick = (index: number) => {
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
setCurrentStep(index)
}
}
return {
currentStep,
steps,
currentStepData,
categoryResults,
fullResults,
isLoading,
error,
isTestStep,
isWelcome,
isSummary,
runCategoryTest,
runAllTests,
goToNext,
goToPrev,
handleStepClick,
}
}

View File

@@ -1,451 +1,27 @@
'use client'
import { useState, useEffect } from 'react'
// ==============================================
// Types
// ==============================================
interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
// ==============================================
// Constants
// ==============================================
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
]
const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
'welcome': {
title: 'Willkommen zum Middleware-Test-Wizard',
content: [
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
'• Warum jede Komponente wichtig ist',
'• Welche Angriffe sie verhindert',
'• Wie Sie Probleme erkennen und beheben',
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
],
},
'request-id': {
title: 'Request-ID & Distributed Tracing',
content: [
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
'Request-IDs sind essentiell fuer:',
'• Fehlersuche in verteilten Systemen',
'• Performance-Analyse',
'• Audit-Trails fuer Compliance',
],
},
'security-headers': {
title: 'Security Headers - Erste Verteidigungslinie',
content: [
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
'• Strict-Transport-Security - Erzwingt HTTPS',
'OWASP empfiehlt diese Headers als Mindeststandard.',
],
},
'rate-limiter': {
title: 'Rate Limiting - Schutz vor Ueberflutung',
content: [
'Ohne Rate Limiting kann ein Angreifer:',
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
'• Teure API-Aufrufe missbrauchen',
'BreakPilot limitiert:',
'• 100 Anfragen/Minute pro IP (allgemein)',
'• 20 Anfragen/Minute fuer Auth-Endpoints',
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
],
},
'pii-redactor': {
title: 'PII Redaktion - DSGVO Pflicht',
content: [
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
'• Telefonnummern: Direkter Personenbezug',
'Der PII Redactor erkennt automatisch:',
'• Email-Adressen → [EMAIL_REDACTED]',
'• IP-Adressen → [IP_REDACTED]',
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
],
},
'input-gate': {
title: 'Input Gate - Der Tuersteher',
content: [
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
'• Content-Type: Erlaubt nur erwartete Formate',
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
],
},
'cors': {
title: 'CORS - Kontrollierte Zugriffe',
content: [
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
'BreakPilot erlaubt nur:',
'• https://breakpilot.app (Produktion)',
'• http://localhost:3000 (Development)',
],
},
'summary': {
title: 'Test-Zusammenfassung',
content: [
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
'• Anzahl bestandener Tests',
'• Fehlgeschlagene Tests mit Details',
'• Empfehlungen zur Behebung',
],
},
}
// ==============================================
// Components
// ==============================================
function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}
function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}
function TestResultCard({ result }: { result: TestResult }) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}
function TestSummaryCard({ results }: { results: FullTestResults }) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}
// ==============================================
// Main Component
// ==============================================
import { useTestWizard } from './_components/useTestWizard'
import { WizardStepper } from './_components/WizardStepper'
import { EducationCard, TestResultCard, TestSummaryCard } from './_components/TestCards'
export default function TestWizardPage() {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentStepData = steps[currentStep]
const isTestStep = currentStepData?.category !== undefined
const isWelcome = currentStepData?.id === 'welcome'
const isSummary = currentStepData?.id === 'summary'
const runCategoryTest = async (category: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: TestCategoryResult = await response.json()
setCategoryResults((prev) => ({ ...prev, [category]: result }))
// Update step status
setSteps((prev) =>
prev.map((step) =>
step.category === category
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
: step
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const runAllTests = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const results: FullTestResults = await response.json()
setFullResults(results)
// Update all step statuses
setSteps((prev) =>
prev.map((step) => {
if (step.category) {
const catResult = results.categories.find((c) => c.category === step.category)
if (catResult) {
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
}
}
return step
})
)
// Store category results
const newCategoryResults: Record<string, TestCategoryResult> = {}
results.categories.forEach((cat) => {
newCategoryResults[cat.category] = cat
})
setCategoryResults(newCategoryResults)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const goToNext = () => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}
const goToPrev = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}
const handleStepClick = (index: number) => {
// Allow clicking on completed steps or the next available step
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
setCurrentStep(index)
}
}
const {
currentStep,
steps,
currentStepData,
categoryResults,
fullResults,
isLoading,
error,
isTestStep,
isWelcome,
isSummary,
runCategoryTest,
runAllTests,
goToNext,
goToPrev,
handleStepClick,
} = useTestWizard()
return (
<div className="min-h-screen bg-gray-100 py-8">

View File

@@ -0,0 +1,131 @@
import { TestResult, FullTestResults, EDUCATION_CONTENT } from './types'
export function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}
export function TestResultCard({ result }: { result: TestResult }) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}
export function TestSummaryCard({ results }: { results: FullTestResults }) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { WizardStep } from './types'
export function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,152 @@
// ==============================================
// Types
// ==============================================
export interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
export interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
export interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
// ==============================================
// Constants
// ==============================================
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export const INITIAL_STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
]
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
'welcome': {
title: 'Willkommen zum Middleware-Test-Wizard',
content: [
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
'• Warum jede Komponente wichtig ist',
'• Welche Angriffe sie verhindert',
'• Wie Sie Probleme erkennen und beheben',
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
],
},
'request-id': {
title: 'Request-ID & Distributed Tracing',
content: [
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
'Request-IDs sind essentiell fuer:',
'• Fehlersuche in verteilten Systemen',
'• Performance-Analyse',
'• Audit-Trails fuer Compliance',
],
},
'security-headers': {
title: 'Security Headers - Erste Verteidigungslinie',
content: [
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
'• Strict-Transport-Security - Erzwingt HTTPS',
'OWASP empfiehlt diese Headers als Mindeststandard.',
],
},
'rate-limiter': {
title: 'Rate Limiting - Schutz vor Ueberflutung',
content: [
'Ohne Rate Limiting kann ein Angreifer:',
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
'• Teure API-Aufrufe missbrauchen',
'BreakPilot limitiert:',
'• 100 Anfragen/Minute pro IP (allgemein)',
'• 20 Anfragen/Minute fuer Auth-Endpoints',
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
],
},
'pii-redactor': {
title: 'PII Redaktion - DSGVO Pflicht',
content: [
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
'• Telefonnummern: Direkter Personenbezug',
'Der PII Redactor erkennt automatisch:',
'• Email-Adressen → [EMAIL_REDACTED]',
'• IP-Adressen → [IP_REDACTED]',
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
],
},
'input-gate': {
title: 'Input Gate - Der Tuersteher',
content: [
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
'• Content-Type: Erlaubt nur erwartete Formate',
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
],
},
'cors': {
title: 'CORS - Kontrollierte Zugriffe',
content: [
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
'BreakPilot erlaubt nur:',
'• https://breakpilot.app (Produktion)',
'• http://localhost:3000 (Development)',
],
},
'summary': {
title: 'Test-Zusammenfassung',
content: [
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
'• Anzahl bestandener Tests',
'• Fehlgeschlagene Tests mit Details',
'• Empfehlungen zur Behebung',
],
},
}

View File

@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import {
WizardStep,
TestCategoryResult,
FullTestResults,
BACKEND_URL,
INITIAL_STEPS,
} from './types'
export function useTestWizard() {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentStepData = steps[currentStep]
const isTestStep = currentStepData?.category !== undefined
const isWelcome = currentStepData?.id === 'welcome'
const isSummary = currentStepData?.id === 'summary'
const runCategoryTest = async (category: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: TestCategoryResult = await response.json()
setCategoryResults((prev) => ({ ...prev, [category]: result }))
// Update step status
setSteps((prev) =>
prev.map((step) =>
step.category === category
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
: step
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const runAllTests = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const results: FullTestResults = await response.json()
setFullResults(results)
// Update all step statuses
setSteps((prev) =>
prev.map((step) => {
if (step.category) {
const catResult = results.categories.find((c) => c.category === step.category)
if (catResult) {
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
}
}
return step
})
)
// Store category results
const newCategoryResults: Record<string, TestCategoryResult> = {}
results.categories.forEach((cat) => {
newCategoryResults[cat.category] = cat
})
setCategoryResults(newCategoryResults)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const goToNext = () => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}
const goToPrev = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}
const handleStepClick = (index: number) => {
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
setCurrentStep(index)
}
}
return {
currentStep,
steps,
currentStepData,
categoryResults,
fullResults,
isLoading,
error,
isTestStep,
isWelcome,
isSummary,
runCategoryTest,
runAllTests,
goToNext,
goToPrev,
handleStepClick,
}
}

View File

@@ -1,451 +1,27 @@
'use client'
import { useState, useEffect } from 'react'
// ==============================================
// Types
// ==============================================
interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
// ==============================================
// Constants
// ==============================================
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
]
const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
'welcome': {
title: 'Willkommen zum Middleware-Test-Wizard',
content: [
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
'• Warum jede Komponente wichtig ist',
'• Welche Angriffe sie verhindert',
'• Wie Sie Probleme erkennen und beheben',
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
],
},
'request-id': {
title: 'Request-ID & Distributed Tracing',
content: [
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
'Request-IDs sind essentiell fuer:',
'• Fehlersuche in verteilten Systemen',
'• Performance-Analyse',
'• Audit-Trails fuer Compliance',
],
},
'security-headers': {
title: 'Security Headers - Erste Verteidigungslinie',
content: [
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
'• Strict-Transport-Security - Erzwingt HTTPS',
'OWASP empfiehlt diese Headers als Mindeststandard.',
],
},
'rate-limiter': {
title: 'Rate Limiting - Schutz vor Ueberflutung',
content: [
'Ohne Rate Limiting kann ein Angreifer:',
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
'• Teure API-Aufrufe missbrauchen',
'BreakPilot limitiert:',
'• 100 Anfragen/Minute pro IP (allgemein)',
'• 20 Anfragen/Minute fuer Auth-Endpoints',
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
],
},
'pii-redactor': {
title: 'PII Redaktion - DSGVO Pflicht',
content: [
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
'• Telefonnummern: Direkter Personenbezug',
'Der PII Redactor erkennt automatisch:',
'• Email-Adressen → [EMAIL_REDACTED]',
'• IP-Adressen → [IP_REDACTED]',
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
],
},
'input-gate': {
title: 'Input Gate - Der Tuersteher',
content: [
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
'• Content-Type: Erlaubt nur erwartete Formate',
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
],
},
'cors': {
title: 'CORS - Kontrollierte Zugriffe',
content: [
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
'BreakPilot erlaubt nur:',
'• https://breakpilot.app (Produktion)',
'• http://localhost:3000 (Development)',
],
},
'summary': {
title: 'Test-Zusammenfassung',
content: [
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
'• Anzahl bestandener Tests',
'• Fehlgeschlagene Tests mit Details',
'• Empfehlungen zur Behebung',
],
},
}
// ==============================================
// Components
// ==============================================
function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}
function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}
function TestResultCard({ result }: { result: TestResult }) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}
function TestSummaryCard({ results }: { results: FullTestResults }) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}
// ==============================================
// Main Component
// ==============================================
import { useTestWizard } from './_components/useTestWizard'
import { WizardStepper } from './_components/WizardStepper'
import { EducationCard, TestResultCard, TestSummaryCard } from './_components/TestCards'
export default function TestWizardPage() {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentStepData = steps[currentStep]
const isTestStep = currentStepData?.category !== undefined
const isWelcome = currentStepData?.id === 'welcome'
const isSummary = currentStepData?.id === 'summary'
const runCategoryTest = async (category: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: TestCategoryResult = await response.json()
setCategoryResults((prev) => ({ ...prev, [category]: result }))
// Update step status
setSteps((prev) =>
prev.map((step) =>
step.category === category
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
: step
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const runAllTests = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const results: FullTestResults = await response.json()
setFullResults(results)
// Update all step statuses
setSteps((prev) =>
prev.map((step) => {
if (step.category) {
const catResult = results.categories.find((c) => c.category === step.category)
if (catResult) {
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
}
}
return step
})
)
// Store category results
const newCategoryResults: Record<string, TestCategoryResult> = {}
results.categories.forEach((cat) => {
newCategoryResults[cat.category] = cat
})
setCategoryResults(newCategoryResults)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const goToNext = () => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}
const goToPrev = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}
const handleStepClick = (index: number) => {
// Allow clicking on completed steps or the next available step
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
setCurrentStep(index)
}
}
const {
currentStep,
steps,
currentStepData,
categoryResults,
fullResults,
isLoading,
error,
isTestStep,
isWelcome,
isSummary,
runCategoryTest,
runAllTests,
goToNext,
goToPrev,
handleStepClick,
} = useTestWizard()
return (
<div className="min-h-screen bg-gray-100 py-8">

View File

@@ -0,0 +1,27 @@
export function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
<div className="prose prose-sm max-w-none mb-6">
{content.split('\n\n').map((paragraph, i) => (
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
{paragraph.split('**').map((part, j) =>
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
)}
</p>
))}
</div>
<div className="bg-indigo-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-indigo-800 mb-2">Tipps:</h3>
<ul className="space-y-1">
{tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-indigo-700">
<span className="text-indigo-500 mt-0.5"></span>
{tip}
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { STEPS, WizardStep } from './types'
export function Sidebar({ currentStepIndex }: { currentStepIndex: number }) {
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<span className="text-xs font-semibold text-indigo-600">
Schritt {currentStepIndex + 1} von {STEPS.length}
</span>
<span className="text-xs font-semibold text-indigo-600">
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-indigo-100">
<div
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-600 transition-all duration-300"
/>
</div>
</div>
</div>
{/* Architecture Overview */}
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow p-6 text-white">
<h3 className="font-semibold mb-4">Architektur</h3>
<div className="text-sm space-y-2 font-mono">
<div className="bg-white/10 rounded px-2 py-1">Unity WebGL</div>
<div className="text-center text-indigo-200"> JS Bridge</div>
<div className="bg-white/10 rounded px-2 py-1">Matrix + Jitsi</div>
<div className="text-center text-indigo-200"> WebSocket/API</div>
<div className="bg-white/10 rounded px-2 py-1">Go Backend</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
<ul className="space-y-2 text-sm">
<li className="text-gray-600">
<span className="text-indigo-600">Go:</span> consent-service/.../matrix/game_rooms.go
</li>
<li className="text-gray-600">
<span className="text-indigo-600">Go:</span> consent-service/.../jitsi/game_meetings.go
</li>
<li className="text-gray-600">
<span className="text-indigo-600">C#:</span> Assets/Scripts/Network/MultiplayerManager.cs
</li>
<li className="text-gray-600">
<span className="text-indigo-600">JS:</span> Assets/Plugins/WebGL/MultiplayerPlugin.jslib
</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { useState } from 'react'
export function GameModeDemo() {
const modes = [
{ name: 'Solo', icon: '🎮', players: '1', features: ['Einzelspieler', 'Offline moeglich', 'Eigenes Tempo'] },
{ name: 'Co-Op', icon: '🤝', players: '2-4', features: ['Team-Chat', 'Gemeinsame Strecke', 'Video optional'] },
{ name: 'Challenge', icon: '⚔️', players: '2', features: ['1v1 Wettbewerb', 'Live-Score', 'Video empfohlen'] },
{ name: 'Klassenrennen', icon: '🏁', players: '∞', features: ['Alle gegen alle', 'Lehrer-Moderation', 'Leaderboard'] },
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{modes.map((mode) => (
<div key={mode.name} className="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-100">
<div className="flex items-center gap-3 mb-3">
<span className="text-3xl">{mode.icon}</span>
<div>
<h4 className="font-bold text-gray-900">{mode.name}</h4>
<p className="text-sm text-gray-500">{mode.players} Spieler</p>
</div>
</div>
<ul className="space-y-1">
{mode.features.map((feature, i) => (
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
<span className="text-indigo-500"></span> {feature}
</li>
))}
</ul>
</div>
))}
</div>
)
}
export function ServiceStatusDemo() {
const [matrixStatus, setMatrixStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
const [jitsiStatus, setJitsiStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
const checkMatrix = async () => {
setMatrixStatus('checking')
try {
// In production, this would check the actual Matrix server
await new Promise(resolve => setTimeout(resolve, 1000))
setMatrixStatus('online') // Mock - assume online
} catch {
setMatrixStatus('offline')
}
}
const checkJitsi = async () => {
setJitsiStatus('checking')
try {
await new Promise(resolve => setTimeout(resolve, 1000))
setJitsiStatus('online') // Mock
} catch {
setJitsiStatus('offline')
}
}
return (
<div className="mt-6 space-y-4">
<h3 className="font-semibold text-gray-800">Service-Status pruefen:</h3>
<div className="flex gap-4">
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">💬</span>
<span className="font-medium">Matrix Synapse</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
matrixStatus === 'online' ? 'bg-green-100 text-green-700' :
matrixStatus === 'offline' ? 'bg-red-100 text-red-700' :
matrixStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{matrixStatus === 'online' ? 'Online' :
matrixStatus === 'offline' ? 'Offline' :
matrixStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
</span>
</div>
<p className="text-sm text-gray-500 mb-3">Port 8008</p>
<button
onClick={checkMatrix}
disabled={matrixStatus === 'checking'}
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
>
Status pruefen
</button>
</div>
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">📹</span>
<span className="font-medium">Jitsi Meet</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
jitsiStatus === 'online' ? 'bg-green-100 text-green-700' :
jitsiStatus === 'offline' ? 'bg-red-100 text-red-700' :
jitsiStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{jitsiStatus === 'online' ? 'Online' :
jitsiStatus === 'offline' ? 'Offline' :
jitsiStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
</span>
</div>
<p className="text-sm text-gray-500 mb-3">Port 8443</p>
<button
onClick={checkJitsi}
disabled={jitsiStatus === 'checking'}
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
>
Status pruefen
</button>
</div>
</div>
</div>
)
}
export function CodePreview({ title, code, language }: { title: string; code: string; language: string }) {
return (
<div className="mt-6 bg-gray-900 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800">
<span className="text-sm text-gray-400">{title}</span>
<span className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded">{language}</span>
</div>
<pre className="p-4 text-sm text-gray-300 overflow-x-auto">
<code>{code}</code>
</pre>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { StepInfo, WizardStep } from './types'
export function WizardStepper({ steps, currentStep, onStepClick }: {
steps: StepInfo[]
currentStep: WizardStep
onStepClick: (step: WizardStep) => void
}) {
const currentIndex = steps.findIndex(s => s.id === currentStep)
return (
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isCompleted = index < currentIndex
const isClickable = index <= currentIndex + 1
return (
<button
key={step.id}
onClick={() => isClickable && onStepClick(step.id)}
disabled={!isClickable}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-indigo-600 text-white'
: isCompleted
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
: isClickable
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
isActive ? 'bg-white/20' : isCompleted ? 'bg-indigo-200' : 'bg-gray-200'
}`}>
{isCompleted ? '✓' : index + 1}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,66 @@
export const CODE_EXAMPLES = {
matrixChat: {
title: 'game_rooms.go - Raum erstellen',
language: 'Go',
code: `func (s *MatrixService) CreateGameTeamRoom(
ctx context.Context,
config GameRoomConfig,
) (*CreateRoomResponse, error) {
roomName := fmt.Sprintf("Breakpilot Drive - Team %s",
config.SessionID[:8])
req := CreateRoomRequest{
Name: roomName,
Visibility: "private",
Preset: "private_chat",
// ... power levels, encryption
}
return s.CreateRoom(ctx, req)
}`,
},
jitsiVideo: {
title: 'game_meetings.go - Meeting erstellen',
language: 'Go',
code: `func (s *JitsiService) CreateChallengeMeeting(
ctx context.Context,
config GameMeetingConfig,
challengerName string,
opponentName string,
) (*GameMeetingLink, error) {
meeting := Meeting{
RoomName: fmt.Sprintf("bp-challenge-%s",
config.SessionID[:8]),
Subject: fmt.Sprintf("Challenge: %s vs %s",
challengerName, opponentName),
Config: &MeetingConfig{
StartWithAudioMuted: false,
RequireDisplayName: true,
},
}
return s.CreateMeetingLink(ctx, meeting)
}`,
},
unityIntegration: {
title: 'MultiplayerManager.cs - Session erstellen',
language: 'C#',
code: `public void CreateSession(
GameMode mode,
string displayName,
Action<MultiplayerSession> onSuccess,
Action<string> onError
) {
localPlayer = new Player {
id = Guid.NewGuid().ToString(),
displayName = displayName,
isHost = true,
isReady = false
};
state = MultiplayerState.Connecting;
StartCoroutine(CreateSessionCoroutine(
mode, onSuccess, onError));
}`,
},
} as const

View File

@@ -0,0 +1,226 @@
// ========================================
// Types
// ========================================
export type WizardStep =
| 'welcome'
| 'game-modes'
| 'matrix-chat'
| 'jitsi-video'
| 'go-services'
| 'unity-integration'
| 'demo'
| 'summary'
export interface StepInfo {
id: WizardStep
title: string
description: string
}
// ========================================
// Step Configuration
// ========================================
export const STEPS: StepInfo[] = [
{ id: 'welcome', title: 'Willkommen', description: 'Multiplayer-Uebersicht' },
{ id: 'game-modes', title: 'Spielmodi', description: 'Co-Op, Challenge, Klasse' },
{ id: 'matrix-chat', title: 'Matrix Chat', description: 'Echtzeit-Kommunikation' },
{ id: 'jitsi-video', title: 'Jitsi Video', description: 'Video-Konferenzen' },
{ id: 'go-services', title: 'Go Services', description: 'Backend-Integration' },
{ id: 'unity-integration', title: 'Unity', description: 'WebGL Bridge' },
{ id: 'demo', title: 'Demo', description: 'Live-Test' },
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
]
// ========================================
// Educational Content
// ========================================
export const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
'welcome': {
title: 'Multiplayer fuer Breakpilot Drive',
content: `Das Multiplayer-System ermoeglicht kooperatives und kompetitives Spielen
zwischen Schuelern. Es basiert auf zwei bewaehrten Open-Source-Technologien:
- **Matrix Synapse** fuer Echtzeit-Chat
- **Jitsi Meet** fuer Video-Kommunikation
Diese Integration ermoeglicht verschiedene Spielmodi, von 1v1-Challenges
bis hin zu klassenweiten Wettbewerben.`,
tips: [
'Matrix ist ein dezentrales Chat-Protokoll mit End-to-End-Verschluesselung',
'Jitsi ist eine Open-Source-Alternative zu Zoom/Teams',
'Beide Systeme sind DSGVO-konform und selbst-gehostet'
]
},
'game-modes': {
title: 'Multiplayer-Spielmodi',
content: `Breakpilot Drive unterstuetzt vier verschiedene Multiplayer-Modi:
**Solo** - Einzelspieler ohne Netzwerk
**Co-Op** - 2-4 Spieler arbeiten zusammen
- Gemeinsame Strecke
- Team-Chat
- Optionales Video
**Challenge** - 1v1 Wettbewerb
- Gleiche Quiz-Fragen
- Live-Punktestand
- Video-Chat empfohlen
**Klassenrennen** - Alle gegen alle
- Lehrer als Moderator
- Klassen-Chat
- Live-Leaderboard`,
tips: [
'Co-Op ist ideal fuer Lerngruppen und Foerderunterricht',
'Challenges motivieren durch direkten Wettbewerb',
'Klassenrennen eignen sich als Abschluss einer Lerneinheit'
]
},
'matrix-chat': {
title: 'Matrix Chat Integration',
content: `Matrix wird fuer die Echtzeit-Kommunikation verwendet:
**Raum-Typen:**
- Team-Raeume (Co-Op, privat)
- Challenge-Raeume (1v1, temporaer)
- Klassen-Raeume (alle Schueler, Lehrer moderiert)
**Features:**
- Spieler-Beitritt/Austritt Benachrichtigungen
- Score-Updates in Echtzeit
- Achievement-Ankuendigungen
- End-to-End-Verschluesselung optional
**Game Events:**
- player_joined, player_left
- game_started, game_ended
- score_update, quiz_answered
- achievement, challenge_won`,
tips: [
'Matrix-Raeume werden automatisch erstellt und archiviert',
'Power Levels kontrollieren wer schreiben darf',
'Custom Events (breakpilot.game.*) fuer Spiellogik'
]
},
'jitsi-video': {
title: 'Jitsi Video Integration',
content: `Jitsi ermoeglicht Video-Kommunikation waehrend des Spiels:
**Konfiguration pro Modus:**
- Co-Op: Audio an, Video optional
- Challenge: Audio/Video empfohlen
- Klassenrennen: Lehrer-Video, Schueler stumm
**Sicherheit:**
- JWT-basierte Authentifizierung
- Lobby fuer Klassenraeume
- Kein Recording fuer Minderjaehrige
**Unity-Embedding:**
- Kompaktes Overlay (320x240px)
- Minimale UI (nur Mikro, Kamera, Auflegen)
- Automatisches Verbinden bei Spielstart`,
tips: [
'Jitsi-Container erscheint als Overlay im Spiel',
'Audio hat Prioritaet - Video ist optional',
'Lehrer koennen Schueler stummschalten'
]
},
'go-services': {
title: 'Backend Go Services',
content: `Die Multiplayer-Logik ist in Go implementiert:
**Matrix Service** (game_rooms.go)
- CreateGameTeamRoom() - Co-Op Raeume
- CreateGameChallengeRoom() - 1v1 Raeume
- CreateGameClassRaceRoom() - Klassen-Raeume
- SendGameEvent() - Event Broadcasting
**Jitsi Service** (game_meetings.go)
- CreateCoopMeeting() - Team Video
- CreateChallengeMeeting() - 1v1 Video
- CreateClassRaceMeeting() - Klassen-Video
- JWT-Token Generierung
**Pfade:**
- consent-service/internal/services/matrix/game_rooms.go
- consent-service/internal/services/jitsi/game_meetings.go`,
tips: [
'Services erweitern bestehende Matrix/Jitsi Integration',
'Validierung erfolgt vor Raum-Erstellung',
'Cleanup wird automatisch bei Spielende ausgefuehrt'
]
},
'unity-integration': {
title: 'Unity WebGL Integration',
content: `Die Unity-seitige Integration besteht aus zwei Teilen:
**MultiplayerManager.cs**
- Singleton fuer Session-Verwaltung
- Events fuer UI-Updates
- Score/Achievement Broadcasting
- Editor-Simulation fuer Entwicklung
**MultiplayerPlugin.jslib**
- JavaScript Bridge fuer WebGL
- WebSocket-Verbindung zu Backend
- Jitsi External API Integration
- Automatisches Container-Management
**Pfade:**
- Assets/Scripts/Network/MultiplayerManager.cs
- Assets/Plugins/WebGL/MultiplayerPlugin.jslib`,
tips: [
'Im Editor werden Multiplayer-Events simuliert',
'jslib-Funktionen nur im WebGL Build verfuegbar',
'Jitsi-Container wird dynamisch erstellt'
]
},
'demo': {
title: 'Live-Demo',
content: `Teste die Multiplayer-Komponenten:
**Matrix-Verbindung:**
- Pruefe ob Matrix Synapse erreichbar ist
- Teste Raum-Erstellung
- Sende Test-Nachricht
**Jitsi-Verbindung:**
- Pruefe ob Jitsi Meet erreichbar ist
- Teste Meeting-Link Generierung
- Pruefe JWT-Validierung
**Hinweis:** Fuer vollstaendige Tests muss das
Unity WebGL Build laufen und mit dem Backend verbunden sein.`,
tips: [
'Matrix laeuft auf Port 8008',
'Jitsi laeuft auf Port 8443',
'Beide Services muessen in Docker laufen'
]
},
'summary': {
title: 'Zusammenfassung & Naechste Schritte',
content: `Du hast gelernt:
✓ Vier Multiplayer-Modi (Solo, Co-Op, Challenge, Klasse)
✓ Matrix fuer Chat und Game Events
✓ Jitsi fuer Video-Kommunikation
✓ Go Backend Services
✓ Unity WebGL Integration
**Naechste Schritte:**
1. Backend-Services starten (docker-compose up)
2. Unity WebGL Build erstellen
3. Multiplayer im Admin Panel testen
4. Phase 9: Mobile App Delivery`,
tips: [
'Dokumentation in docs/breakpilot-drive/multiplayer.md',
'Tests in consent-service/*_test.go',
'Unity Tests ueber Test Runner'
]
},
}

View File

@@ -10,445 +10,12 @@
import { useState } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
// ========================================
// Types
// ========================================
type WizardStep =
| 'welcome'
| 'game-modes'
| 'matrix-chat'
| 'jitsi-video'
| 'go-services'
| 'unity-integration'
| 'demo'
| 'summary'
interface StepInfo {
id: WizardStep
title: string
description: string
}
// ========================================
// Step Configuration
// ========================================
const STEPS: StepInfo[] = [
{ id: 'welcome', title: 'Willkommen', description: 'Multiplayer-Uebersicht' },
{ id: 'game-modes', title: 'Spielmodi', description: 'Co-Op, Challenge, Klasse' },
{ id: 'matrix-chat', title: 'Matrix Chat', description: 'Echtzeit-Kommunikation' },
{ id: 'jitsi-video', title: 'Jitsi Video', description: 'Video-Konferenzen' },
{ id: 'go-services', title: 'Go Services', description: 'Backend-Integration' },
{ id: 'unity-integration', title: 'Unity', description: 'WebGL Bridge' },
{ id: 'demo', title: 'Demo', description: 'Live-Test' },
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
]
// ========================================
// Educational Content
// ========================================
const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
'welcome': {
title: 'Multiplayer fuer Breakpilot Drive',
content: `Das Multiplayer-System ermoeglicht kooperatives und kompetitives Spielen
zwischen Schuelern. Es basiert auf zwei bewaehrten Open-Source-Technologien:
- **Matrix Synapse** fuer Echtzeit-Chat
- **Jitsi Meet** fuer Video-Kommunikation
Diese Integration ermoeglicht verschiedene Spielmodi, von 1v1-Challenges
bis hin zu klassenweiten Wettbewerben.`,
tips: [
'Matrix ist ein dezentrales Chat-Protokoll mit End-to-End-Verschluesselung',
'Jitsi ist eine Open-Source-Alternative zu Zoom/Teams',
'Beide Systeme sind DSGVO-konform und selbst-gehostet'
]
},
'game-modes': {
title: 'Multiplayer-Spielmodi',
content: `Breakpilot Drive unterstuetzt vier verschiedene Multiplayer-Modi:
**Solo** - Einzelspieler ohne Netzwerk
**Co-Op** - 2-4 Spieler arbeiten zusammen
- Gemeinsame Strecke
- Team-Chat
- Optionales Video
**Challenge** - 1v1 Wettbewerb
- Gleiche Quiz-Fragen
- Live-Punktestand
- Video-Chat empfohlen
**Klassenrennen** - Alle gegen alle
- Lehrer als Moderator
- Klassen-Chat
- Live-Leaderboard`,
tips: [
'Co-Op ist ideal fuer Lerngruppen und Foerderunterricht',
'Challenges motivieren durch direkten Wettbewerb',
'Klassenrennen eignen sich als Abschluss einer Lerneinheit'
]
},
'matrix-chat': {
title: 'Matrix Chat Integration',
content: `Matrix wird fuer die Echtzeit-Kommunikation verwendet:
**Raum-Typen:**
- Team-Raeume (Co-Op, privat)
- Challenge-Raeume (1v1, temporaer)
- Klassen-Raeume (alle Schueler, Lehrer moderiert)
**Features:**
- Spieler-Beitritt/Austritt Benachrichtigungen
- Score-Updates in Echtzeit
- Achievement-Ankuendigungen
- End-to-End-Verschluesselung optional
**Game Events:**
- player_joined, player_left
- game_started, game_ended
- score_update, quiz_answered
- achievement, challenge_won`,
tips: [
'Matrix-Raeume werden automatisch erstellt und archiviert',
'Power Levels kontrollieren wer schreiben darf',
'Custom Events (breakpilot.game.*) fuer Spiellogik'
]
},
'jitsi-video': {
title: 'Jitsi Video Integration',
content: `Jitsi ermoeglicht Video-Kommunikation waehrend des Spiels:
**Konfiguration pro Modus:**
- Co-Op: Audio an, Video optional
- Challenge: Audio/Video empfohlen
- Klassenrennen: Lehrer-Video, Schueler stumm
**Sicherheit:**
- JWT-basierte Authentifizierung
- Lobby fuer Klassenraeume
- Kein Recording fuer Minderjaehrige
**Unity-Embedding:**
- Kompaktes Overlay (320x240px)
- Minimale UI (nur Mikro, Kamera, Auflegen)
- Automatisches Verbinden bei Spielstart`,
tips: [
'Jitsi-Container erscheint als Overlay im Spiel',
'Audio hat Prioritaet - Video ist optional',
'Lehrer koennen Schueler stummschalten'
]
},
'go-services': {
title: 'Backend Go Services',
content: `Die Multiplayer-Logik ist in Go implementiert:
**Matrix Service** (game_rooms.go)
- CreateGameTeamRoom() - Co-Op Raeume
- CreateGameChallengeRoom() - 1v1 Raeume
- CreateGameClassRaceRoom() - Klassen-Raeume
- SendGameEvent() - Event Broadcasting
**Jitsi Service** (game_meetings.go)
- CreateCoopMeeting() - Team Video
- CreateChallengeMeeting() - 1v1 Video
- CreateClassRaceMeeting() - Klassen-Video
- JWT-Token Generierung
**Pfade:**
- consent-service/internal/services/matrix/game_rooms.go
- consent-service/internal/services/jitsi/game_meetings.go`,
tips: [
'Services erweitern bestehende Matrix/Jitsi Integration',
'Validierung erfolgt vor Raum-Erstellung',
'Cleanup wird automatisch bei Spielende ausgefuehrt'
]
},
'unity-integration': {
title: 'Unity WebGL Integration',
content: `Die Unity-seitige Integration besteht aus zwei Teilen:
**MultiplayerManager.cs**
- Singleton fuer Session-Verwaltung
- Events fuer UI-Updates
- Score/Achievement Broadcasting
- Editor-Simulation fuer Entwicklung
**MultiplayerPlugin.jslib**
- JavaScript Bridge fuer WebGL
- WebSocket-Verbindung zu Backend
- Jitsi External API Integration
- Automatisches Container-Management
**Pfade:**
- Assets/Scripts/Network/MultiplayerManager.cs
- Assets/Plugins/WebGL/MultiplayerPlugin.jslib`,
tips: [
'Im Editor werden Multiplayer-Events simuliert',
'jslib-Funktionen nur im WebGL Build verfuegbar',
'Jitsi-Container wird dynamisch erstellt'
]
},
'demo': {
title: 'Live-Demo',
content: `Teste die Multiplayer-Komponenten:
**Matrix-Verbindung:**
- Pruefe ob Matrix Synapse erreichbar ist
- Teste Raum-Erstellung
- Sende Test-Nachricht
**Jitsi-Verbindung:**
- Pruefe ob Jitsi Meet erreichbar ist
- Teste Meeting-Link Generierung
- Pruefe JWT-Validierung
**Hinweis:** Fuer vollstaendige Tests muss das
Unity WebGL Build laufen und mit dem Backend verbunden sein.`,
tips: [
'Matrix laeuft auf Port 8008',
'Jitsi laeuft auf Port 8443',
'Beide Services muessen in Docker laufen'
]
},
'summary': {
title: 'Zusammenfassung & Naechste Schritte',
content: `Du hast gelernt:
✓ Vier Multiplayer-Modi (Solo, Co-Op, Challenge, Klasse)
✓ Matrix fuer Chat und Game Events
✓ Jitsi fuer Video-Kommunikation
✓ Go Backend Services
✓ Unity WebGL Integration
**Naechste Schritte:**
1. Backend-Services starten (docker-compose up)
2. Unity WebGL Build erstellen
3. Multiplayer im Admin Panel testen
4. Phase 9: Mobile App Delivery`,
tips: [
'Dokumentation in docs/breakpilot-drive/multiplayer.md',
'Tests in consent-service/*_test.go',
'Unity Tests ueber Test Runner'
]
},
}
// ========================================
// Components
// ========================================
function WizardStepper({ steps, currentStep, onStepClick }: {
steps: StepInfo[]
currentStep: WizardStep
onStepClick: (step: WizardStep) => void
}) {
const currentIndex = steps.findIndex(s => s.id === currentStep)
return (
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isCompleted = index < currentIndex
const isClickable = index <= currentIndex + 1
return (
<button
key={step.id}
onClick={() => isClickable && onStepClick(step.id)}
disabled={!isClickable}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-indigo-600 text-white'
: isCompleted
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
: isClickable
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
isActive ? 'bg-white/20' : isCompleted ? 'bg-indigo-200' : 'bg-gray-200'
}`}>
{isCompleted ? '✓' : index + 1}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
)
})}
</div>
)
}
function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
<div className="prose prose-sm max-w-none mb-6">
{content.split('\n\n').map((paragraph, i) => (
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
{paragraph.split('**').map((part, j) =>
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
)}
</p>
))}
</div>
<div className="bg-indigo-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-indigo-800 mb-2">Tipps:</h3>
<ul className="space-y-1">
{tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-indigo-700">
<span className="text-indigo-500 mt-0.5"></span>
{tip}
</li>
))}
</ul>
</div>
</div>
)
}
function GameModeDemo() {
const modes = [
{ name: 'Solo', icon: '🎮', players: '1', features: ['Einzelspieler', 'Offline moeglich', 'Eigenes Tempo'] },
{ name: 'Co-Op', icon: '🤝', players: '2-4', features: ['Team-Chat', 'Gemeinsame Strecke', 'Video optional'] },
{ name: 'Challenge', icon: '⚔️', players: '2', features: ['1v1 Wettbewerb', 'Live-Score', 'Video empfohlen'] },
{ name: 'Klassenrennen', icon: '🏁', players: '∞', features: ['Alle gegen alle', 'Lehrer-Moderation', 'Leaderboard'] },
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{modes.map((mode) => (
<div key={mode.name} className="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-100">
<div className="flex items-center gap-3 mb-3">
<span className="text-3xl">{mode.icon}</span>
<div>
<h4 className="font-bold text-gray-900">{mode.name}</h4>
<p className="text-sm text-gray-500">{mode.players} Spieler</p>
</div>
</div>
<ul className="space-y-1">
{mode.features.map((feature, i) => (
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
<span className="text-indigo-500"></span> {feature}
</li>
))}
</ul>
</div>
))}
</div>
)
}
function ServiceStatusDemo() {
const [matrixStatus, setMatrixStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
const [jitsiStatus, setJitsiStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
const checkMatrix = async () => {
setMatrixStatus('checking')
try {
// In production, this would check the actual Matrix server
await new Promise(resolve => setTimeout(resolve, 1000))
setMatrixStatus('online') // Mock - assume online
} catch {
setMatrixStatus('offline')
}
}
const checkJitsi = async () => {
setJitsiStatus('checking')
try {
await new Promise(resolve => setTimeout(resolve, 1000))
setJitsiStatus('online') // Mock
} catch {
setJitsiStatus('offline')
}
}
return (
<div className="mt-6 space-y-4">
<h3 className="font-semibold text-gray-800">Service-Status pruefen:</h3>
<div className="flex gap-4">
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">💬</span>
<span className="font-medium">Matrix Synapse</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
matrixStatus === 'online' ? 'bg-green-100 text-green-700' :
matrixStatus === 'offline' ? 'bg-red-100 text-red-700' :
matrixStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{matrixStatus === 'online' ? 'Online' :
matrixStatus === 'offline' ? 'Offline' :
matrixStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
</span>
</div>
<p className="text-sm text-gray-500 mb-3">Port 8008</p>
<button
onClick={checkMatrix}
disabled={matrixStatus === 'checking'}
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
>
Status pruefen
</button>
</div>
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">📹</span>
<span className="font-medium">Jitsi Meet</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
jitsiStatus === 'online' ? 'bg-green-100 text-green-700' :
jitsiStatus === 'offline' ? 'bg-red-100 text-red-700' :
jitsiStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{jitsiStatus === 'online' ? 'Online' :
jitsiStatus === 'offline' ? 'Offline' :
jitsiStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
</span>
</div>
<p className="text-sm text-gray-500 mb-3">Port 8443</p>
<button
onClick={checkJitsi}
disabled={jitsiStatus === 'checking'}
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
>
Status pruefen
</button>
</div>
</div>
</div>
)
}
function CodePreview({ title, code, language }: { title: string; code: string; language: string }) {
return (
<div className="mt-6 bg-gray-900 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800">
<span className="text-sm text-gray-400">{title}</span>
<span className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded">{language}</span>
</div>
<pre className="p-4 text-sm text-gray-300 overflow-x-auto">
<code>{code}</code>
</pre>
</div>
)
}
// ========================================
// Main Component
// ========================================
import { STEPS, EDUCATION_CONTENT, WizardStep } from './_components/types'
import { WizardStepper } from './_components/WizardStepper'
import { EducationCard } from './_components/EducationCard'
import { GameModeDemo, ServiceStatusDemo, CodePreview } from './_components/StepContent'
import { Sidebar } from './_components/Sidebar'
import { CODE_EXAMPLES } from './_components/codeExamples'
export default function MultiplayerWizardPage() {
const [currentStep, setCurrentStep] = useState<WizardStep>('welcome')
@@ -508,80 +75,15 @@ export default function MultiplayerWizardPage() {
{/* Step-specific content */}
{currentStep === 'game-modes' && <GameModeDemo />}
{currentStep === 'demo' && <ServiceStatusDemo />}
{currentStep === 'matrix-chat' && (
<CodePreview
title="game_rooms.go - Raum erstellen"
language="Go"
code={`func (s *MatrixService) CreateGameTeamRoom(
ctx context.Context,
config GameRoomConfig,
) (*CreateRoomResponse, error) {
roomName := fmt.Sprintf("Breakpilot Drive - Team %s",
config.SessionID[:8])
req := CreateRoomRequest{
Name: roomName,
Visibility: "private",
Preset: "private_chat",
// ... power levels, encryption
}
return s.CreateRoom(ctx, req)
}`}
/>
<CodePreview {...CODE_EXAMPLES.matrixChat} />
)}
{currentStep === 'jitsi-video' && (
<CodePreview
title="game_meetings.go - Meeting erstellen"
language="Go"
code={`func (s *JitsiService) CreateChallengeMeeting(
ctx context.Context,
config GameMeetingConfig,
challengerName string,
opponentName string,
) (*GameMeetingLink, error) {
meeting := Meeting{
RoomName: fmt.Sprintf("bp-challenge-%s",
config.SessionID[:8]),
Subject: fmt.Sprintf("Challenge: %s vs %s",
challengerName, opponentName),
Config: &MeetingConfig{
StartWithAudioMuted: false,
RequireDisplayName: true,
},
}
return s.CreateMeetingLink(ctx, meeting)
}`}
/>
<CodePreview {...CODE_EXAMPLES.jitsiVideo} />
)}
{currentStep === 'unity-integration' && (
<CodePreview
title="MultiplayerManager.cs - Session erstellen"
language="C#"
code={`public void CreateSession(
GameMode mode,
string displayName,
Action<MultiplayerSession> onSuccess,
Action<string> onError
) {
localPlayer = new Player {
id = Guid.NewGuid().ToString(),
displayName = displayName,
isHost = true,
isReady = false
};
state = MultiplayerState.Connecting;
StartCoroutine(CreateSessionCoroutine(
mode, onSuccess, onError));
}`}
/>
<CodePreview {...CODE_EXAMPLES.unityIntegration} />
)}
{/* Navigation */}
@@ -604,59 +106,7 @@ export default function MultiplayerWizardPage() {
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<span className="text-xs font-semibold text-indigo-600">
Schritt {currentStepIndex + 1} von {STEPS.length}
</span>
<span className="text-xs font-semibold text-indigo-600">
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-indigo-100">
<div
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-600 transition-all duration-300"
/>
</div>
</div>
</div>
{/* Architecture Overview */}
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow p-6 text-white">
<h3 className="font-semibold mb-4">Architektur</h3>
<div className="text-sm space-y-2 font-mono">
<div className="bg-white/10 rounded px-2 py-1">Unity WebGL</div>
<div className="text-center text-indigo-200"> JS Bridge</div>
<div className="bg-white/10 rounded px-2 py-1">Matrix + Jitsi</div>
<div className="text-center text-indigo-200"> WebSocket/API</div>
<div className="bg-white/10 rounded px-2 py-1">Go Backend</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white rounded-xl shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
<ul className="space-y-2 text-sm">
<li className="text-gray-600">
<span className="text-indigo-600">Go:</span> consent-service/.../matrix/game_rooms.go
</li>
<li className="text-gray-600">
<span className="text-indigo-600">Go:</span> consent-service/.../jitsi/game_meetings.go
</li>
<li className="text-gray-600">
<span className="text-indigo-600">C#:</span> Assets/Scripts/Network/MultiplayerManager.cs
</li>
<li className="text-gray-600">
<span className="text-indigo-600">JS:</span> Assets/Plugins/WebGL/MultiplayerPlugin.jslib
</li>
</ul>
</div>
</div>
<Sidebar currentStepIndex={currentStepIndex} />
</div>
</AdminLayout>
)

View File

@@ -0,0 +1,246 @@
'use client'
import { useState } from 'react'
import type { Collection } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
const statusColors = {
ready: 'bg-green-100 text-green-800',
indexing: 'bg-yellow-100 text-yellow-800',
empty: 'bg-slate-100 text-slate-800',
}
const statusLabels = {
ready: 'Bereit',
indexing: 'Indexierung...',
empty: 'Leer',
}
const useCaseLabels: Record<string, string> = {
klausur: 'Klausurkorrektur',
zeugnis: 'Zeugniserstellung',
material: 'Unterrichtsmaterial',
curriculum: 'Lehrplan',
other: 'Sonstiges',
unknown: 'Unbekannt',
}
export function CollectionCard({ collection }: { collection: Collection }) {
const [ingesting, setIngesting] = useState(false)
const [reindexing, setReindexing] = useState(false)
const [ingestMessage, setIngestMessage] = useState<string | null>(null)
const [showReindexConfirm, setShowReindexConfirm] = useState(false)
const [chunkingStrategy, setChunkingStrategy] = useState<'semantic' | 'recursive'>('semantic')
const handleIngest = async () => {
setIngesting(true)
setIngestMessage(null)
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/ingest?incremental=true`, {
method: 'POST',
})
if (res.ok) {
const data = await res.json()
setIngestMessage(data.message || 'Indexierung gestartet')
} else {
const error = await res.json()
setIngestMessage(error.detail || 'Fehler beim Starten')
}
} catch (err) {
setIngestMessage('Netzwerkfehler')
} finally {
setIngesting(false)
}
}
const handleReindex = async () => {
setShowReindexConfirm(false)
setReindexing(true)
setIngestMessage('Starte Re-Indexierung...')
try {
const res = await fetch(
`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/reindex?chunking_strategy=${chunkingStrategy}`,
{ method: 'POST' }
)
if (!res.ok) {
const error = await res.json()
setIngestMessage(error.detail || 'Fehler beim Starten')
setReindexing(false)
return
}
const pollProgress = async () => {
try {
const progressRes = await fetch(`${API_BASE}/api/v1/admin/rag/reindex/progress`)
if (progressRes.ok) {
const progress = await progressRes.json()
if (progress.phase === 'deleting') {
setIngestMessage('Loesche alte Chunks...')
} else if (progress.phase === 'indexing') {
const pct = progress.total_docs > 0
? Math.round((progress.current_doc / progress.total_docs) * 100)
: 0
setIngestMessage(
`Indexiere: ${progress.current_doc}/${progress.total_docs} (${pct}%) - ${progress.current_filename}`
)
} else if (progress.phase === 'complete') {
setIngestMessage(
`Fertig: ${progress.documents_processed} Dokumente, ` +
`${progress.chunks_created} neue Chunks (${progress.old_chunks_deleted} alte geloescht)`
)
setReindexing(false)
return
} else if (progress.phase === 'failed') {
setIngestMessage(`Fehler: ${progress.error || 'Unbekannter Fehler'}`)
setReindexing(false)
return
}
if (progress.running) {
setTimeout(pollProgress, 1000)
} else {
setReindexing(false)
}
}
} catch (err) {
setTimeout(pollProgress, 2000)
}
}
setTimeout(pollProgress, 500)
} catch (err) {
setIngestMessage('Netzwerkfehler bei Re-Indexierung')
setReindexing(false)
}
}
return (
<div 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-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
{collection.description && (
<p className="text-sm text-slate-600 mt-1">{collection.description}</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
{statusLabels[collection.status]}
</span>
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded">
{useCaseLabels[collection.useCase] || collection.useCase}
</span>
</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">Chunks</p>
<p className="text-lg font-semibold text-slate-900">{collection.chunkCount.toLocaleString()}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
<p className="text-lg font-semibold text-slate-900">
{collection.years?.length > 0
? `${Math.min(...collection.years)}-${Math.max(...collection.years)}`
: '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Faecher</p>
<p className="text-lg font-semibold text-slate-900">
{collection.subjects?.length > 0 ? collection.subjects.length : '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
<p className="text-lg font-semibold text-slate-900">{collection.bundesland}</p>
</div>
</div>
{collection.subjects.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{collection.subjects.slice(0, 8).map((subject) => (
<span key={subject} className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md">{subject}</span>
))}
{collection.subjects.length > 8 && (
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">+{collection.subjects.length - 8} weitere</span>
)}
</div>
)}
{/* Ingestion Buttons */}
<div className="mt-4 pt-4 border-t border-slate-200">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleIngest}
disabled={ingesting || reindexing}
className="px-4 py-2 text-sm font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-lg hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{ingesting ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>Wird gestartet...</>
) : (
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>Neue indexieren</>
)}
</button>
<button
onClick={() => setShowReindexConfirm(true)}
disabled={ingesting || reindexing || collection.chunkCount === 0}
className="px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Alle Dokumente mit neuem Chunking-Algorithmus neu indexieren"
>
{reindexing ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600"></div>Re-Indexierung...</>
) : (
<><svg className="w-4 h-4" 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" /></svg>Neu-Chunking</>
)}
</button>
</div>
{ingestMessage && <p className="mt-2 text-sm text-slate-600">{ingestMessage}</p>}
</div>
{/* Re-Index Confirmation Modal */}
{showReindexConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Collection neu indexieren?</h3>
<p className="text-slate-600 mb-4">
Dies loescht alle {collection.chunkCount.toLocaleString()} bestehenden Chunks
und erstellt sie mit dem gewaehlten Chunking-Algorithmus neu.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">Chunking-Strategie</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="chunkingStrategy" value="semantic" checked={chunkingStrategy === 'semantic'} onChange={() => setChunkingStrategy('semantic')} className="text-primary-600" />
<span className="text-sm"><strong>Semantisch</strong><span className="text-slate-500 ml-1">(empfohlen)</span></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="chunkingStrategy" value="recursive" checked={chunkingStrategy === 'recursive'} onChange={() => setChunkingStrategy('recursive')} className="text-primary-600" />
<span className="text-sm">Rekursiv (legacy)</span>
</label>
</div>
<p className="text-xs text-slate-500 mt-2">Semantisches Chunking respektiert Satzgrenzen und verbessert die Suchqualitaet.</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowReindexConfirm(false)} className="px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200">Abbrechen</button>
<button onClick={handleReindex} className="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700">Neu indexieren</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'
import type { Collection, CreateCollectionData } from '../types'
import { CollectionCard } from './CollectionCard'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
@@ -11,11 +12,24 @@ interface CollectionsTabProps {
onRefresh: () => void
}
function CollectionsTab({
collections,
loading,
onRefresh
}: CollectionsTabProps) {
const useCaseOptions = [
{ value: 'klausur', label: 'Klausurkorrektur' },
{ value: 'zeugnis', label: 'Zeugniserstellung' },
{ value: 'material', label: 'Unterrichtsmaterial' },
{ value: 'curriculum', label: 'Lehrplan' },
{ value: 'other', label: 'Sonstiges' },
]
const bundeslandOptions = [
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Wuerttemberg' },
{ value: 'HE', label: 'Hessen' },
{ value: 'DE', label: 'Bundesweit' },
]
function CollectionsTab({ collections, loading, onRefresh }: CollectionsTabProps) {
const [showCreateModal, setShowCreateModal] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
@@ -30,23 +44,15 @@ function CollectionsTab({
const handleCreate = async () => {
setCreating(true)
setCreateError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (res.ok) {
setShowCreateModal(false)
setFormData({
name: 'bp_',
display_name: '',
bundesland: 'NI',
use_case: '',
description: '',
})
setFormData({ name: 'bp_', display_name: '', bundesland: 'NI', use_case: '', description: '' })
onRefresh()
} else {
const error = await res.json()
@@ -59,23 +65,6 @@ function CollectionsTab({
}
}
const useCaseOptions = [
{ value: 'klausur', label: 'Klausurkorrektur' },
{ value: 'zeugnis', label: 'Zeugniserstellung' },
{ value: 'material', label: 'Unterrichtsmaterial' },
{ value: 'curriculum', label: 'Lehrplan' },
{ value: 'other', label: 'Sonstiges' },
]
const bundeslandOptions = [
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Württemberg' },
{ value: 'HE', label: 'Hessen' },
{ value: 'DE', label: 'Bundesweit' },
]
return (
<div className="space-y-6">
{/* Header */}
@@ -85,32 +74,20 @@ function CollectionsTab({
<p className="text-sm text-slate-500">Verwaltung der indexierten Dokumentensammlungen</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={() => setShowCreateModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<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={() => setShowCreateModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 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="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
Neue Sammlung
</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-primary-600"></div>
</div>
)}
{/* Collections Grid */}
{!loading && (
<div className="grid gap-4">
{collections.length === 0 ? (
@@ -120,28 +97,14 @@ function CollectionsTab({
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Sammlungen vorhanden</h3>
<p className="text-slate-500 mb-4">Erstellen Sie eine neue Sammlung, um Dokumente zu indexieren.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
Erste Sammlung erstellen
</button>
<button onClick={() => setShowCreateModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">Erste Sammlung erstellen</button>
</div>
) : (
collections.map((col) => (
<CollectionCard key={col.name} collection={col} />
))
collections.map((col) => <CollectionCard key={col.name} collection={col} />)
)}
{/* Add new collection card */}
{collections.length > 0 && (
<button
onClick={() => setShowCreateModal(true)}
className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
>
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<button onClick={() => setShowCreateModal(true)} className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer">
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
<span className="text-sm font-medium text-slate-600">Neue Sammlung erstellen</span>
</button>
)}
@@ -154,115 +117,43 @@ function CollectionsTab({
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue RAG-Sammlung erstellen</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellen Sie eine neue Sammlung für einen spezifischen Anwendungsfall
</p>
<p className="text-sm text-slate-500 mt-1">Erstellen Sie eine neue Sammlung fuer einen spezifischen Anwendungsfall</p>
</div>
<div className="p-6 space-y-4">
{createError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{createError}
</div>
)}
{createError && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{createError}</div>}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Anzeigename *
</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => setFormData(prev => ({ ...prev, display_name: e.target.value }))}
placeholder="z.B. Niedersachsen - Zeugniserstellung"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename *</label>
<input type="text" value={formData.display_name} onChange={(e) => setFormData(prev => ({ ...prev, display_name: e.target.value }))} placeholder="z.B. Niedersachsen - Zeugniserstellung" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Bundesland
</label>
<select
value={formData.bundesland}
onChange={(e) => setFormData(prev => ({ ...prev, bundesland: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
{bundeslandOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
<label className="block text-sm font-medium text-slate-700 mb-1">Bundesland</label>
<select value={formData.bundesland} onChange={(e) => setFormData(prev => ({ ...prev, bundesland: e.target.value }))} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
{bundeslandOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Anwendungsfall *
</label>
<select
value={formData.use_case}
onChange={(e) => setFormData(prev => ({ ...prev, use_case: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Auswählen...</option>
{useCaseOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
<label className="block text-sm font-medium text-slate-700 mb-1">Anwendungsfall *</label>
<select value={formData.use_case} onChange={(e) => setFormData(prev => ({ ...prev, use_case: e.target.value }))} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
<option value="">Auswaehlen...</option>
{useCaseOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Technischer Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="bp_ni_zeugnis"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm"
/>
<label className="block text-sm font-medium text-slate-700 mb-1">Technischer Name *</label>
<input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="bp_ni_zeugnis" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm" />
<p className="text-xs text-slate-500 mt-1">Muss mit "bp_" beginnen. Nur Kleinbuchstaben und Unterstriche.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Wofür wird diese Sammlung verwendet?"
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="Wofuer wird diese Sammlung verwendet?" rows={3} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500" />
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => {
setShowCreateModal(false)
setCreateError(null)
}}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={handleCreate}
disabled={creating || !formData.name || !formData.display_name || !formData.use_case}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird erstellt...
</>
) : (
'Sammlung erstellen'
)}
<button onClick={() => { setShowCreateModal(false); setCreateError(null) }} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Abbrechen</button>
<button onClick={handleCreate} disabled={creating || !formData.name || !formData.display_name || !formData.use_case} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
{creating ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Wird erstellt...</>) : 'Sammlung erstellen'}
</button>
</div>
</div>
@@ -272,322 +163,4 @@ function CollectionsTab({
)
}
function CollectionCard({ collection }: { collection: Collection }) {
const [ingesting, setIngesting] = useState(false)
const [reindexing, setReindexing] = useState(false)
const [ingestMessage, setIngestMessage] = useState<string | null>(null)
const [showReindexConfirm, setShowReindexConfirm] = useState(false)
const [chunkingStrategy, setChunkingStrategy] = useState<'semantic' | 'recursive'>('semantic')
const statusColors = {
ready: 'bg-green-100 text-green-800',
indexing: 'bg-yellow-100 text-yellow-800',
empty: 'bg-slate-100 text-slate-800',
}
const statusLabels = {
ready: 'Bereit',
indexing: 'Indexierung...',
empty: 'Leer',
}
const useCaseLabels: Record<string, string> = {
klausur: 'Klausurkorrektur',
zeugnis: 'Zeugniserstellung',
material: 'Unterrichtsmaterial',
curriculum: 'Lehrplan',
other: 'Sonstiges',
unknown: 'Unbekannt',
}
const handleIngest = async () => {
setIngesting(true)
setIngestMessage(null)
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/ingest?incremental=true`, {
method: 'POST',
})
if (res.ok) {
const data = await res.json()
setIngestMessage(data.message || 'Indexierung gestartet')
} else {
const error = await res.json()
setIngestMessage(error.detail || 'Fehler beim Starten')
}
} catch (err) {
setIngestMessage('Netzwerkfehler')
} finally {
setIngesting(false)
}
}
const handleReindex = async () => {
setShowReindexConfirm(false)
setReindexing(true)
setIngestMessage('Starte Re-Indexierung...')
try {
// Start the reindex
const res = await fetch(
`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/reindex?chunking_strategy=${chunkingStrategy}`,
{ method: 'POST' }
)
if (!res.ok) {
const error = await res.json()
setIngestMessage(error.detail || 'Fehler beim Starten')
setReindexing(false)
return
}
// Poll for progress
const pollProgress = async () => {
try {
const progressRes = await fetch(`${API_BASE}/api/v1/admin/rag/reindex/progress`)
if (progressRes.ok) {
const progress = await progressRes.json()
if (progress.phase === 'deleting') {
setIngestMessage('Lösche alte Chunks...')
} else if (progress.phase === 'indexing') {
const pct = progress.total_docs > 0
? Math.round((progress.current_doc / progress.total_docs) * 100)
: 0
setIngestMessage(
`Indexiere: ${progress.current_doc}/${progress.total_docs} (${pct}%) - ${progress.current_filename}`
)
} else if (progress.phase === 'complete') {
setIngestMessage(
`Fertig: ${progress.documents_processed} Dokumente, ` +
`${progress.chunks_created} neue Chunks (${progress.old_chunks_deleted} alte gelöscht)`
)
setReindexing(false)
return // Stop polling
} else if (progress.phase === 'failed') {
setIngestMessage(`Fehler: ${progress.error || 'Unbekannter Fehler'}`)
setReindexing(false)
return // Stop polling
}
// Continue polling if still running
if (progress.running) {
setTimeout(pollProgress, 1000)
} else {
setReindexing(false)
}
}
} catch (err) {
// Ignore polling errors, will retry
setTimeout(pollProgress, 2000)
}
}
// Start polling after a short delay
setTimeout(pollProgress, 500)
} catch (err) {
setIngestMessage('Netzwerkfehler bei Re-Indexierung')
setReindexing(false)
}
}
return (
<div 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-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
{collection.description && (
<p className="text-sm text-slate-600 mt-1">{collection.description}</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
{statusLabels[collection.status]}
</span>
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded">
{useCaseLabels[collection.useCase] || collection.useCase}
</span>
</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">Chunks</p>
<p className="text-lg font-semibold text-slate-900">
{collection.chunkCount.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
<p className="text-lg font-semibold text-slate-900">
{collection.years?.length > 0
? `${Math.min(...collection.years)}-${Math.max(...collection.years)}`
: '-'
}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fächer</p>
<p className="text-lg font-semibold text-slate-900">
{collection.subjects?.length > 0 ? collection.subjects.length : '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
<p className="text-lg font-semibold text-slate-900">
{collection.bundesland}
</p>
</div>
</div>
{collection.subjects.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{collection.subjects.slice(0, 8).map((subject) => (
<span
key={subject}
className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md"
>
{subject}
</span>
))}
{collection.subjects.length > 8 && (
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">
+{collection.subjects.length - 8} weitere
</span>
)}
</div>
)}
{/* Ingestion Buttons */}
<div className="mt-4 pt-4 border-t border-slate-200">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleIngest}
disabled={ingesting || reindexing}
className="px-4 py-2 text-sm font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-lg hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{ingesting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
Wird gestartet...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neue indexieren
</>
)}
</button>
<button
onClick={() => setShowReindexConfirm(true)}
disabled={ingesting || reindexing || collection.chunkCount === 0}
className="px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Alle Dokumente mit neuem Chunking-Algorithmus neu indexieren"
>
{reindexing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600"></div>
Re-Indexierung...
</>
) : (
<>
<svg className="w-4 h-4" 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" />
</svg>
Neu-Chunking
</>
)}
</button>
</div>
{ingestMessage && (
<p className="mt-2 text-sm text-slate-600">{ingestMessage}</p>
)}
</div>
{/* Re-Index Confirmation Modal */}
{showReindexConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-slate-900 mb-4">
Collection neu indexieren?
</h3>
<p className="text-slate-600 mb-4">
Dies löscht alle {collection.chunkCount.toLocaleString()} bestehenden Chunks
und erstellt sie mit dem gewählten Chunking-Algorithmus neu.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Chunking-Strategie
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="chunkingStrategy"
value="semantic"
checked={chunkingStrategy === 'semantic'}
onChange={() => setChunkingStrategy('semantic')}
className="text-primary-600"
/>
<span className="text-sm">
<strong>Semantisch</strong>
<span className="text-slate-500 ml-1">(empfohlen)</span>
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="chunkingStrategy"
value="recursive"
checked={chunkingStrategy === 'recursive'}
onChange={() => setChunkingStrategy('recursive')}
className="text-primary-600"
/>
<span className="text-sm">Rekursiv (legacy)</span>
</label>
</div>
<p className="text-xs text-slate-500 mt-2">
Semantisches Chunking respektiert Satzgrenzen und verbessert die Suchqualität.
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowReindexConfirm(false)}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200"
>
Abbrechen
</button>
<button
onClick={handleReindex}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700"
>
Neu indexieren
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ============================================================================
// Upload Tab
// ============================================================================
export { CollectionsTab, CollectionCard }

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import type { IngestionHistoryEntry } from '../types'
interface IngestionHistoryProps {
history: IngestionHistoryEntry[]
}
export function IngestionHistory({ history }: IngestionHistoryProps) {
const [showHistory, setShowHistory] = useState(false)
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Indexierungs-Historie</h3>
<button
onClick={() => setShowHistory(!showHistory)}
className="text-sm text-slate-600 hover:text-slate-900 font-medium"
>
{showHistory ? 'Ausblenden' : `Alle anzeigen (${history.length})`}
</button>
</div>
{history.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Indexierungslaeufe durchgefuehrt.</p>
) : (
<div className="space-y-3">
{(showHistory ? history : history.slice(0, 3)).map((entry) => (
<div
key={entry.id}
className={`rounded-lg p-4 ${
entry.status === 'success'
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{entry.status === 'success' ? (
<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="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5 text-red-600" 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="font-medium text-slate-900">
{new Date(entry.started_at || entry.startedAt).toLocaleString('de-DE')}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{entry.collection}</span>
</div>
{entry.stats && (
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gefunden:</span>{' '}
<span className="font-medium">{entry.stats.documents_found}</span>
</div>
<div>
<span className="text-slate-500">Indexiert:</span>{' '}
<span className="font-medium text-green-700">{entry.stats.documents_indexed}</span>
</div>
<div>
<span className="text-slate-500">Uebersprungen:</span>{' '}
<span className="font-medium">{entry.stats.documents_skipped}</span>
</div>
<div>
<span className="text-slate-500">Chunks:</span>{' '}
<span className="font-medium">{entry.stats.chunks_created}</span>
</div>
</div>
)}
{entry.filters && (
<div className="mt-2 flex flex-wrap gap-2">
{entry.filters.year && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
Jahr: {entry.filters.year}
</span>
)}
{entry.filters.subject && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
Fach: {entry.filters.subject}
</span>
)}
{entry.filters.incremental && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
Inkrementell
</span>
)}
</div>
)}
{entry.error && (
<p className="mt-2 text-sm text-red-700">{entry.error}</p>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,15 +1,14 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import type {
Collection,
IngestionStatus,
LiveProgress,
IndexedStats,
PendingFile,
PendingFilesData,
IngestionHistoryEntry
} from '../types'
import { IngestionHistory } from './IngestionHistory'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
@@ -18,117 +17,66 @@ interface IngestionTabProps {
onRefresh: () => void
}
function IngestionTab({
status,
onRefresh
}: IngestionTabProps) {
function IngestionTab({ status, onRefresh }: IngestionTabProps) {
const [starting, setStarting] = useState(false)
const [liveProgress, setLiveProgress] = useState<LiveProgress | null>(null)
const [indexedStats, setIndexedStats] = useState<IndexedStats | null>(null)
const [pendingFiles, setPendingFiles] = useState<PendingFilesData | null>(null)
const [ingestionHistory, setIngestionHistory] = useState<IngestionHistoryEntry[]>([])
const [showHistory, setShowHistory] = useState(false)
const [showPending, setShowPending] = useState(false)
// Fetch indexed stats, pending files, and history on mount
useEffect(() => {
const fetchStats = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`)
if (res.ok) {
const data = await res.json()
setIndexedStats(data)
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
try { const res = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`); if (res.ok) setIndexedStats(await res.json()) }
catch (err) { console.error('Failed to fetch stats:', err) }
}
const fetchPending = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/files/pending`)
if (res.ok) {
const data = await res.json()
setPendingFiles(data)
}
} catch (err) {
console.error('Failed to fetch pending files:', err)
}
try { const res = await fetch(`${API_BASE}/api/v1/admin/rag/files/pending`); if (res.ok) setPendingFiles(await res.json()) }
catch (err) { console.error('Failed to fetch pending files:', err) }
}
const fetchHistory = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/ingestion/history?limit=20`)
if (res.ok) {
const data = await res.json()
setIngestionHistory(data.history || [])
}
} catch (err) {
console.error('Failed to fetch history:', err)
}
try { const res = await fetch(`${API_BASE}/api/v1/admin/rag/ingestion/history?limit=20`); if (res.ok) { const data = await res.json(); setIngestionHistory(data.history || []) } }
catch (err) { console.error('Failed to fetch history:', err) }
}
fetchStats()
fetchPending()
fetchHistory()
fetchStats(); fetchPending(); fetchHistory()
}, [])
// Poll for live progress when running
useEffect(() => {
let interval: NodeJS.Timeout | null = null
const fetchProgress = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/progress`)
if (res.ok) {
const data = await res.json()
setLiveProgress(data)
// Refresh stats when complete
if (!data.running && data.phase === 'complete') {
onRefresh()
// Also refresh indexed stats
const statsRes = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`)
if (statsRes.ok) {
setIndexedStats(await statsRes.json())
}
if (statsRes.ok) setIndexedStats(await statsRes.json())
}
}
} catch (err) {
console.error('Failed to fetch progress:', err)
}
} catch (err) { console.error('Failed to fetch progress:', err) }
}
// Start polling immediately and every 1.5 seconds
fetchProgress()
interval = setInterval(fetchProgress, 1500)
return () => {
if (interval) clearInterval(interval)
}
return () => { if (interval) clearInterval(interval) }
}, [onRefresh])
const startIngestion = async () => {
setStarting(true)
try {
await fetch(`${API_BASE}/api/v1/admin/nibis/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ewh_only: true, incremental: true }),
})
onRefresh()
} catch (err) {
console.error('Failed to start ingestion:', err)
} finally {
setStarting(false)
}
} catch (err) { console.error('Failed to start ingestion:', err) }
finally { setStarting(false) }
}
const phaseLabels: Record<string, string> = {
idle: 'Bereit',
extracting: 'Entpacke ZIP-Dateien...',
discovering: 'Suche Dokumente...',
indexing: 'Indexiere Dokumente...',
complete: 'Abgeschlossen',
idle: 'Bereit', extracting: 'Entpacke ZIP-Dateien...', discovering: 'Suche Dokumente...',
indexing: 'Indexiere Dokumente...', complete: 'Abgeschlossen',
}
return (
@@ -136,131 +84,58 @@ function IngestionTab({
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">Ingestion Status</h2>
<p className="text-sm text-slate-500">
Übersicht über laufende und vergangene Indexierungsvorgänge
</p>
<p className="text-sm text-slate-500">Uebersicht ueber laufende und vergangene Indexierungsvorgaenge</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={startIngestion}
disabled={status?.running || starting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<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={startIngestion} disabled={status?.running || starting} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed">
{starting ? 'Startet...' : 'Ingestion starten'}
</button>
</div>
</div>
{/* Live Progress (when running) */}
{/* Live Progress */}
{liveProgress?.running && (
<div className="bg-primary-50 rounded-lg border border-primary-200 p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-3 h-3 bg-primary-500 rounded-full animate-pulse"></div>
<span className="text-lg font-medium text-primary-900">
{liveProgress.phase ? (phaseLabels[liveProgress.phase] || liveProgress.phase) : 'Läuft...'}
</span>
<span className="ml-auto text-sm text-primary-700 font-mono">
{(liveProgress.percent ?? 0).toFixed(1)}%
</span>
<span className="text-lg font-medium text-primary-900">{liveProgress.phase ? (phaseLabels[liveProgress.phase] || liveProgress.phase) : 'Laeuft...'}</span>
<span className="ml-auto text-sm text-primary-700 font-mono">{(liveProgress.percent ?? 0).toFixed(1)}%</span>
</div>
{/* Progress Bar */}
<div className="h-3 bg-primary-200 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-primary-600 transition-all duration-300"
style={{ width: `${liveProgress.percent ?? 0}%` }}
/>
<div className="h-full bg-primary-600 transition-all duration-300" style={{ width: `${liveProgress.percent ?? 0}%` }} />
</div>
{/* Current File */}
{liveProgress.current_filename && (
<p className="text-sm text-primary-700 mb-4 truncate">
<span className="font-medium">[{liveProgress.current_doc}/{liveProgress.total_docs}]</span>{' '}
{liveProgress.current_filename}
<span className="font-medium">[{liveProgress.current_doc}/{liveProgress.total_docs}]</span> {liveProgress.current_filename}
</p>
)}
{/* Live Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-white/50 rounded p-2">
<p className="text-primary-600 text-xs uppercase">Indexiert</p>
<p className="text-lg font-semibold text-primary-900">{liveProgress.documents_indexed}</p>
</div>
<div className="bg-white/50 rounded p-2">
<p className="text-primary-600 text-xs uppercase">Übersprungen</p>
<p className="text-lg font-semibold text-primary-900">{liveProgress.documents_skipped}</p>
</div>
<div className="bg-white/50 rounded p-2">
<p className="text-primary-600 text-xs uppercase">Chunks</p>
<p className="text-lg font-semibold text-primary-900">{liveProgress.chunks_created}</p>
</div>
<div className="bg-white/50 rounded p-2">
<p className="text-primary-600 text-xs uppercase">Fehler</p>
<p className={`text-lg font-semibold ${(liveProgress.errors_count ?? 0) > 0 ? 'text-red-600' : 'text-primary-900'}`}>
{liveProgress.errors_count ?? 0}
</p>
</div>
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Indexiert</p><p className="text-lg font-semibold text-primary-900">{liveProgress.documents_indexed}</p></div>
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Uebersprungen</p><p className="text-lg font-semibold text-primary-900">{liveProgress.documents_skipped}</p></div>
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Chunks</p><p className="text-lg font-semibold text-primary-900">{liveProgress.chunks_created}</p></div>
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Fehler</p><p className={`text-lg font-semibold ${(liveProgress.errors_count ?? 0) > 0 ? 'text-red-600' : 'text-primary-900'}`}>{liveProgress.errors_count ?? 0}</p></div>
</div>
</div>
)}
{/* Indexed Data Overview - Always visible */}
{/* Indexed Data Overview */}
{indexedStats?.indexed && (
<div className="bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200 p-6">
<h3 className="text-lg font-semibold text-green-900 mb-4 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="M5 13l4 4L19 7" />
</svg>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
Indexierte Daten (Gesamt)
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-4">
<div className="bg-white/60 rounded-lg p-3">
<p className="text-xs text-green-700 uppercase tracking-wider">Chunks gesamt</p>
<p className="text-2xl font-bold text-green-900">
{(indexedStats.total_chunks ?? 0).toLocaleString()}
</p>
</div>
<div className="bg-white/60 rounded-lg p-3">
<p className="text-xs text-green-700 uppercase tracking-wider">Jahre</p>
<p className="text-2xl font-bold text-green-900">
{indexedStats.years?.length ?? 0}
</p>
<p className="text-xs text-green-600 mt-1">
{(indexedStats.years?.length ?? 0) > 0
? `${Math.min(...indexedStats.years!)} - ${Math.max(...indexedStats.years!)}`
: '-'
}
</p>
</div>
<div className="bg-white/60 rounded-lg p-3">
<p className="text-xs text-green-700 uppercase tracking-wider">Fächer</p>
<p className="text-2xl font-bold text-green-900">
{indexedStats.subjects?.length || 0}
</p>
</div>
<div className="bg-white/60 rounded-lg p-3">
<p className="text-xs text-green-700 uppercase tracking-wider">Status</p>
<p className="text-lg font-bold text-green-600">Bereit</p>
</div>
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Chunks gesamt</p><p className="text-2xl font-bold text-green-900">{(indexedStats.total_chunks ?? 0).toLocaleString()}</p></div>
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Jahre</p><p className="text-2xl font-bold text-green-900">{indexedStats.years?.length ?? 0}</p><p className="text-xs text-green-600 mt-1">{(indexedStats.years?.length ?? 0) > 0 ? `${Math.min(...indexedStats.years!)} - ${Math.max(...indexedStats.years!)}` : '-'}</p></div>
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Faecher</p><p className="text-2xl font-bold text-green-900">{indexedStats.subjects?.length || 0}</p></div>
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Status</p><p className="text-lg font-bold text-green-600">Bereit</p></div>
</div>
{/* Years breakdown */}
{indexedStats.years && indexedStats.years.length > 0 && (
<div className="flex flex-wrap gap-2">
{indexedStats.years.sort((a, b) => a - b).map((year) => (
<span
key={year}
className="px-3 py-1 bg-white/80 text-green-800 text-sm rounded-full font-medium"
>
{year}
</span>
<span key={year} className="px-3 py-1 bg-white/80 text-green-800 text-sm rounded-full font-medium">{year}</span>
))}
</div>
)}
@@ -271,63 +146,27 @@ function IngestionTab({
{!liveProgress?.running && (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Letzter Indexierungslauf</h3>
<div className="flex items-center gap-4 mb-4">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-lg font-medium text-slate-900">
{liveProgress?.phase === 'complete' ? 'Abgeschlossen' : 'Bereit'}
</span>
<span className="text-lg font-medium text-slate-900">{liveProgress?.phase === 'complete' ? 'Abgeschlossen' : 'Bereit'}</span>
</div>
{status && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Zeitpunkt</p>
<p className="text-lg font-semibold text-slate-900">
{status.lastRun
? new Date(status.lastRun).toLocaleString('de-DE')
: 'Noch nie'
}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Neu indexiert</p>
<p className="text-lg font-semibold text-slate-900">
{status.documentsIndexed ?? '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Neue Chunks</p>
<p className="text-lg font-semibold text-slate-900">
{status.chunksCreated ?? '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p>
<p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>
{status.errors.length}
</p>
</div>
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Zeitpunkt</p><p className="text-lg font-semibold text-slate-900">{status.lastRun ? new Date(status.lastRun).toLocaleString('de-DE') : 'Noch nie'}</p></div>
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Neu indexiert</p><p className="text-lg font-semibold text-slate-900">{status.documentsIndexed ?? '-'}</p></div>
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Neue Chunks</p><p className="text-lg font-semibold text-slate-900">{status.chunksCreated ?? '-'}</p></div>
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p><p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>{status.errors.length}</p></div>
</div>
)}
{/* Show skipped info from live progress */}
{liveProgress && (liveProgress.documents_skipped ?? 0) > 0 && (
<p className="mt-3 text-sm text-slate-500">
{liveProgress.documents_skipped} Dokumente übersprungen (bereits indexiert)
</p>
<p className="mt-3 text-sm text-slate-500">{liveProgress.documents_skipped} Dokumente uebersprungen (bereits indexiert)</p>
)}
{status?.errors && status.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">
{status.errors.slice(0, 5).map((error, i) => (
<li key={i}>{error}</li>
))}
{status.errors.length > 5 && (
<li className="text-red-500">... und {status.errors.length - 5} weitere</li>
)}
{status.errors.slice(0, 5).map((error, i) => <li key={i}>{error}</li>)}
{status.errors.length > 5 && <li className="text-red-500">... und {status.errors.length - 5} weitere</li>}
</ul>
</div>
)}
@@ -339,174 +178,40 @@ function IngestionTab({
<div className="bg-amber-50 rounded-lg border border-amber-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div>
<h3 className="text-lg font-semibold text-amber-900">
{pendingFiles.pending_count} Dateien warten auf Indexierung
</h3>
<p className="text-sm text-amber-700">
{pendingFiles.indexed_count} von {pendingFiles.total_files} Dateien sind indexiert
</p>
<h3 className="text-lg font-semibold text-amber-900">{pendingFiles.pending_count} Dateien warten auf Indexierung</h3>
<p className="text-sm text-amber-700">{pendingFiles.indexed_count} von {pendingFiles.total_files} Dateien sind indexiert</p>
</div>
</div>
<button
onClick={() => setShowPending(!showPending)}
className="text-sm text-amber-700 hover:text-amber-900 font-medium"
>
{showPending ? 'Ausblenden' : 'Details anzeigen'}
</button>
<button onClick={() => setShowPending(!showPending)} className="text-sm text-amber-700 hover:text-amber-900 font-medium">{showPending ? 'Ausblenden' : 'Details anzeigen'}</button>
</div>
{/* Pending by year summary */}
{pendingFiles.by_year && Object.keys(pendingFiles.by_year).length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{Object.entries(pendingFiles.by_year)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([year, count]) => (
<span
key={year}
className="px-3 py-1 bg-amber-100 text-amber-800 text-sm rounded-full font-medium"
>
{year}: {count} Dateien
</span>
))}
{Object.entries(pendingFiles.by_year).sort(([a], [b]) => Number(b) - Number(a)).map(([year, count]) => (
<span key={year} className="px-3 py-1 bg-amber-100 text-amber-800 text-sm rounded-full font-medium">{year}: {count} Dateien</span>
))}
</div>
)}
{/* Pending files list */}
{showPending && pendingFiles.pending_files && (
<div className="mt-4 max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="text-left text-amber-700 border-b border-amber-200">
<tr>
<th className="pb-2">Dateiname</th>
<th className="pb-2">Jahr</th>
<th className="pb-2">Fach</th>
<th className="pb-2">Niveau</th>
</tr>
</thead>
<thead className="text-left text-amber-700 border-b border-amber-200"><tr><th className="pb-2">Dateiname</th><th className="pb-2">Jahr</th><th className="pb-2">Fach</th><th className="pb-2">Niveau</th></tr></thead>
<tbody className="divide-y divide-amber-100">
{pendingFiles.pending_files.map((file) => (
<tr key={file.id} className="text-amber-900">
<td className="py-2 font-mono text-xs">{file.filename}</td>
<td className="py-2">{file.year}</td>
<td className="py-2">{file.subject}</td>
<td className="py-2">{file.niveau}</td>
</tr>
<tr key={file.id} className="text-amber-900"><td className="py-2 font-mono text-xs">{file.filename}</td><td className="py-2">{file.year}</td><td className="py-2">{file.subject}</td><td className="py-2">{file.niveau}</td></tr>
))}
</tbody>
</table>
{(pendingFiles.pending_count ?? 0) > 100 && (
<p className="mt-2 text-xs text-amber-600">
Zeige 100 von {pendingFiles.pending_count} Dateien
</p>
)}
{(pendingFiles.pending_count ?? 0) > 100 && <p className="mt-2 text-xs text-amber-600">Zeige 100 von {pendingFiles.pending_count} Dateien</p>}
</div>
)}
</div>
)}
{/* Ingestion History Section */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Indexierungs-Historie</h3>
<button
onClick={() => setShowHistory(!showHistory)}
className="text-sm text-slate-600 hover:text-slate-900 font-medium"
>
{showHistory ? 'Ausblenden' : `Alle anzeigen (${ingestionHistory.length})`}
</button>
</div>
{ingestionHistory.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Indexierungsläufe durchgeführt.</p>
) : (
<div className="space-y-3">
{(showHistory ? ingestionHistory : ingestionHistory.slice(0, 3)).map((entry) => (
<div
key={entry.id}
className={`rounded-lg p-4 ${
entry.status === 'success'
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{entry.status === 'success' ? (
<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="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5 text-red-600" 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="font-medium text-slate-900">
{new Date(entry.started_at || entry.startedAt).toLocaleString('de-DE')}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{entry.collection}</span>
</div>
{entry.stats && (
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gefunden:</span>{' '}
<span className="font-medium">{entry.stats.documents_found}</span>
</div>
<div>
<span className="text-slate-500">Indexiert:</span>{' '}
<span className="font-medium text-green-700">{entry.stats.documents_indexed}</span>
</div>
<div>
<span className="text-slate-500">Übersprungen:</span>{' '}
<span className="font-medium">{entry.stats.documents_skipped}</span>
</div>
<div>
<span className="text-slate-500">Chunks:</span>{' '}
<span className="font-medium">{entry.stats.chunks_created}</span>
</div>
</div>
)}
{entry.filters && (
<div className="mt-2 flex flex-wrap gap-2">
{entry.filters.year && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
Jahr: {entry.filters.year}
</span>
)}
{entry.filters.subject && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
Fach: {entry.filters.subject}
</span>
)}
{entry.filters.incremental && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
Inkrementell
</span>
)}
</div>
)}
{entry.error && (
<p className="mt-2 text-sm text-red-700">{entry.error}</p>
)}
</div>
))}
</div>
)}
</div>
<IngestionHistory history={ingestionHistory} />
</div>
)
}
// ============================================================================
// Documents Tab
// ============================================================================
export { IngestionTab }

View File

@@ -2,7 +2,8 @@
* RAG Admin Page Components
*/
export { CollectionsTab, CollectionCard } from './CollectionsTab'
export { CollectionsTab } from './CollectionsTab'
export { CollectionCard } from './CollectionCard'
export { UploadTab } from './UploadTab'
export { IngestionTab } from './IngestionTab'
export { DocumentsTab } from './DocumentsTab'

View File

@@ -0,0 +1,92 @@
import type { Component } from './sbom-data'
import { getCategoryColor, getLicenseColor } from './sbom-data'
interface SBOMTableProps {
components: Component[]
loading: boolean
}
export function SBOMTable({ components, loading }: SBOMTableProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span className="ml-3 text-gray-600">Lade SBOM...</span>
</div>
)
}
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{components.map((component, idx) => {
const licenseId = component.license || component.licenses?.[0]?.license?.id
return (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-4">
<div>
<div className="text-sm font-medium text-gray-900">{component.name}</div>
{component.description && (
<div className="text-xs text-gray-500 max-w-xs truncate">{component.description}</div>
)}
</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">{component.version}</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
{component.category || component.type}
</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.port ? (
<span className="text-sm font-mono text-gray-600">{component.port}</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{licenseId ? (
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
{licenseId}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.sourceUrl && component.sourceUrl !== '-' ? (
<a href={component.sourceUrl} target="_blank" rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 text-sm flex items-center gap-1">
<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>
GitHub
</a>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
{components.length === 0 && (
<div className="p-8 text-center text-gray-500">Keine Komponenten gefunden.</div>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
export interface Component {
type: string
name: string
version: string
purl?: string
licenses?: { license: { id: string } }[]
category?: string
port?: string
description?: string
license?: string
sourceUrl?: string
}
export interface SBOMData {
bomFormat?: string
specVersion?: string
version?: number
metadata?: {
timestamp?: string
tools?: { vendor: string; name: string; version: string }[]
component?: { type: string; name: string; version: string }
}
components?: Component[]
}
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs'
// Infrastructure components from docker-compose.yml and project analysis
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== DATABASES =====
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
// ===== CACHE & QUEUE =====
{ type: 'service', name: 'Redis', version: 'alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
{ type: 'service', name: 'ERPNext Redis Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
{ type: 'service', name: 'ERPNext Redis Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
// ===== SEARCH ENGINES =====
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
// ===== OBJECT STORAGE =====
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
// ===== APPLICATION SERVICES (Python) =====
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API & Studio', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Go) =====
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Node.js) =====
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Vue) =====
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
// ===== AI/LLM SERVICES =====
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
// ===== ERP =====
{ 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' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
]
export const SECURITY_TOOLS: Component[] = [
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
]
export const PYTHON_PACKAGES: Component[] = [
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
]
export const GO_MODULES: Component[] = [
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
]
export const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
]
export const getCategoryColor = (category?: string) => {
switch (category) {
case 'database': return 'bg-blue-100 text-blue-800'
case 'security': return 'bg-purple-100 text-purple-800'
case 'security-tool': return 'bg-red-100 text-red-800'
case 'application': return 'bg-green-100 text-green-800'
case 'communication': return 'bg-yellow-100 text-yellow-800'
case 'storage': return 'bg-orange-100 text-orange-800'
case 'search': return 'bg-pink-100 text-pink-800'
case 'erp': return 'bg-indigo-100 text-indigo-800'
case 'cache': return 'bg-cyan-100 text-cyan-800'
case 'ai': return 'bg-violet-100 text-violet-800'
case 'development': return 'bg-gray-100 text-gray-800'
case 'python': return 'bg-emerald-100 text-emerald-800'
case 'go': return 'bg-sky-100 text-sky-800'
case 'nodejs': return 'bg-lime-100 text-lime-800'
default: return 'bg-slate-100 text-slate-800'
}
}
export const getLicenseColor = (license?: string) => {
if (!license) return 'bg-gray-100 text-gray-600'
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
return 'bg-gray-100 text-gray-600'
}

View File

@@ -2,250 +2,56 @@
/**
* SBOM (Software Bill of Materials) Admin Page
*
* Displays:
* - All infrastructure components (Docker services)
* - Python/Go dependencies
* - Node.js packages
* - License information
* - Version tracking
*/
import { useState, useEffect } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
interface Component {
type: string
name: string
version: string
purl?: string
licenses?: { license: { id: string } }[]
category?: string
port?: string
description?: string
license?: string
sourceUrl?: string
}
interface SBOMData {
bomFormat?: string
specVersion?: string
version?: number
metadata?: {
timestamp?: string
tools?: { vendor: string; name: string; version: string }[]
component?: { type: string; name: string; version: string }
}
components?: Component[]
}
type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs'
// Infrastructure components from docker-compose.yml and project analysis
const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== DATABASES =====
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
// ===== CACHE & QUEUE =====
{ type: 'service', name: 'Redis', version: 'alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
{ type: 'service', name: 'ERPNext Redis Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
{ type: 'service', name: 'ERPNext Redis Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
// ===== SEARCH ENGINES =====
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
// ===== OBJECT STORAGE =====
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
// ===== APPLICATION SERVICES (Python) =====
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API & Studio', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Go) =====
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Node.js) =====
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Vue) =====
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
// ===== AI/LLM SERVICES =====
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
// ===== ERP =====
{ 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' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
]
// Security Tools discovered in project
const SECURITY_TOOLS: Component[] = [
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
]
// Key Python packages (from requirements.txt)
const PYTHON_PACKAGES: Component[] = [
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
// Mail Module Dependencies
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
]
// Key Go modules (from go.mod files)
const GO_MODULES: Component[] = [
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
]
// Key Node.js packages (from package.json files)
const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
]
import {
Component,
SBOMData,
CategoryType,
INFRASTRUCTURE_COMPONENTS,
SECURITY_TOOLS,
PYTHON_PACKAGES,
GO_MODULES,
NODE_PACKAGES,
} from './_components/sbom-data'
import { SBOMTable } from './_components/SBOMTable'
export default function SBOMPage() {
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState<CategoryType>('all')
const [searchTerm, setSearchTerm] = useState('')
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
const loadSBOM = async () => {
setLoading(true)
try { const res = await fetch(`${BACKEND_URL}/api/v1/security/sbom`); if (res.ok) setSbomData(await res.json()) }
catch (error) { console.error('Failed to load SBOM:', error) }
finally { setLoading(false) }
}
loadSBOM()
}, [])
const loadSBOM = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/security/sbom`)
if (res.ok) {
const data = await res.json()
setSbomData(data)
}
} catch (error) {
console.error('Failed to load SBOM:', error)
} finally {
setLoading(false)
}
}
const getAllComponents = (): Component[] => {
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({
...c,
category: c.category || 'infrastructure'
}))
const securityToolsComponents = SECURITY_TOOLS.map(c => ({
...c,
category: c.category || 'security-tool'
}))
const pythonComponents = PYTHON_PACKAGES.map(c => ({
...c,
category: 'python'
}))
const goComponents = GO_MODULES.map(c => ({
...c,
category: 'go'
}))
const nodeComponents = NODE_PACKAGES.map(c => ({
...c,
category: 'nodejs'
}))
// Add dynamic SBOM data from backend if available
const dynamicPython = (sbomData?.components || []).map(c => ({
...c,
category: 'python'
}))
return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...dynamicPython]
}
const getFilteredComponents = () => {
let components = getAllComponents()
if (activeCategory !== 'all') {
if (activeCategory === 'infrastructure') {
components = INFRASTRUCTURE_COMPONENTS
} else if (activeCategory === 'security-tools') {
components = SECURITY_TOOLS
} else if (activeCategory === 'python') {
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
} else if (activeCategory === 'go') {
components = GO_MODULES
} else if (activeCategory === 'nodejs') {
components = NODE_PACKAGES
}
let components: Component[]
if (activeCategory === 'infrastructure') components = INFRASTRUCTURE_COMPONENTS
else if (activeCategory === 'security-tools') components = SECURITY_TOOLS
else if (activeCategory === 'python') components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
else if (activeCategory === 'go') components = GO_MODULES
else if (activeCategory === 'nodejs') components = NODE_PACKAGES
else {
components = [
...INFRASTRUCTURE_COMPONENTS.map(c => ({ ...c, category: c.category || 'infrastructure' })),
...SECURITY_TOOLS.map(c => ({ ...c, category: c.category || 'security-tool' })),
...PYTHON_PACKAGES.map(c => ({ ...c, category: 'python' })),
...GO_MODULES.map(c => ({ ...c, category: 'go' })),
...NODE_PACKAGES.map(c => ({ ...c, category: 'nodejs' })),
...(sbomData?.components || []).map(c => ({ ...c, category: 'python' })),
]
}
if (searchTerm) {
components = components.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -253,39 +59,9 @@ export default function SBOMPage() {
(c.description?.toLowerCase().includes(searchTerm.toLowerCase()))
)
}
return components
}
const getCategoryColor = (category?: string) => {
switch (category) {
case 'database': return 'bg-blue-100 text-blue-800'
case 'security': return 'bg-purple-100 text-purple-800'
case 'security-tool': return 'bg-red-100 text-red-800'
case 'application': return 'bg-green-100 text-green-800'
case 'communication': return 'bg-yellow-100 text-yellow-800'
case 'storage': return 'bg-orange-100 text-orange-800'
case 'search': return 'bg-pink-100 text-pink-800'
case 'erp': return 'bg-indigo-100 text-indigo-800'
case 'cache': return 'bg-cyan-100 text-cyan-800'
case 'ai': return 'bg-violet-100 text-violet-800'
case 'development': return 'bg-gray-100 text-gray-800'
case 'python': return 'bg-emerald-100 text-emerald-800'
case 'go': return 'bg-sky-100 text-sky-800'
case 'nodejs': return 'bg-lime-100 text-lime-800'
default: return 'bg-slate-100 text-slate-800'
}
}
const getLicenseColor = (license?: string) => {
if (!license) return 'bg-gray-100 text-gray-600'
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
return 'bg-gray-100 text-gray-600'
}
const stats = {
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
totalSecurityTools: SECURITY_TOOLS.length,
@@ -295,7 +71,6 @@ export default function SBOMPage() {
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + (sbomData?.components?.length || 0),
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
}
const categories = [
@@ -307,75 +82,41 @@ export default function SBOMPage() {
{ id: 'nodejs', name: 'Node.js', count: stats.totalNode },
]
const filteredComponents = getFilteredComponents()
return (
<AdminLayout title="SBOM" description="Software Bill of Materials - Alle Komponenten & Abhängigkeiten">
<AdminLayout title="SBOM" description="Software Bill of Materials - Alle Komponenten & Abhaengigkeiten">
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-slate-800">{stats.totalAll}</div>
<div className="text-sm text-slate-500">Komponenten Total</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-purple-600">{stats.totalInfra}</div>
<div className="text-sm text-slate-500">Docker Services</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-red-600">{stats.totalSecurityTools}</div>
<div className="text-sm text-slate-500">Security Tools</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-emerald-600">{stats.totalPython}</div>
<div className="text-sm text-slate-500">Python</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-sky-600">{stats.totalGo}</div>
<div className="text-sm text-slate-500">Go</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-lime-600">{stats.totalNode}</div>
<div className="text-sm text-slate-500">Node.js</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-blue-600">{stats.databases}</div>
<div className="text-sm text-slate-500">Datenbanken</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-green-600">{stats.services}</div>
<div className="text-sm text-slate-500">App Services</div>
</div>
{[
{ v: stats.totalAll, l: 'Komponenten Total', c: 'text-slate-800' },
{ v: stats.totalInfra, l: 'Docker Services', c: 'text-purple-600' },
{ v: stats.totalSecurityTools, l: 'Security Tools', c: 'text-red-600' },
{ v: stats.totalPython, l: 'Python', c: 'text-emerald-600' },
{ v: stats.totalGo, l: 'Go', c: 'text-sky-600' },
{ v: stats.totalNode, l: 'Node.js', c: 'text-lime-600' },
{ v: stats.databases, l: 'Datenbanken', c: 'text-blue-600' },
{ v: stats.services, l: 'App Services', c: 'text-green-600' },
].map((s, i) => (
<div key={i} className="bg-white rounded-lg shadow p-4">
<div className={`text-3xl font-bold ${s.c}`}>{s.v}</div>
<div className="text-sm text-slate-500">{s.l}</div>
</div>
))}
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
{/* Category Tabs */}
<div className="flex gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id as CategoryType)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.id
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<button key={cat.id} onClick={() => setActiveCategory(cat.id as CategoryType)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeCategory === cat.id ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
{cat.name} ({cat.count})
</button>
))}
</div>
{/* Search */}
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<input type="text" placeholder="Suchen..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" />
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
@@ -387,135 +128,24 @@ export default function SBOMPage() {
{sbomData?.metadata && (
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
<div className="flex flex-wrap gap-6">
<div>
<span className="text-slate-500">Format:</span>
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
</div>
<div>
<span className="text-slate-500">Generiert:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
</span>
</div>
<div>
<span className="text-slate-500">Anwendung:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
</span>
</div>
<div><span className="text-slate-500">Format:</span><span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span></div>
<div><span className="text-slate-500">Generiert:</span><span className="ml-2 font-medium">{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}</span></div>
<div><span className="text-slate-500">Anwendung:</span><span className="ml-2 font-medium">{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}</span></div>
</div>
</div>
)}
{/* Components Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span className="ml-3 text-gray-600">Lade SBOM...</span>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredComponents.map((component, idx) => {
// Get license from either the new license field or the old licenses array
const licenseId = component.license || component.licenses?.[0]?.license?.id
return (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-4">
<div>
<div className="text-sm font-medium text-gray-900">{component.name}</div>
{component.description && (
<div className="text-xs text-gray-500 max-w-xs truncate">{component.description}</div>
)}
</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">{component.version}</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
{component.category || component.type}
</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.port ? (
<span className="text-sm font-mono text-gray-600">{component.port}</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{licenseId ? (
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
{licenseId}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.sourceUrl && component.sourceUrl !== '-' ? (
<a
href={component.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 text-sm flex items-center gap-1"
>
<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>
GitHub
</a>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
{filteredComponents.length === 0 && (
<div className="p-8 text-center text-gray-500">
Keine Komponenten gefunden.
</div>
)}
</div>
)}
<SBOMTable components={getFilteredComponents()} loading={loading} />
{/* Export Button */}
<div className="mt-6 flex justify-end">
<button
onClick={() => {
const data = JSON.stringify({
...sbomData,
infrastructure: INFRASTRUCTURE_COMPONENTS
}, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`
a.click()
}}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<button onClick={() => {
const data = JSON.stringify({ ...sbomData, infrastructure: INFRASTRUCTURE_COMPONENTS }, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`; a.click()
}} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
SBOM exportieren (JSON)
</button>
</div>

View File

@@ -0,0 +1,100 @@
export function CategoryDemo({ stepId }: { stepId: string }) {
if (stepId === 'categories') {
const categories = [
{ name: 'infrastructure', color: 'blue', count: 45 },
{ name: 'security-tools', color: 'red', count: 12 },
{ name: 'python', color: 'yellow', count: 35 },
{ name: 'go', color: 'cyan', count: 18 },
{ name: 'nodejs', color: 'green', count: 55 },
{ name: 'unity', color: 'amber', count: 7, isNew: true },
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
{ name: 'game', color: 'rose', count: 1, isNew: true },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
<div className="grid grid-cols-4 gap-2">
{categories.map((cat) => (
<div
key={cat.name}
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
>
<p className="font-medium">{cat.name}</p>
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
{cat.isNew && (
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
)}
</div>
))}
</div>
</div>
)
}
if (stepId === 'unity-game') {
const unityComponents = [
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
<div className="space-y-2">
{unityComponents.map((comp) => (
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-2">
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
<span className="font-medium">{comp.name}</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
<span>{comp.version}</span>
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
</div>
</div>
))}
</div>
</div>
)
}
if (stepId === 'licenses') {
const licenses = [
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
<div className="space-y-2">
{licenses.map((lic) => (
<div key={lic.name} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="font-medium">{lic.name}</span>
<span className="text-sm text-slate-500">({lic.count})</span>
</div>
<span className={`text-xs px-2 py-1 rounded ${
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
'bg-orange-100 text-orange-700'
}`}>
Risiko: {lic.risk}
</span>
</div>
))}
</div>
</div>
)
}
return null
}

View File

@@ -0,0 +1,75 @@
import { WizardStep, EDUCATION_CONTENT } from './types'
export function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-primary-100 text-primary-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: 'text-slate-400 hover:bg-slate-100'
}`}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
)
}
export function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
<span className="mr-2">📖</span>
{content.title}
</h3>
<div className="space-y-2 text-primary-900">
{content.content.map((line, index) => (
<p
key={index}
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
dangerouslySetInnerHTML={{
__html: line
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/→/g, '<span class="text-primary-600">→</span>')
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
}}
/>
))}
</div>
{content.tips && content.tips.length > 0 && (
<div className="mt-4 pt-4 border-t border-primary-200">
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
{content.tips.map((tip, index) => (
<p key={index} className="text-sm text-primary-700 ml-4"> {tip}</p>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
export type StepStatus = 'pending' | 'active' | 'completed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
}
export const INITIAL_STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
]
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
'welcome': {
title: 'Willkommen zum SBOM-Wizard!',
content: [
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
'• Open-Source-Bibliotheken',
'• Frameworks und Engines',
'• Infrastruktur-Dienste',
'• Entwicklungs-Tools',
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
],
tips: ['SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht', 'Die EU plant aehnliche Vorschriften im Cyber Resilience Act'],
},
'what-is-sbom': {
title: 'Was ist eine SBOM?',
content: [
'**SBOM = Software Bill of Materials**',
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
'**Enthaltene Informationen:**',
'• Name der Komponente', '• Version', '• Lizenz (MIT, Apache, GPL, etc.)', '• Herkunft (Source URL)', '• Typ (Library, Service, Tool)',
'**Formate:**',
'• SPDX (Linux Foundation Standard)', '• CycloneDX (OWASP Standard)', '• SWID Tags (ISO Standard)',
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
],
tips: ['Eine SBOM ist wie ein Beipackzettel fuer Medikamente', 'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken'],
},
'why-important': {
title: 'Warum sind SBOMs wichtig?',
content: [
'**1. Sicherheit (Security)**',
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
'**2. Compliance (Lizenz-Einhaltung)**',
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
'• MIT: Fast keine Einschraenkungen', '• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein', '• Proprietary: Kommerzielle Nutzung eingeschraenkt',
'**3. Supply Chain Security**',
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
'**4. Regulatorische Anforderungen**',
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
],
tips: ['Log4Shell (2021) betraf Millionen von Systemen', 'Mit SBOM: Betroffenheit in Minuten geprueft'],
},
'categories': {
title: 'SBOM-Kategorien in BreakPilot',
content: [
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
'**infrastructure** (Blau)', '→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
'**security-tools** (Rot)', '→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
'**python** (Gelb)', '→ Python-Backend: FastAPI, Pydantic, httpx',
'**go** (Cyan)', '→ Go-Services: Gin, GORM, JWT',
'**nodejs** (Gruen)', '→ Frontend: Next.js, React, Tailwind',
'**unity** (Amber) ← NEU!', '→ Game Engine: Unity 6, URP, TextMeshPro',
'**csharp** (Fuchsia) ← NEU!', '→ C#/.NET: .NET Standard, UnityWebRequest',
'**game** (Rose) ← NEU!', '→ Breakpilot Drive Service',
],
tips: ['Klicke auf eine Kategorie um zu filtern', 'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt'],
},
'infrastructure': {
title: 'Infrastruktur-Komponenten',
content: [
'BreakPilot basiert auf robuster Infrastruktur:',
'**Datenbanken:**', '• PostgreSQL 16 - Relationale Datenbank', '• Valkey 8 - In-Memory Cache (Redis-Fork)', '• ChromaDB - Vector Store fuer RAG',
'**Auth & Security:**', '• Keycloak 23 - Identity & Access Management', '• HashiCorp Vault - Secrets Management',
'**Container & Orchestrierung:**', '• Docker - Container Runtime', '• Traefik - Reverse Proxy',
'**Kommunikation:**', '• Matrix Synapse - Chat/Messaging', '• Jitsi Meet - Video-Konferenzen',
],
tips: ['Alle Services laufen in Docker-Containern', 'Ports sind in docker-compose.yml definiert'],
},
'unity-game': {
title: 'Unity & Breakpilot Drive',
content: [
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
'**Unity Engine (6000.0)**', '→ Die Game Engine fuer das Lernspiel', '→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
'**Universal Render Pipeline (17.x)**', '→ Optimierte Grafik-Pipeline fuer WebGL', '→ Lizenz: Unity Companion License',
'**TextMeshPro (3.2)**', '→ Fortgeschrittenes Text-Rendering',
'**Unity Mathematics (1.3)**', '→ SIMD-optimierte Mathe-Bibliothek',
'**Newtonsoft.Json (3.2)**', '→ JSON-Serialisierung fuer API-Kommunikation',
'**C# Abhängigkeiten:**', '• .NET Standard 2.1', '• UnityWebRequest (HTTP Client)', '• System.Text.Json',
],
tips: ['Unity 6 ist die neueste LTS-Version', 'WebGL-Builds sind ~30-50 MB gross'],
},
'licenses': {
title: 'Lizenz-Compliance',
content: [
'**Lizenz-Typen in BreakPilot:**',
'**Permissive (Unkompliziert):**', '• MIT - Die meisten JS/Python Libs', '• Apache 2.0 - FastAPI, Keycloak', '• BSD - PostgreSQL',
'**Copyleft (Vorsicht bei Aenderungen):**', '• GPL - Wenige Komponenten', '• AGPL - Jitsi (Server-Side OK)',
'**Proprietary:**', '• Unity EULA - Kostenlos bis 100k Revenue', '• Unity Companion - Packages an Engine gebunden',
'**Wichtig:**', 'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
],
tips: ['MIT und Apache 2.0 sind am unproblematischsten', 'AGPL erfordert Source-Code-Freigabe bei Modifikation'],
},
'summary': {
title: 'Zusammenfassung',
content: [
'Du hast die SBOM von BreakPilot kennengelernt:',
'✅ Was eine SBOM ist und warum sie wichtig ist', '✅ Die verschiedenen Kategorien (8 Stueck)',
'✅ Infrastruktur-Komponenten', '✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive', '✅ Lizenz-Typen und Compliance',
'**Im SBOM-Dashboard kannst du:**', '• Nach Kategorie filtern', '• Nach Namen suchen', '• Lizenzen pruefen', '• Komponenten-Details ansehen',
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
],
tips: ['Pruefe regelmaessig auf veraltete Komponenten', 'Bei neuen Abhaengigkeiten: SBOM aktualisieren'],
},
}

View File

@@ -3,410 +3,18 @@
/**
* SBOM (Software Bill of Materials) - Lern-Wizard
*
* Interaktiver Wizard zum Verstehen der SBOM:
* - Was ist eine SBOM?
* - Warum ist sie wichtig?
* - Kategorien erklaert
* - Breakpilot Drive (Unity/C#/Game) Komponenten
* - Lizenzen und Compliance
* Interaktiver Wizard zum Verstehen der SBOM
*/
import { useState } from 'react'
import Link from 'next/link'
// ==============================================
// Types
// ==============================================
type StepStatus = 'pending' | 'active' | 'completed'
interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
}
// ==============================================
// Constants
// ==============================================
const STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
]
const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
'welcome': {
title: 'Willkommen zum SBOM-Wizard!',
content: [
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
'• Open-Source-Bibliotheken',
'• Frameworks und Engines',
'• Infrastruktur-Dienste',
'• Entwicklungs-Tools',
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
],
tips: [
'SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht',
'Die EU plant aehnliche Vorschriften im Cyber Resilience Act',
],
},
'what-is-sbom': {
title: 'Was ist eine SBOM?',
content: [
'**SBOM = Software Bill of Materials**',
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
'**Enthaltene Informationen:**',
'• Name der Komponente',
'• Version',
'• Lizenz (MIT, Apache, GPL, etc.)',
'• Herkunft (Source URL)',
'• Typ (Library, Service, Tool)',
'**Formate:**',
'• SPDX (Linux Foundation Standard)',
'• CycloneDX (OWASP Standard)',
'• SWID Tags (ISO Standard)',
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
],
tips: [
'Eine SBOM ist wie ein Beipackzettel fuer Medikamente',
'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken',
],
},
'why-important': {
title: 'Warum sind SBOMs wichtig?',
content: [
'**1. Sicherheit (Security)**',
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
'**2. Compliance (Lizenz-Einhaltung)**',
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
'• MIT: Fast keine Einschraenkungen',
'• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein',
'• Proprietary: Kommerzielle Nutzung eingeschraenkt',
'**3. Supply Chain Security**',
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
'**4. Regulatorische Anforderungen**',
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
],
tips: [
'Log4Shell (2021) betraf Millionen von Systemen',
'Mit SBOM: Betroffenheit in Minuten geprueft',
],
},
'categories': {
title: 'SBOM-Kategorien in BreakPilot',
content: [
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
'**infrastructure** (Blau)',
'→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
'**security-tools** (Rot)',
'→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
'**python** (Gelb)',
'→ Python-Backend: FastAPI, Pydantic, httpx',
'**go** (Cyan)',
'→ Go-Services: Gin, GORM, JWT',
'**nodejs** (Gruen)',
'→ Frontend: Next.js, React, Tailwind',
'**unity** (Amber) ← NEU!',
'→ Game Engine: Unity 6, URP, TextMeshPro',
'**csharp** (Fuchsia) ← NEU!',
'→ C#/.NET: .NET Standard, UnityWebRequest',
'**game** (Rose) ← NEU!',
'→ Breakpilot Drive Service',
],
tips: [
'Klicke auf eine Kategorie um zu filtern',
'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt',
],
},
'infrastructure': {
title: 'Infrastruktur-Komponenten',
content: [
'BreakPilot basiert auf robuster Infrastruktur:',
'**Datenbanken:**',
'• PostgreSQL 16 - Relationale Datenbank',
'• Valkey 8 - In-Memory Cache (Redis-Fork)',
'• ChromaDB - Vector Store fuer RAG',
'**Auth & Security:**',
'• Keycloak 23 - Identity & Access Management',
'• HashiCorp Vault - Secrets Management',
'**Container & Orchestrierung:**',
'• Docker - Container Runtime',
'• Traefik - Reverse Proxy',
'**Kommunikation:**',
'• Matrix Synapse - Chat/Messaging',
'• Jitsi Meet - Video-Konferenzen',
],
tips: [
'Alle Services laufen in Docker-Containern',
'Ports sind in docker-compose.yml definiert',
],
},
'unity-game': {
title: 'Unity & Breakpilot Drive',
content: [
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
'**Unity Engine (6000.0)**',
'→ Die Game Engine fuer das Lernspiel',
'→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
'**Universal Render Pipeline (17.x)**',
'→ Optimierte Grafik-Pipeline fuer WebGL',
'→ Lizenz: Unity Companion License',
'**TextMeshPro (3.2)**',
'→ Fortgeschrittenes Text-Rendering',
'**Unity Mathematics (1.3)**',
'→ SIMD-optimierte Mathe-Bibliothek',
'**Newtonsoft.Json (3.2)**',
'→ JSON-Serialisierung fuer API-Kommunikation',
'**C# Abhängigkeiten:**',
'• .NET Standard 2.1',
'• UnityWebRequest (HTTP Client)',
'• System.Text.Json',
],
tips: [
'Unity 6 ist die neueste LTS-Version',
'WebGL-Builds sind ~30-50 MB gross',
],
},
'licenses': {
title: 'Lizenz-Compliance',
content: [
'**Lizenz-Typen in BreakPilot:**',
'**Permissive (Unkompliziert):**',
'• MIT - Die meisten JS/Python Libs',
'• Apache 2.0 - FastAPI, Keycloak',
'• BSD - PostgreSQL',
'**Copyleft (Vorsicht bei Aenderungen):**',
'• GPL - Wenige Komponenten',
'• AGPL - Jitsi (Server-Side OK)',
'**Proprietary:**',
'• Unity EULA - Kostenlos bis 100k Revenue',
'• Unity Companion - Packages an Engine gebunden',
'**Wichtig:**',
'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
],
tips: [
'MIT und Apache 2.0 sind am unproblematischsten',
'AGPL erfordert Source-Code-Freigabe bei Modifikation',
],
},
'summary': {
title: 'Zusammenfassung',
content: [
'Du hast die SBOM von BreakPilot kennengelernt:',
'✅ Was eine SBOM ist und warum sie wichtig ist',
'✅ Die verschiedenen Kategorien (8 Stueck)',
'✅ Infrastruktur-Komponenten',
'✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive',
'✅ Lizenz-Typen und Compliance',
'**Im SBOM-Dashboard kannst du:**',
'• Nach Kategorie filtern',
'• Nach Namen suchen',
'• Lizenzen pruefen',
'• Komponenten-Details ansehen',
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
],
tips: [
'Pruefe regelmaessig auf veraltete Komponenten',
'Bei neuen Abhaengigkeiten: SBOM aktualisieren',
],
},
}
// ==============================================
// Components
// ==============================================
function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-primary-100 text-primary-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: 'text-slate-400 hover:bg-slate-100'
}`}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
)
}
function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
<span className="mr-2">📖</span>
{content.title}
</h3>
<div className="space-y-2 text-primary-900">
{content.content.map((line, index) => (
<p
key={index}
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
dangerouslySetInnerHTML={{
__html: line
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/→/g, '<span class="text-primary-600">→</span>')
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
}}
/>
))}
</div>
{content.tips && content.tips.length > 0 && (
<div className="mt-4 pt-4 border-t border-primary-200">
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
{content.tips.map((tip, index) => (
<p key={index} className="text-sm text-primary-700 ml-4"> {tip}</p>
))}
</div>
)}
</div>
)
}
function CategoryDemo({ stepId }: { stepId: string }) {
if (stepId === 'categories') {
const categories = [
{ name: 'infrastructure', color: 'blue', count: 45 },
{ name: 'security-tools', color: 'red', count: 12 },
{ name: 'python', color: 'yellow', count: 35 },
{ name: 'go', color: 'cyan', count: 18 },
{ name: 'nodejs', color: 'green', count: 55 },
{ name: 'unity', color: 'amber', count: 7, isNew: true },
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
{ name: 'game', color: 'rose', count: 1, isNew: true },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
<div className="grid grid-cols-4 gap-2">
{categories.map((cat) => (
<div
key={cat.name}
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
>
<p className="font-medium">{cat.name}</p>
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
{cat.isNew && (
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
)}
</div>
))}
</div>
</div>
)
}
if (stepId === 'unity-game') {
const unityComponents = [
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
<div className="space-y-2">
{unityComponents.map((comp) => (
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-2">
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
<span className="font-medium">{comp.name}</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
<span>{comp.version}</span>
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
</div>
</div>
))}
</div>
</div>
)
}
if (stepId === 'licenses') {
const licenses = [
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
]
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
<div className="space-y-2">
{licenses.map((lic) => (
<div key={lic.name} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="font-medium">{lic.name}</span>
<span className="text-sm text-slate-500">({lic.count})</span>
</div>
<span className={`text-xs px-2 py-1 rounded ${
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
'bg-orange-100 text-orange-700'
}`}>
Risiko: {lic.risk}
</span>
</div>
))}
</div>
</div>
)
}
return null
}
// ==============================================
// Main Component
// ==============================================
import { INITIAL_STEPS, WizardStep } from './_components/types'
import { WizardStepper, EducationCard } from './_components/WizardComponents'
import { CategoryDemo } from './_components/CategoryDemo'
export default function SBOMWizardPage() {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
const currentStepData = steps[currentStep]
const isWelcome = currentStepData?.id === 'welcome'
@@ -509,7 +117,7 @@ export default function SBOMWizardPage() {
<button
onClick={() => {
setCurrentStep(0)
setSteps(STEPS.map(s => ({ ...s, status: 'pending' })))
setSteps(INITIAL_STEPS.map(s => ({ ...s, status: 'pending' })))
}}
className="px-6 py-3 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 transition-colors"
>

View File

@@ -0,0 +1,134 @@
import { StaffMember, Publication } from './types'
export function StaffDetailPanel({
selectedStaff,
publications,
}: {
selectedStaff: StaffMember | null
publications: Publication[]
}) {
if (!selectedStaff) {
return (
<div className="p-8 text-center text-slate-400">
Wahlen Sie eine Person aus der Liste
</div>
)
}
return (
<div>
{/* Header */}
<div className="p-4 border-b">
<div className="flex items-start gap-4">
{selectedStaff.photo_url ? (
<img
src={selectedStaff.photo_url}
alt={selectedStaff.full_name || selectedStaff.last_name}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 text-xl font-medium">
{(selectedStaff.first_name?.[0] || '') + (selectedStaff.last_name?.[0] || '')}
</div>
)}
<div>
<h3 className="font-semibold text-lg">
{selectedStaff.title && `${selectedStaff.title} `}
{selectedStaff.full_name || `${selectedStaff.first_name || ''} ${selectedStaff.last_name}`}
</h3>
<p className="text-sm text-slate-500">{selectedStaff.position}</p>
<p className="text-sm text-slate-400">{selectedStaff.university_name}</p>
</div>
</div>
</div>
{/* Contact */}
<div className="p-4 border-b space-y-2">
{selectedStaff.email && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<a href={`mailto:${selectedStaff.email}`} className="text-primary-600 hover:underline">
{selectedStaff.email}
</a>
</div>
)}
{selectedStaff.profile_url && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<a href={selectedStaff.profile_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline truncate">
Profil
</a>
</div>
)}
{selectedStaff.orcid && (
<div className="flex items-center gap-2 text-sm">
<span className="w-4 h-4 text-green-600 font-bold text-xs">ID</span>
<a href={`https://orcid.org/${selectedStaff.orcid}`} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
ORCID: {selectedStaff.orcid}
</a>
</div>
)}
</div>
{/* Research Interests */}
{selectedStaff.research_interests && selectedStaff.research_interests.length > 0 && (
<div className="p-4 border-b">
<h4 className="text-sm font-medium text-slate-700 mb-2">Forschungsgebiete</h4>
<div className="flex flex-wrap gap-1">
{selectedStaff.research_interests.map((interest, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
{interest}
</span>
))}
</div>
</div>
)}
{/* Publications */}
<div className="p-4">
<h4 className="text-sm font-medium text-slate-700 mb-2">
Publikationen ({publications.length})
</h4>
{publications.length > 0 ? (
<div className="space-y-3 max-h-64 overflow-y-auto">
{publications.map((pub) => (
<div key={pub.id} className="text-sm">
<p className="font-medium text-slate-800 line-clamp-2">{pub.title}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
{pub.year && <span>{pub.year}</span>}
{pub.venue && <span>| {pub.venue}</span>}
{pub.citation_count > 0 && (
<span className="text-green-600">{pub.citation_count} Zitierungen</span>
)}
</div>
{pub.doi && (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-600 hover:underline"
>
DOI
</a>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-slate-400">Keine Publikationen gefunden</p>
)}
</div>
{/* Actions */}
<div className="p-4 border-t bg-slate-50">
<button className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm">
Als Kunde markieren
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { StaffMember } from './types'
import { getPositionBadgeColor } from './helpers'
export function StaffListItem({
member,
isSelected,
onClick,
}: {
member: StaffMember
isSelected: boolean
onClick: () => void
}) {
return (
<div
onClick={onClick}
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
isSelected ? 'bg-primary-50' : ''
}`}
>
<div className="flex items-start gap-4">
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.full_name || member.last_name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-medium">
{(member.first_name?.[0] || '') + (member.last_name?.[0] || '')}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">
{member.title && `${member.title} `}
{member.full_name || `${member.first_name || ''} ${member.last_name}`}
</span>
{member.is_professor && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
Prof
</span>
)}
</div>
<div className="text-sm text-slate-500 truncate">
{member.position || member.position_type}
{member.department_name && ` - ${member.department_name}`}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-400">
{member.university_short || member.university_name}
</span>
{member.publication_count > 0 && (
<span className="text-xs text-green-600">
{member.publication_count} Publikationen
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
{member.position_type && (
<span className={`px-2 py-0.5 text-xs rounded-full ${getPositionBadgeColor(member.position_type)}`}>
{member.position_type}
</span>
)}
{member.email && (
<a
href={`mailto:${member.email}`}
onClick={(e) => e.stopPropagation()}
className="text-xs text-primary-600 hover:underline"
>
E-Mail
</a>
)}
</div>
</div>
{member.research_interests && member.research_interests.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{member.research_interests.slice(0, 5).map((interest, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 rounded-full">
{interest}
</span>
))}
{member.research_interests.length > 5 && (
<span className="px-2 py-0.5 text-xs text-slate-400">
+{member.research_interests.length - 5} mehr
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
export function getPositionBadgeColor(posType?: string) {
switch (posType) {
case 'professor':
return 'bg-purple-100 text-purple-800'
case 'postdoc':
return 'bg-blue-100 text-blue-800'
case 'researcher':
return 'bg-green-100 text-green-800'
case 'phd_student':
return 'bg-yellow-100 text-yellow-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
export function getStateBadgeColor(state?: string) {
const colors: Record<string, string> = {
BW: 'bg-yellow-100 text-yellow-800',
BY: 'bg-blue-100 text-blue-800',
BE: 'bg-red-100 text-red-800',
NW: 'bg-green-100 text-green-800',
HE: 'bg-orange-100 text-orange-800',
SN: 'bg-purple-100 text-purple-800',
}
return colors[state || ''] || 'bg-gray-100 text-gray-800'
}

View File

@@ -0,0 +1,52 @@
export interface StaffMember {
id: string
first_name?: string
last_name: string
full_name?: string
title?: string
position?: string
position_type?: string
is_professor: boolean
email?: string
profile_url?: string
photo_url?: string
orcid?: string
research_interests?: string[]
university_name?: string
university_short?: string
department_name?: string
publication_count: number
}
export interface Publication {
id: string
title: string
abstract?: string
year?: number
pub_type?: string
venue?: string
doi?: string
url?: string
citation_count: number
}
export interface StaffStats {
total_staff: number
total_professors: number
total_publications: number
total_universities: number
by_state?: Record<string, number>
by_uni_type?: Record<string, number>
by_position_type?: Record<string, number>
}
export interface University {
id: string
name: string
short_name?: string
url: string
state?: string
uni_type?: string
}
export const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'

View File

@@ -0,0 +1,120 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { StaffMember, Publication, StaffStats, University, EDU_SEARCH_API } from './types'
export function useStaffSearch() {
const [searchQuery, setSearchQuery] = useState('')
const [staff, setStaff] = useState<StaffMember[]>([])
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null)
const [publications, setPublications] = useState<Publication[]>([])
const [stats, setStats] = useState<StaffStats | null>(null)
const [universities, setUniversities] = useState<University[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterState, setFilterState] = useState('')
const [filterUniType, setFilterUniType] = useState('')
const [filterPositionType, setFilterPositionType] = useState('')
const [filterProfessorsOnly, setFilterProfessorsOnly] = useState(false)
// Pagination
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const limit = 20
useEffect(() => {
fetchStats()
fetchUniversities()
}, [])
const fetchStats = async () => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/stats`)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch {
// Stats not critical
}
}
const fetchUniversities = async () => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/universities`)
if (res.ok) {
const data = await res.json()
setUniversities(data.universities || [])
}
} catch {
// Universities not critical
}
}
const searchStaff = useCallback(async (newOffset = 0) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (searchQuery) params.append('q', searchQuery)
if (filterState) params.append('state', filterState)
if (filterUniType) params.append('uni_type', filterUniType)
if (filterPositionType) params.append('position_type', filterPositionType)
if (filterProfessorsOnly) params.append('is_professor', 'true')
params.append('limit', limit.toString())
params.append('offset', newOffset.toString())
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/search?${params}`)
if (!res.ok) throw new Error('Search failed')
const data = await res.json()
setStaff(data.staff || [])
setTotal(data.total || 0)
setOffset(newOffset)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
setStaff([])
} finally {
setLoading(false)
}
}, [searchQuery, filterState, filterUniType, filterPositionType, filterProfessorsOnly])
useEffect(() => {
const timer = setTimeout(() => {
searchStaff(0)
}, 300)
return () => clearTimeout(timer)
}, [searchStaff])
const fetchPublications = async (staffId: string) => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/${staffId}/publications`)
if (res.ok) {
const data = await res.json()
setPublications(data.publications || [])
}
} catch {
setPublications([])
}
}
const handleSelectStaff = (member: StaffMember) => {
setSelectedStaff(member)
fetchPublications(member.id)
}
return {
searchQuery, setSearchQuery,
staff, selectedStaff, publications, stats,
loading, error,
filterState, setFilterState,
filterUniType, setFilterUniType,
filterPositionType, setFilterPositionType,
filterProfessorsOnly, setFilterProfessorsOnly,
total, offset, limit,
searchStaff, handleSelectStaff,
}
}

View File

@@ -7,193 +7,23 @@
* Potential customers for BreakPilot services.
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
interface StaffMember {
id: string
first_name?: string
last_name: string
full_name?: string
title?: string
position?: string
position_type?: string
is_professor: boolean
email?: string
profile_url?: string
photo_url?: string
orcid?: string
research_interests?: string[]
university_name?: string
university_short?: string
department_name?: string
publication_count: number
}
interface Publication {
id: string
title: string
abstract?: string
year?: number
pub_type?: string
venue?: string
doi?: string
url?: string
citation_count: number
}
interface StaffStats {
total_staff: number
total_professors: number
total_publications: number
total_universities: number
by_state?: Record<string, number>
by_uni_type?: Record<string, number>
by_position_type?: Record<string, number>
}
interface University {
id: string
name: string
short_name?: string
url: string
state?: string
uni_type?: string
}
const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'
import { useStaffSearch } from './_components/useStaffSearch'
import { StaffListItem } from './_components/StaffListItem'
import { StaffDetailPanel } from './_components/StaffDetailPanel'
export default function StaffSearchPage() {
const [searchQuery, setSearchQuery] = useState('')
const [staff, setStaff] = useState<StaffMember[]>([])
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null)
const [publications, setPublications] = useState<Publication[]>([])
const [stats, setStats] = useState<StaffStats | null>(null)
const [universities, setUniversities] = useState<University[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterState, setFilterState] = useState('')
const [filterUniType, setFilterUniType] = useState('')
const [filterPositionType, setFilterPositionType] = useState('')
const [filterProfessorsOnly, setFilterProfessorsOnly] = useState(false)
// Pagination
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const limit = 20
// Fetch stats on mount
useEffect(() => {
fetchStats()
fetchUniversities()
}, [])
const fetchStats = async () => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/stats`)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch {
// Stats not critical
}
}
const fetchUniversities = async () => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/universities`)
if (res.ok) {
const data = await res.json()
setUniversities(data.universities || [])
}
} catch {
// Universities not critical
}
}
const searchStaff = useCallback(async (newOffset = 0) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (searchQuery) params.append('q', searchQuery)
if (filterState) params.append('state', filterState)
if (filterUniType) params.append('uni_type', filterUniType)
if (filterPositionType) params.append('position_type', filterPositionType)
if (filterProfessorsOnly) params.append('is_professor', 'true')
params.append('limit', limit.toString())
params.append('offset', newOffset.toString())
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/search?${params}`)
if (!res.ok) throw new Error('Search failed')
const data = await res.json()
setStaff(data.staff || [])
setTotal(data.total || 0)
setOffset(newOffset)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
setStaff([])
} finally {
setLoading(false)
}
}, [searchQuery, filterState, filterUniType, filterPositionType, filterProfessorsOnly])
// Search on filter change
useEffect(() => {
const timer = setTimeout(() => {
searchStaff(0)
}, 300)
return () => clearTimeout(timer)
}, [searchStaff])
const fetchPublications = async (staffId: string) => {
try {
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/${staffId}/publications`)
if (res.ok) {
const data = await res.json()
setPublications(data.publications || [])
}
} catch {
setPublications([])
}
}
const handleSelectStaff = (member: StaffMember) => {
setSelectedStaff(member)
fetchPublications(member.id)
}
const getPositionBadgeColor = (posType?: string) => {
switch (posType) {
case 'professor':
return 'bg-purple-100 text-purple-800'
case 'postdoc':
return 'bg-blue-100 text-blue-800'
case 'researcher':
return 'bg-green-100 text-green-800'
case 'phd_student':
return 'bg-yellow-100 text-yellow-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getStateBadgeColor = (state?: string) => {
const colors: Record<string, string> = {
BW: 'bg-yellow-100 text-yellow-800',
BY: 'bg-blue-100 text-blue-800',
BE: 'bg-red-100 text-red-800',
NW: 'bg-green-100 text-green-800',
HE: 'bg-orange-100 text-orange-800',
SN: 'bg-purple-100 text-purple-800',
}
return colors[state || ''] || 'bg-gray-100 text-gray-800'
}
const {
searchQuery, setSearchQuery,
staff, selectedStaff, publications, stats,
loading, error,
filterState, setFilterState,
filterUniType, setFilterUniType,
filterPositionType, setFilterPositionType,
filterProfessorsOnly, setFilterProfessorsOnly,
total, offset, limit,
searchStaff, handleSelectStaff,
} = useStaffSearch()
return (
<AdminLayout
@@ -248,11 +78,7 @@ export default function StaffSearchPage() {
{/* Filters */}
<div className="flex flex-wrap gap-4">
<select
value={filterState}
onChange={(e) => setFilterState(e.target.value)}
className="px-3 py-1.5 border rounded-lg text-sm"
>
<select value={filterState} onChange={(e) => setFilterState(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
<option value="">Alle Bundeslander</option>
<option value="BW">Baden-Wurttemberg</option>
<option value="BY">Bayern</option>
@@ -271,12 +97,7 @@ export default function StaffSearchPage() {
<option value="SH">Schleswig-Holstein</option>
<option value="TH">Thuringen</option>
</select>
<select
value={filterUniType}
onChange={(e) => setFilterUniType(e.target.value)}
className="px-3 py-1.5 border rounded-lg text-sm"
>
<select value={filterUniType} onChange={(e) => setFilterUniType(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
<option value="">Alle Hochschultypen</option>
<option value="UNI">Universitaten</option>
<option value="FH">Fachhochschulen</option>
@@ -284,12 +105,7 @@ export default function StaffSearchPage() {
<option value="KUNST">Kunsthochschulen</option>
<option value="PRIVATE">Private</option>
</select>
<select
value={filterPositionType}
onChange={(e) => setFilterPositionType(e.target.value)}
className="px-3 py-1.5 border rounded-lg text-sm"
>
<select value={filterPositionType} onChange={(e) => setFilterPositionType(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
<option value="">Alle Positionen</option>
<option value="professor">Professoren</option>
<option value="postdoc">Postdocs</option>
@@ -297,14 +113,8 @@ export default function StaffSearchPage() {
<option value="phd_student">Doktoranden</option>
<option value="staff">Sonstige</option>
</select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filterProfessorsOnly}
onChange={(e) => setFilterProfessorsOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<input type="checkbox" checked={filterProfessorsOnly} onChange={(e) => setFilterProfessorsOnly(e.target.checked)} className="rounded border-gray-300" />
Nur Professoren
</label>
</div>
@@ -312,9 +122,7 @@ export default function StaffSearchPage() {
{/* Error */}
{error && (
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">
{error}
</div>
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">{error}</div>
)}
{/* Results */}
@@ -325,114 +133,21 @@ export default function StaffSearchPage() {
</span>
{total > limit && (
<div className="flex gap-2">
<button
onClick={() => searchStaff(Math.max(0, offset - limit))}
disabled={offset === 0 || loading}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
>
Zuruck
</button>
<span className="px-3 py-1 text-sm text-gray-500">
{Math.floor(offset / limit) + 1} / {Math.ceil(total / limit)}
</span>
<button
onClick={() => searchStaff(offset + limit)}
disabled={offset + limit >= total || loading}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
>
Weiter
</button>
<button onClick={() => searchStaff(Math.max(0, offset - limit))} disabled={offset === 0 || loading} className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50">Zuruck</button>
<span className="px-3 py-1 text-sm text-gray-500">{Math.floor(offset / limit) + 1} / {Math.ceil(total / limit)}</span>
<button onClick={() => searchStaff(offset + limit)} disabled={offset + limit >= total || loading} className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50">Weiter</button>
</div>
)}
</div>
<div className="divide-y">
{staff.map((member) => (
<div
<StaffListItem
key={member.id}
member={member}
isSelected={selectedStaff?.id === member.id}
onClick={() => handleSelectStaff(member)}
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
selectedStaff?.id === member.id ? 'bg-primary-50' : ''
}`}
>
<div className="flex items-start gap-4">
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.full_name || member.last_name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-medium">
{(member.first_name?.[0] || '') + (member.last_name?.[0] || '')}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">
{member.title && `${member.title} `}
{member.full_name || `${member.first_name || ''} ${member.last_name}`}
</span>
{member.is_professor && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
Prof
</span>
)}
</div>
<div className="text-sm text-slate-500 truncate">
{member.position || member.position_type}
{member.department_name && ` - ${member.department_name}`}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-400">
{member.university_short || member.university_name}
</span>
{member.publication_count > 0 && (
<span className="text-xs text-green-600">
{member.publication_count} Publikationen
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
{member.position_type && (
<span className={`px-2 py-0.5 text-xs rounded-full ${getPositionBadgeColor(member.position_type)}`}>
{member.position_type}
</span>
)}
{member.email && (
<a
href={`mailto:${member.email}`}
onClick={(e) => e.stopPropagation()}
className="text-xs text-primary-600 hover:underline"
>
E-Mail
</a>
)}
</div>
</div>
{member.research_interests && member.research_interests.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{member.research_interests.slice(0, 5).map((interest, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 rounded-full">
{interest}
</span>
))}
{member.research_interests.length > 5 && (
<span className="px-2 py-0.5 text-xs text-slate-400">
+{member.research_interests.length - 5} mehr
</span>
)}
</div>
)}
</div>
/>
))}
{staff.length === 0 && !loading && (
<div className="p-8 text-center text-slate-500">
{searchQuery || filterState || filterUniType || filterPositionType
@@ -440,11 +155,8 @@ export default function StaffSearchPage() {
: 'Geben Sie einen Suchbegriff ein oder wahlen Sie Filter'}
</div>
)}
{loading && (
<div className="p-8 text-center text-slate-500">
Suche lauft...
</div>
<div className="p-8 text-center text-slate-500">Suche lauft...</div>
)}
</div>
</div>
@@ -453,126 +165,7 @@ export default function StaffSearchPage() {
{/* Right Panel: Detail View */}
<div className="w-96 shrink-0">
<div className="bg-white rounded-lg shadow-sm border sticky top-20">
{selectedStaff ? (
<div>
{/* Header */}
<div className="p-4 border-b">
<div className="flex items-start gap-4">
{selectedStaff.photo_url ? (
<img
src={selectedStaff.photo_url}
alt={selectedStaff.full_name || selectedStaff.last_name}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 text-xl font-medium">
{(selectedStaff.first_name?.[0] || '') + (selectedStaff.last_name?.[0] || '')}
</div>
)}
<div>
<h3 className="font-semibold text-lg">
{selectedStaff.title && `${selectedStaff.title} `}
{selectedStaff.full_name || `${selectedStaff.first_name || ''} ${selectedStaff.last_name}`}
</h3>
<p className="text-sm text-slate-500">{selectedStaff.position}</p>
<p className="text-sm text-slate-400">{selectedStaff.university_name}</p>
</div>
</div>
</div>
{/* Contact */}
<div className="p-4 border-b space-y-2">
{selectedStaff.email && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<a href={`mailto:${selectedStaff.email}`} className="text-primary-600 hover:underline">
{selectedStaff.email}
</a>
</div>
)}
{selectedStaff.profile_url && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<a href={selectedStaff.profile_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline truncate">
Profil
</a>
</div>
)}
{selectedStaff.orcid && (
<div className="flex items-center gap-2 text-sm">
<span className="w-4 h-4 text-green-600 font-bold text-xs">ID</span>
<a href={`https://orcid.org/${selectedStaff.orcid}`} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
ORCID: {selectedStaff.orcid}
</a>
</div>
)}
</div>
{/* Research Interests */}
{selectedStaff.research_interests && selectedStaff.research_interests.length > 0 && (
<div className="p-4 border-b">
<h4 className="text-sm font-medium text-slate-700 mb-2">Forschungsgebiete</h4>
<div className="flex flex-wrap gap-1">
{selectedStaff.research_interests.map((interest, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
{interest}
</span>
))}
</div>
</div>
)}
{/* Publications */}
<div className="p-4">
<h4 className="text-sm font-medium text-slate-700 mb-2">
Publikationen ({publications.length})
</h4>
{publications.length > 0 ? (
<div className="space-y-3 max-h-64 overflow-y-auto">
{publications.map((pub) => (
<div key={pub.id} className="text-sm">
<p className="font-medium text-slate-800 line-clamp-2">{pub.title}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
{pub.year && <span>{pub.year}</span>}
{pub.venue && <span>| {pub.venue}</span>}
{pub.citation_count > 0 && (
<span className="text-green-600">{pub.citation_count} Zitierungen</span>
)}
</div>
{pub.doi && (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-600 hover:underline"
>
DOI
</a>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-slate-400">Keine Publikationen gefunden</p>
)}
</div>
{/* Actions */}
<div className="p-4 border-t bg-slate-50">
<button className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm">
Als Kunde markieren
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-slate-400">
Wahlen Sie eine Person aus der Liste
</div>
)}
<StaffDetailPanel selectedStaff={selectedStaff} publications={publications} />
</div>
</div>
</div>

View File

@@ -0,0 +1,89 @@
import type { CrawlQueueItem } from './types'
import { phaseConfig } from './types'
interface QueueListProps {
queue: CrawlQueueItem[]
onPause: (universityId: string) => void
onResume: (universityId: string) => void
onRemove: (universityId: string) => void
}
export function QueueList({ queue, onPause, onResume, onRemove }: QueueListProps) {
return (
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">Crawl Queue ({queue.length})</h2>
</div>
{queue.length === 0 ? (
<div className="px-6 py-12 text-center text-gray-500">
Queue ist leer. Fuege Universitaeten hinzu, um das Crawling zu starten.
</div>
) : (
<div className="divide-y divide-gray-100">
{queue.map((item) => (
<div key={item.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-400">#{item.queue_position || '-'}</span>
<h3 className="font-medium text-gray-900">{item.university_name}</h3>
<span className="text-sm text-gray-500">({item.university_short})</span>
</div>
{/* Phase Badge */}
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[item.current_phase].color}`}>
{phaseConfig[item.current_phase].label}
</span>
<span className="text-xs text-gray-500">Prioritaet: {item.priority}</span>
{item.retry_count > 0 && (
<span className="text-xs text-orange-600">Versuch {item.retry_count}/{item.max_retries}</span>
)}
</div>
{/* Progress Bar */}
<div className="mt-3">
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 transition-all duration-500" style={{ width: `${item.progress_percent}%` }} />
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{item.progress_percent}%</span>
<span>{item.discovery_count} Disc / {item.professors_count} Prof / {item.staff_count} Staff / {item.publications_count} Pub</span>
</div>
</div>
{/* Phase Checkmarks */}
<div className="flex gap-4 mt-3 text-xs">
<span className={item.discovery_completed ? 'text-green-600' : 'text-gray-400'}>Discovery</span>
<span className={item.professors_completed ? 'text-green-600' : 'text-gray-400'}>Professoren</span>
<span className={item.all_staff_completed ? 'text-green-600' : 'text-gray-400'}>Mitarbeiter</span>
<span className={item.publications_completed ? 'text-green-600' : 'text-gray-400'}>Publikationen</span>
</div>
{/* Error Message */}
{item.last_error && (
<p className="mt-2 text-xs text-red-600 bg-red-50 px-2 py-1 rounded">{item.last_error}</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
{item.current_phase === 'paused' ? (
<button onClick={() => onResume(item.university_id)} className="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors">Fortsetzen</button>
) : item.current_phase !== 'completed' && item.current_phase !== 'failed' ? (
<button onClick={() => onPause(item.university_id)} className="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors">Pausieren</button>
) : null}
<button onClick={() => onRemove(item.university_id)} className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors">Entfernen</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
export interface CrawlQueueItem {
id: string
university_id: string
university_name: string
university_short: string
queue_position: number | null
priority: number
current_phase: CrawlPhase
discovery_completed: boolean
discovery_completed_at?: string
professors_completed: boolean
professors_completed_at?: string
all_staff_completed: boolean
all_staff_completed_at?: string
publications_completed: boolean
publications_completed_at?: string
discovery_count: number
professors_count: number
staff_count: number
publications_count: number
retry_count: number
max_retries: number
last_error?: string
started_at?: string
completed_at?: string
progress_percent: number
created_at: string
updated_at: string
}
export type CrawlPhase = 'pending' | 'discovery' | 'professors' | 'all_staff' | 'publications' | 'completed' | 'failed' | 'paused'
export interface OrchestratorStatus {
is_running: boolean
current_university?: CrawlQueueItem
current_phase: CrawlPhase
queue_length: number
completed_today: number
total_processed: number
last_activity?: string
}
export interface University {
id: string
name: string
short_name?: string
url: string
state?: string
uni_type?: string
}
export const API_BASE = '/api/admin/uni-crawler'
export const phaseConfig: Record<CrawlPhase, { label: string; color: string; icon: string }> = {
pending: { label: 'Wartend', color: 'bg-gray-100 text-gray-700', icon: 'clock' },
discovery: { label: 'Discovery', color: 'bg-blue-100 text-blue-700', icon: 'search' },
professors: { label: 'Professoren', color: 'bg-indigo-100 text-indigo-700', icon: 'user' },
all_staff: { label: 'Alle Mitarbeiter', color: 'bg-purple-100 text-purple-700', icon: 'users' },
publications: { label: 'Publikationen', color: 'bg-orange-100 text-orange-700', icon: 'book' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700', icon: 'check' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700', icon: 'x' },
paused: { label: 'Pausiert', color: 'bg-yellow-100 text-yellow-700', icon: 'pause' }
}

View File

@@ -4,79 +4,18 @@
* University Crawler Control Panel
*
* Admin interface for managing the multi-phase university crawling system.
* Allows starting/stopping the orchestrator, adding universities to the queue,
* and monitoring crawl progress.
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
// Types matching the Go backend
interface CrawlQueueItem {
id: string
university_id: string
university_name: string
university_short: string
queue_position: number | null
priority: number
current_phase: CrawlPhase
discovery_completed: boolean
discovery_completed_at?: string
professors_completed: boolean
professors_completed_at?: string
all_staff_completed: boolean
all_staff_completed_at?: string
publications_completed: boolean
publications_completed_at?: string
discovery_count: number
professors_count: number
staff_count: number
publications_count: number
retry_count: number
max_retries: number
last_error?: string
started_at?: string
completed_at?: string
progress_percent: number
created_at: string
updated_at: string
}
type CrawlPhase = 'pending' | 'discovery' | 'professors' | 'all_staff' | 'publications' | 'completed' | 'failed' | 'paused'
interface OrchestratorStatus {
is_running: boolean
current_university?: CrawlQueueItem
current_phase: CrawlPhase
queue_length: number
completed_today: number
total_processed: number
last_activity?: string
}
interface University {
id: string
name: string
short_name?: string
url: string
state?: string
uni_type?: string
}
// Use local API proxy to avoid CORS issues and keep API keys server-side
const API_BASE = '/api/admin/uni-crawler'
// Phase display configuration
const phaseConfig: Record<CrawlPhase, { label: string; color: string; icon: string }> = {
pending: { label: 'Wartend', color: 'bg-gray-100 text-gray-700', icon: 'clock' },
discovery: { label: 'Discovery', color: 'bg-blue-100 text-blue-700', icon: 'search' },
professors: { label: 'Professoren', color: 'bg-indigo-100 text-indigo-700', icon: 'user' },
all_staff: { label: 'Alle Mitarbeiter', color: 'bg-purple-100 text-purple-700', icon: 'users' },
publications: { label: 'Publikationen', color: 'bg-orange-100 text-orange-700', icon: 'book' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700', icon: 'check' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700', icon: 'x' },
paused: { label: 'Pausiert', color: 'bg-yellow-100 text-yellow-700', icon: 'pause' }
}
import {
CrawlQueueItem,
OrchestratorStatus,
University,
API_BASE,
phaseConfig,
} from './_components/types'
import { QueueList } from './_components/QueueList'
export default function UniCrawlerPage() {
const [status, setStatus] = useState<OrchestratorStatus | null>(null)
@@ -85,288 +24,130 @@ export default function UniCrawlerPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Add to queue form
const [selectedUniversity, setSelectedUniversity] = useState('')
const [priority, setPriority] = useState(5)
// Fetch status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}?action=status`)
if (res.ok) {
const data = await res.json()
setStatus(data)
}
} catch (err) {
console.error('Failed to fetch status:', err)
}
if (res.ok) setStatus(await res.json())
} catch (err) { console.error('Failed to fetch status:', err) }
}, [])
// Fetch queue
const fetchQueue = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}?action=queue`)
if (res.ok) {
const data = await res.json()
setQueue(data.queue || [])
}
} catch (err) {
console.error('Failed to fetch queue:', err)
}
if (res.ok) { const data = await res.json(); setQueue(data.queue || []) }
} catch (err) { console.error('Failed to fetch queue:', err) }
}, [])
// Fetch universities (for dropdown)
const fetchUniversities = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}?action=universities`)
if (res.ok) {
const data = await res.json()
// Handle null/undefined universities array from API
const unis = data.universities ?? data ?? []
setUniversities(Array.isArray(unis) ? unis : [])
}
} catch (err) {
console.error('Failed to fetch universities:', err)
}
} catch (err) { console.error('Failed to fetch universities:', err) }
}, [])
// Initial load and polling
useEffect(() => {
fetchStatus()
fetchQueue()
fetchUniversities()
// Poll every 5 seconds
const interval = setInterval(() => {
fetchStatus()
fetchQueue()
}, 5000)
fetchStatus(); fetchQueue(); fetchUniversities()
const interval = setInterval(() => { fetchStatus(); fetchQueue() }, 5000)
return () => clearInterval(interval)
}, [fetchStatus, fetchQueue, fetchUniversities])
// Start orchestrator
const handleStart = async () => {
setLoading(true)
setError(null)
setLoading(true); setError(null)
try {
const res = await fetch(`${API_BASE}?action=start`, {
method: 'POST'
})
if (res.ok) {
setSuccess('Orchestrator gestartet')
fetchStatus()
} else {
const data = await res.json()
setError(data.error || 'Start fehlgeschlagen')
}
} catch (err) {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
const res = await fetch(`${API_BASE}?action=start`, { method: 'POST' })
if (res.ok) { setSuccess('Orchestrator gestartet'); fetchStatus() }
else { const data = await res.json(); setError(data.error || 'Start fehlgeschlagen') }
} catch { setError('Verbindungsfehler') }
finally { setLoading(false) }
}
// Stop orchestrator
const handleStop = async () => {
setLoading(true)
setError(null)
setLoading(true); setError(null)
try {
const res = await fetch(`${API_BASE}?action=stop`, {
method: 'POST'
})
if (res.ok) {
setSuccess('Orchestrator gestoppt')
fetchStatus()
} else {
const data = await res.json()
setError(data.error || 'Stop fehlgeschlagen')
}
} catch (err) {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
const res = await fetch(`${API_BASE}?action=stop`, { method: 'POST' })
if (res.ok) { setSuccess('Orchestrator gestoppt'); fetchStatus() }
else { const data = await res.json(); setError(data.error || 'Stop fehlgeschlagen') }
} catch { setError('Verbindungsfehler') }
finally { setLoading(false) }
}
// Add university to queue
const handleAddToQueue = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUniversity) return
setLoading(true)
setError(null)
setLoading(true); setError(null)
try {
const res = await fetch(`${API_BASE}?action=queue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
university_id: selectedUniversity,
priority: priority,
initiated_by: 'admin-ui'
})
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ university_id: selectedUniversity, priority, initiated_by: 'admin-ui' })
})
if (res.ok) {
setSuccess('Universitaet zur Queue hinzugefuegt')
setSelectedUniversity('')
setPriority(5)
fetchQueue()
} else {
const data = await res.json()
setError(data.error || 'Hinzufuegen fehlgeschlagen')
}
} catch (err) {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
if (res.ok) { setSuccess('Universitaet zur Queue hinzugefuegt'); setSelectedUniversity(''); setPriority(5); fetchQueue() }
else { const data = await res.json(); setError(data.error || 'Hinzufuegen fehlgeschlagen') }
} catch { setError('Verbindungsfehler') }
finally { setLoading(false) }
}
// Remove from queue
const handleRemove = async (universityId: string) => {
if (!confirm('Wirklich aus der Queue entfernen?')) return
try {
const res = await fetch(`${API_BASE}?university_id=${universityId}`, {
method: 'DELETE'
})
if (res.ok) {
fetchQueue()
}
} catch (err) {
console.error('Remove failed:', err)
}
try { const res = await fetch(`${API_BASE}?university_id=${universityId}`, { method: 'DELETE' }); if (res.ok) fetchQueue() }
catch (err) { console.error('Remove failed:', err) }
}
// Pause/Resume
const handlePause = async (universityId: string) => {
try {
const res = await fetch(`${API_BASE}?action=pause&university_id=${universityId}`, {
method: 'POST'
})
if (res.ok) {
fetchQueue()
}
} catch (err) {
console.error('Pause failed:', err)
}
try { const res = await fetch(`${API_BASE}?action=pause&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
catch (err) { console.error('Pause failed:', err) }
}
const handleResume = async (universityId: string) => {
try {
const res = await fetch(`${API_BASE}?action=resume&university_id=${universityId}`, {
method: 'POST'
})
if (res.ok) {
fetchQueue()
}
} catch (err) {
console.error('Resume failed:', err)
}
try { const res = await fetch(`${API_BASE}?action=resume&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
catch (err) { console.error('Resume failed:', err) }
}
// Clear messages after 5 seconds
useEffect(() => {
if (success) {
const timer = setTimeout(() => setSuccess(null), 5000)
return () => clearTimeout(timer)
}
}, [success])
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000)
return () => clearTimeout(timer)
}
}, [error])
useEffect(() => { if (success) { const t = setTimeout(() => setSuccess(null), 5000); return () => clearTimeout(t) } }, [success])
useEffect(() => { if (error) { const t = setTimeout(() => setError(null), 5000); return () => clearTimeout(t) } }, [error])
return (
<AdminLayout
title="University Crawler"
description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten"
>
{/* Messages */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
{success}
</div>
)}
<AdminLayout title="University Crawler" description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten">
{error && <div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{error}</div>}
{success && <div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">{success}</div>}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Status Card */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Orchestrator Status</h2>
{/* Status Indicator */}
<div className="flex items-center gap-3 mb-6">
<div className={`w-4 h-4 rounded-full ${status?.is_running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
<span className={`font-medium ${status?.is_running ? 'text-green-700' : 'text-gray-600'}`}>
{status?.is_running ? 'Laeuft' : 'Gestoppt'}
</span>
</div>
{/* Control Buttons */}
<div className="flex gap-3 mb-6">
<button
onClick={handleStart}
disabled={loading || status?.is_running}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Start
</button>
<button
onClick={handleStop}
disabled={loading || !status?.is_running}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Stop
</button>
<button onClick={handleStart} disabled={loading || status?.is_running} className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Start</button>
<button onClick={handleStop} disabled={loading || !status?.is_running} className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Stop</button>
</div>
{/* Stats */}
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500">In Queue:</span>
<span className="font-medium">{status?.queue_length || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Heute abgeschlossen:</span>
<span className="font-medium text-green-600">{status?.completed_today || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Gesamt verarbeitet:</span>
<span className="font-medium">{status?.total_processed || 0}</span>
</div>
<div className="flex justify-between text-sm"><span className="text-gray-500">In Queue:</span><span className="font-medium">{status?.queue_length || 0}</span></div>
<div className="flex justify-between text-sm"><span className="text-gray-500">Heute abgeschlossen:</span><span className="font-medium text-green-600">{status?.completed_today || 0}</span></div>
<div className="flex justify-between text-sm"><span className="text-gray-500">Gesamt verarbeitet:</span><span className="font-medium">{status?.total_processed || 0}</span></div>
{status?.last_activity && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Letzte Aktivitaet:</span>
<span className="font-medium text-xs">
{new Date(status.last_activity).toLocaleTimeString('de-DE')}
</span>
</div>
<div className="flex justify-between text-sm"><span className="text-gray-500">Letzte Aktivitaet:</span><span className="font-medium text-xs">{new Date(status.last_activity).toLocaleTimeString('de-DE')}</span></div>
)}
</div>
{/* Current University */}
{status?.current_university && (
<div className="mt-6 pt-6 border-t border-gray-100">
<h3 className="text-sm font-medium text-gray-500 mb-2">Aktuelle Verarbeitung</h3>
<div className="bg-blue-50 rounded-lg p-3">
<p className="font-medium text-blue-900">{status.current_university.university_name}</p>
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[status.current_phase].color}`}>
{phaseConfig[status.current_phase].label}
</span>
<span className="text-xs text-blue-600">
{status.current_university.progress_percent}%
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[status.current_phase].color}`}>{phaseConfig[status.current_phase].label}</span>
<span className="text-xs text-blue-600">{status.current_university.progress_percent}%</span>
</div>
</div>
</div>
@@ -378,157 +159,23 @@ export default function UniCrawlerPage() {
<h2 className="text-lg font-semibold text-gray-900 mb-4">Zur Queue hinzufuegen</h2>
<form onSubmit={handleAddToQueue} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Universitaet
</label>
<select
value={selectedUniversity}
onChange={(e) => setSelectedUniversity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<label className="block text-sm font-medium text-gray-700 mb-1">Universitaet</label>
<select value={selectedUniversity} onChange={(e) => setSelectedUniversity(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">Waehlen...</option>
{universities.map((uni) => (
<option key={uni.id} value={uni.id}>
{uni.name} {uni.short_name && `(${uni.short_name})`}
</option>
))}
{universities.map((uni) => (<option key={uni.id} value={uni.id}>{uni.name} {uni.short_name && `(${uni.short_name})`}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Prioritaet (1-10)
</label>
<input
type="number"
min={1}
max={10}
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet (1-10)</label>
<input type="number" min={1} max={10} value={priority} onChange={(e) => setPriority(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<p className="text-xs text-gray-500 mt-1">Hoeher = dringender</p>
</div>
<button
type="submit"
disabled={loading || !selectedUniversity}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Hinzufuegen
</button>
<button type="submit" disabled={loading || !selectedUniversity} className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Hinzufuegen</button>
</form>
</div>
</div>
{/* Queue List */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">Crawl Queue ({queue.length})</h2>
</div>
{queue.length === 0 ? (
<div className="px-6 py-12 text-center text-gray-500">
Queue ist leer. Fuege Universitaeten hinzu, um das Crawling zu starten.
</div>
) : (
<div className="divide-y divide-gray-100">
{queue.map((item) => (
<div key={item.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-400">#{item.queue_position || '-'}</span>
<h3 className="font-medium text-gray-900">{item.university_name}</h3>
<span className="text-sm text-gray-500">({item.university_short})</span>
</div>
{/* Phase Badge */}
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[item.current_phase].color}`}>
{phaseConfig[item.current_phase].label}
</span>
<span className="text-xs text-gray-500">
Prioritaet: {item.priority}
</span>
{item.retry_count > 0 && (
<span className="text-xs text-orange-600">
Versuch {item.retry_count}/{item.max_retries}
</span>
)}
</div>
{/* Progress Bar */}
<div className="mt-3">
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${item.progress_percent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{item.progress_percent}%</span>
<span>
{item.discovery_count} Disc / {item.professors_count} Prof / {item.staff_count} Staff / {item.publications_count} Pub
</span>
</div>
</div>
{/* Phase Checkmarks */}
<div className="flex gap-4 mt-3 text-xs">
<span className={item.discovery_completed ? 'text-green-600' : 'text-gray-400'}>
{item.discovery_completed ? 'Discovery' : 'Discovery'}
</span>
<span className={item.professors_completed ? 'text-green-600' : 'text-gray-400'}>
{item.professors_completed ? 'Professoren' : 'Professoren'}
</span>
<span className={item.all_staff_completed ? 'text-green-600' : 'text-gray-400'}>
{item.all_staff_completed ? 'Mitarbeiter' : 'Mitarbeiter'}
</span>
<span className={item.publications_completed ? 'text-green-600' : 'text-gray-400'}>
{item.publications_completed ? 'Publikationen' : 'Publikationen'}
</span>
</div>
{/* Error Message */}
{item.last_error && (
<p className="mt-2 text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
{item.last_error}
</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
{item.current_phase === 'paused' ? (
<button
onClick={() => handleResume(item.university_id)}
className="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
>
Fortsetzen
</button>
) : item.current_phase !== 'completed' && item.current_phase !== 'failed' ? (
<button
onClick={() => handlePause(item.university_id)}
className="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors"
>
Pausieren
</button>
) : null}
<button
onClick={() => handleRemove(item.university_id)}
className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
>
Entfernen
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<QueueList queue={queue} onPause={handlePause} onResume={handleResume} onRemove={handleRemove} />
</div>
</AdminLayout>
)

View File

@@ -0,0 +1,220 @@
'use client'
import { useState } from 'react'
import { TASK_STATES, INTENT_GROUPS, DSGVO_CATEGORIES, API_ENDPOINTS } from './constants'
export function TabDemo() {
const [demoLoaded, setDemoLoaded] = useState(false)
return (
<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="http://localhost: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>
<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="http://localhost:3001/voice-test?embed=true" className="w-full h-full border-0" title="Voice Demo" allow="microphone" />
)}
</div>
</div>
)
}
export function TabTasks() {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
<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>
<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>
)
}
export function TabIntents() {
return (
<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>
)
}
export function TabDsgvo() {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
<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>
<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>
<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>
)
}
export function TabApi() {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
<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>
<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>
<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 http://localhost:8091/api/v1/sessions \\
-H "Content-Type: application/json" \\
-d '{
"namespace_id": "ns-12345678abcdef12345678abcdef12",
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
"device_type": "pwa"
}'`}</pre>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
export function TabOverview() {
return (
<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>
)
}

View File

@@ -0,0 +1,108 @@
export type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
// Task State Machine data
export 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)
export 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
export const DSGVO_CATEGORIES = [
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' as const },
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' as const },
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' as const },
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' as const },
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' as const },
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' as const },
]
// API Endpoints
export 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 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: '🔌' },
] as const

View File

@@ -14,149 +14,30 @@
import { useState } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import Link from 'next/link'
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' },
]
import { TabType, TABS } from './_components/constants'
import { TabOverview } from './_components/TabOverview'
import { TabDemo, TabTasks, TabIntents, TabDsgvo, TabApi } from './_components/TabContent'
export default function VoicePage() {
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 (
<AdminLayout title="Voice Service" description="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator">
{/* Quick Links */}
<div className="mb-6 flex flex-wrap gap-3">
<Link
href="/voice-test"
target="_blank"
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<Link href="/voice-test" target="_blank" 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)
</Link>
<a
href="http://localhost: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"
>
<a href="http://localhost: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="/admin/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"
>
<Link href="/admin/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>
@@ -166,37 +47,19 @@ export default function VoicePage() {
{/* 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 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) => (
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
@@ -214,360 +77,12 @@ export default function VoicePage() {
</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="http://localhost: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="http://localhost: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 http://localhost:8091/api/v1/sessions \\
-H "Content-Type: application/json" \\
-d '{
"namespace_id": "ns-12345678abcdef12345678abcdef12",
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
"device_type": "pwa"
}'`}</pre>
</div>
</div>
)}
{activeTab === 'overview' && <TabOverview />}
{activeTab === 'demo' && <TabDemo />}
{activeTab === 'tasks' && <TabTasks />}
{activeTab === 'intents' && <TabIntents />}
{activeTab === 'dsgvo' && <TabDsgvo />}
{activeTab === 'api' && <TabApi />}
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,93 @@
interface AssistantSidebarProps {
isRTL: boolean
t: (key: string) => string
assistantHistory: { role: string; content: string }[]
assistantMessage: string
setAssistantMessage: (msg: string) => void
onAskAssistant: () => void
onClose: () => void
}
export function AssistantSidebar({
isRTL,
t,
assistantHistory,
assistantMessage,
setAssistantMessage,
onAskAssistant,
onClose,
}: AssistantSidebarProps) {
return (
<div className={`fixed ${isRTL ? 'left-0' : 'right-0'} top-0 h-full w-96 bg-white border-${isRTL ? 'r' : 'l'} border-slate-200 shadow-xl z-30 flex flex-col`}>
<div className={`p-4 border-b border-slate-200 flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className={`flex items-center gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">{t('fa_assistant_title')}</h3>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{assistantHistory.length === 0 && (
<div className="text-center py-8">
<p className="text-slate-500 text-sm">
{t('fa_assistant_placeholder')}
</p>
</div>
)}
{assistantHistory.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] p-3 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700'
}`}
>
{msg.content}
</div>
</div>
))}
</div>
{/* Input */}
<div className="p-4 border-t border-slate-200">
<div className={`flex gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
<input
type="text"
value={assistantMessage}
onChange={(e) => setAssistantMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onAskAssistant()}
placeholder={t('fa_assistant_placeholder')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={onAskAssistant}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import React from 'react'
export const stepIcons: Record<string, React.ReactNode> = {
'document-text': (
<svg className="w-5 h-5" 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>
),
'academic-cap': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
</svg>
),
'server': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
),
'document-report': (
<svg className="w-5 h-5" 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>
),
'currency-euro': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
'calculator': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
'calendar': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
'document-download': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
),
}

View File

@@ -0,0 +1,154 @@
export function Step1({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<p className="text-slate-600">{t('fa_step1_desc')}</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">{t('fa_wizard_next')}</p>
</div>
</div>
)
}
export function Step2({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_title')} *</label>
<input type="text" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
)
}
export function Step3({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step3_desc')}</label>
<select className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option>16 Mbit/s</option>
<option>16-50 Mbit/s</option>
<option>50-100 Mbit/s</option>
<option>100-250 Mbit/s</option>
<option>250+ Mbit/s</option>
</select>
</div>
</div>
)
}
export function Step4({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_desc')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={3} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_subtitle')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
</div>
</div>
)
}
export function Step5({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<p className="text-slate-600">{t('fa_step5_desc')}</p>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700">{t('fa_step5_subtitle')}</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-slate-200">
<td className="px-4 py-2">
<button className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
+
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
export function Step6({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step6_desc')}</label>
<div className="flex items-center gap-4">
<input type="range" min="50" max="100" defaultValue="90" className="flex-1" />
<span className="text-lg font-semibold text-slate-900">90%</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-500">{t('fa_step6_subtitle')}</div>
<div className="text-xl font-bold text-slate-900">0,00 EUR</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-600">{t('fa_step6_title')}</div>
<div className="text-xl font-bold text-blue-700">0,00 EUR</div>
</div>
</div>
</div>
)
}
export function Step7({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
)
}
export function Step8({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="font-semibold text-green-800">{t('fa_step8_title')}</h3>
<p className="text-sm text-green-700 mt-1">{t('fa_step8_desc')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step8_subtitle')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">{t('fa_info_text')}</span>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
export interface WizardStep {
number: number
id: string
titleKey: string
subtitleKey: string
descKey: string
icon: string
is_required: boolean
is_completed: boolean
}
export interface FormData {
[key: string]: any
}
export const DEFAULT_STEPS: WizardStep[] = [
{ number: 1, id: 'foerderprogramm', titleKey: 'fa_step1_title', subtitleKey: 'fa_step1_subtitle', descKey: 'fa_step1_desc', icon: 'document-text', is_required: true, is_completed: false },
{ number: 2, id: 'schulinformationen', titleKey: 'fa_step2_title', subtitleKey: 'fa_step2_subtitle', descKey: 'fa_step2_desc', icon: 'academic-cap', is_required: true, is_completed: false },
{ number: 3, id: 'bestandsaufnahme', titleKey: 'fa_step3_title', subtitleKey: 'fa_step3_subtitle', descKey: 'fa_step3_desc', icon: 'server', is_required: true, is_completed: false },
{ number: 4, id: 'projektbeschreibung', titleKey: 'fa_step4_title', subtitleKey: 'fa_step4_subtitle', descKey: 'fa_step4_desc', icon: 'document-report', is_required: true, is_completed: false },
{ number: 5, id: 'investitionen', titleKey: 'fa_step5_title', subtitleKey: 'fa_step5_subtitle', descKey: 'fa_step5_desc', icon: 'currency-euro', is_required: true, is_completed: false },
{ number: 6, id: 'finanzierungsplan', titleKey: 'fa_step6_title', subtitleKey: 'fa_step6_subtitle', descKey: 'fa_step6_desc', icon: 'calculator', is_required: true, is_completed: false },
{ number: 7, id: 'zeitplan', titleKey: 'fa_step7_title', subtitleKey: 'fa_step7_subtitle', descKey: 'fa_step7_desc', icon: 'calendar', is_required: true, is_completed: false },
{ number: 8, id: 'abschluss', titleKey: 'fa_step8_title', subtitleKey: 'fa_step8_subtitle', descKey: 'fa_step8_desc', icon: 'document-download', is_required: true, is_completed: false },
]

View File

@@ -4,65 +4,10 @@ import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
interface WizardStep {
number: number
id: string
titleKey: string
subtitleKey: string
descKey: string
icon: string
is_required: boolean
is_completed: boolean
}
interface FormData {
[key: string]: any
}
const stepIcons: Record<string, React.ReactNode> = {
'document-text': (
<svg className="w-5 h-5" 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>
),
'academic-cap': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
</svg>
),
'server': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
),
'document-report': (
<svg className="w-5 h-5" 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>
),
'currency-euro': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
'calculator': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
'calendar': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
'document-download': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
),
}
import { WizardStep, FormData, DEFAULT_STEPS } from './_components/types'
import { stepIcons } from './_components/StepIcons'
import { Step1, Step2, Step3, Step4, Step5, Step6, Step7, Step8 } from './_components/WizardSteps'
import { AssistantSidebar } from './_components/AssistantSidebar'
export default function FoerderantragWizardPage() {
const params = useParams()
@@ -70,19 +15,8 @@ export default function FoerderantragWizardPage() {
const { t, isRTL } = useLanguage()
const applicationId = params.applicationId as string
const defaultSteps: WizardStep[] = [
{ number: 1, id: 'foerderprogramm', titleKey: 'fa_step1_title', subtitleKey: 'fa_step1_subtitle', descKey: 'fa_step1_desc', icon: 'document-text', is_required: true, is_completed: false },
{ number: 2, id: 'schulinformationen', titleKey: 'fa_step2_title', subtitleKey: 'fa_step2_subtitle', descKey: 'fa_step2_desc', icon: 'academic-cap', is_required: true, is_completed: false },
{ number: 3, id: 'bestandsaufnahme', titleKey: 'fa_step3_title', subtitleKey: 'fa_step3_subtitle', descKey: 'fa_step3_desc', icon: 'server', is_required: true, is_completed: false },
{ number: 4, id: 'projektbeschreibung', titleKey: 'fa_step4_title', subtitleKey: 'fa_step4_subtitle', descKey: 'fa_step4_desc', icon: 'document-report', is_required: true, is_completed: false },
{ number: 5, id: 'investitionen', titleKey: 'fa_step5_title', subtitleKey: 'fa_step5_subtitle', descKey: 'fa_step5_desc', icon: 'currency-euro', is_required: true, is_completed: false },
{ number: 6, id: 'finanzierungsplan', titleKey: 'fa_step6_title', subtitleKey: 'fa_step6_subtitle', descKey: 'fa_step6_desc', icon: 'calculator', is_required: true, is_completed: false },
{ number: 7, id: 'zeitplan', titleKey: 'fa_step7_title', subtitleKey: 'fa_step7_subtitle', descKey: 'fa_step7_desc', icon: 'calendar', is_required: true, is_completed: false },
{ number: 8, id: 'abschluss', titleKey: 'fa_step8_title', subtitleKey: 'fa_step8_subtitle', descKey: 'fa_step8_desc', icon: 'document-download', is_required: true, is_completed: false },
]
const [currentStep, setCurrentStep] = useState(1)
const [steps, setSteps] = useState<WizardStep[]>(defaultSteps)
const [steps, setSteps] = useState<WizardStep[]>(DEFAULT_STEPS)
const [formData, setFormData] = useState<FormData>({})
const [isSaving, setIsSaving] = useState(false)
const [showAssistant, setShowAssistant] = useState(false)
@@ -146,24 +80,15 @@ export default function FoerderantragWizardPage() {
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <Step1 t={t} />
case 2:
return <Step2 t={t} />
case 3:
return <Step3 t={t} />
case 4:
return <Step4 t={t} />
case 5:
return <Step5 t={t} />
case 6:
return <Step6 t={t} />
case 7:
return <Step7 t={t} />
case 8:
return <Step8 t={t} />
default:
return null
case 1: return <Step1 t={t} />
case 2: return <Step2 t={t} />
case 3: return <Step3 t={t} />
case 4: return <Step4 t={t} />
case 5: return <Step5 t={t} />
case 6: return <Step6 t={t} />
case 7: return <Step7 t={t} />
case 8: return <Step8 t={t} />
default: return null
}
}
@@ -306,235 +231,17 @@ export default function FoerderantragWizardPage() {
{/* Assistant Sidebar */}
{showAssistant && (
<div className={`fixed ${isRTL ? 'left-0' : 'right-0'} top-0 h-full w-96 bg-white border-${isRTL ? 'r' : 'l'} border-slate-200 shadow-xl z-30 flex flex-col`}>
<div className={`p-4 border-b border-slate-200 flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className={`flex items-center gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">{t('fa_assistant_title')}</h3>
</div>
</div>
<button
onClick={() => setShowAssistant(false)}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{assistantHistory.length === 0 && (
<div className="text-center py-8">
<p className="text-slate-500 text-sm">
{t('fa_assistant_placeholder')}
</p>
</div>
)}
{assistantHistory.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] p-3 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700'
}`}
>
{msg.content}
</div>
</div>
))}
</div>
{/* Input */}
<div className="p-4 border-t border-slate-200">
<div className={`flex gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
<input
type="text"
value={assistantMessage}
onChange={(e) => setAssistantMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskAssistant()}
placeholder={t('fa_assistant_placeholder')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAskAssistant}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
<AssistantSidebar
isRTL={isRTL}
t={t}
assistantHistory={assistantHistory}
assistantMessage={assistantMessage}
setAssistantMessage={setAssistantMessage}
onAskAssistant={handleAskAssistant}
onClose={() => setShowAssistant(false)}
/>
)}
</div>
</div>
)
}
// Step Components
function Step1({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<p className="text-slate-600">{t('fa_step1_desc')}</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">{t('fa_wizard_next')}</p>
</div>
</div>
)
}
function Step2({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_title')} *</label>
<input type="text" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
)
}
function Step3({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step3_desc')}</label>
<select className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option>16 Mbit/s</option>
<option>16-50 Mbit/s</option>
<option>50-100 Mbit/s</option>
<option>100-250 Mbit/s</option>
<option>250+ Mbit/s</option>
</select>
</div>
</div>
)
}
function Step4({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_desc')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={3} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_subtitle')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
</div>
</div>
)
}
function Step5({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<p className="text-slate-600">{t('fa_step5_desc')}</p>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700">{t('fa_step5_subtitle')}</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-slate-200">
<td className="px-4 py-2">
<button className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
+
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
function Step6({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step6_desc')}</label>
<div className="flex items-center gap-4">
<input type="range" min="50" max="100" defaultValue="90" className="flex-1" />
<span className="text-lg font-semibold text-slate-900">90%</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-500">{t('fa_step6_subtitle')}</div>
<div className="text-xl font-bold text-slate-900">0,00 EUR</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-600">{t('fa_step6_title')}</div>
<div className="text-xl font-bold text-blue-700">0,00 EUR</div>
</div>
</div>
</div>
)
}
function Step7({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
)
}
function Step8({ t }: { t: (key: string) => string }) {
return (
<div className="space-y-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="font-semibold text-green-800">{t('fa_step8_title')}</h3>
<p className="text-sm text-green-700 mt-1">{t('fa_step8_desc')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step8_subtitle')} *</label>
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">{t('fa_info_text')}</span>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState } from 'react'
import { API_BASE } from './types'
interface CreateTaskModalProps {
onClose: () => void
onSuccess: () => void
}
export function CreateTaskModal({
onClose,
onSuccess
}: CreateTaskModalProps) {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium',
deadline: '',
})
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
priority: formData.priority,
deadline: formData.deadline || null,
}),
})
if (res.ok) {
onSuccess()
}
} catch (err) {
console.error('Failed to create task:', err)
} 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-md w-full mx-4">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Neue Aufgabe erstellen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="Was muss erledigt werden?"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={3}
placeholder="Weitere Details..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Prioritaet</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="urgent">Dringend</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Frist</label>
<input
type="date"
value={formData.deadline}
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</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-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Erstellen...' : 'Aufgabe erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
interface StatCardProps {
label: string
value: number
color?: 'slate' | 'blue' | 'green' | 'yellow' | 'red' | 'orange'
highlight?: boolean
}
const colorClasses: Record<string, string> = {
slate: 'text-slate-900',
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
orange: 'text-orange-600',
}
export function StatCard({
label,
value,
color = 'slate',
highlight = false
}: StatCardProps) {
return (
<div className={`bg-white rounded-lg shadow p-4 ${highlight ? 'ring-2 ring-red-200' : ''}`}>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-2xl font-bold ${colorClasses[color]}`}>{value}</p>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import type { Task } from './types'
import { priorityColors, priorityLabels, getOverdueIndicator } from './types'
interface TaskCardProps {
task: Task
onUpdateStatus: (taskId: string, status: string) => void
}
export function TaskCard({ task, onUpdateStatus }: TaskCardProps) {
const colors = priorityColors[task.priority]
const overdueInfo = getOverdueIndicator(task.deadline)
return (
<div className={`bg-white rounded-lg shadow border-l-4 ${colors.border} p-6 hover:shadow-md transition-shadow`}>
<div className="flex items-start justify-between">
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 text-xs font-medium rounded ${colors.bg} ${colors.text}`}>
{priorityLabels[task.priority]}
</span>
{overdueInfo && (
<span className={`px-2 py-1 text-xs font-medium rounded ${overdueInfo.color}`}>
{overdueInfo.label}
</span>
)}
{task.aiExtracted && (
<span className="px-2 py-1 text-xs font-medium rounded bg-purple-100 text-purple-700 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
KI
</span>
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-slate-900 mb-2">{task.title}</h3>
{/* Description */}
{task.description && (
<p className="text-slate-600 text-sm mb-3 line-clamp-2">{task.description}</p>
)}
{/* Source Email */}
{task.sourceSubject && (
<div className="flex items-center gap-2 text-sm text-slate-500 mb-3">
<svg className="w-4 h-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>
<span className="truncate">
Von: {task.sourceSender} - {task.sourceSubject}
</span>
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-sm text-slate-500">
{task.deadline && (
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>
{new Date(task.deadline).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})}
</span>
</div>
)}
<div className="flex items-center gap-1">
<svg className="w-4 h-4" 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>Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{task.status === 'pending' && (
<button
onClick={() => onUpdateStatus(task.id, 'in_progress')}
className="px-3 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100"
>
Starten
</button>
)}
{task.status === 'in_progress' && (
<button
onClick={() => onUpdateStatus(task.id, 'completed')}
className="px-3 py-1.5 text-sm font-medium text-green-600 bg-green-50 rounded-lg hover:bg-green-100"
>
Erledigen
</button>
)}
{task.status === 'completed' && (
<span className="px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 rounded-lg flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Erledigt
</span>
)}
<button className="p-2 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export interface Task {
id: string
title: string
description: string
status: 'pending' | 'in_progress' | 'completed'
priority: 'urgent' | 'high' | 'medium' | 'low'
deadline: string | null
emailId: string | null
sourceSubject: string | null
sourceSender: string | null
senderType: string | null
aiExtracted: boolean
confidenceScore: number | null
createdAt: string
updatedAt: string
}
export interface DashboardStats {
totalTasks: number
pendingTasks: number
inProgressTasks: number
completedTasks: number
overdueTasks: number
dueToday: number
dueThisWeek: number
byPriority: Record<string, number>
bySenderType: Record<string, number>
}
export type FilterStatus = 'all' | 'pending' | 'in_progress' | 'completed'
export type FilterPriority = 'all' | 'urgent' | 'high' | 'medium' | 'low'
export const priorityColors: Record<string, { bg: string; text: string; border: string }> = {
urgent: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
}
export const priorityLabels: Record<string, string> = {
urgent: 'Dringend',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
export const statusLabels: Record<string, string> = {
pending: 'Offen',
in_progress: 'In Bearbeitung',
completed: 'Erledigt',
}
export const getOverdueIndicator = (deadline: string | null) => {
if (!deadline) return null
const deadlineDate = new Date(deadline)
const now = new Date()
const diffDays = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { label: 'Ueberfaellig', color: 'bg-red-100 text-red-800', urgent: true }
} else if (diffDays === 0) {
return { label: 'Heute', color: 'bg-orange-100 text-orange-800', urgent: true }
} else if (diffDays <= 3) {
return { label: `In ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`, color: 'bg-yellow-100 text-yellow-800', urgent: false }
} else if (diffDays <= 7) {
return { label: `In ${diffDays} Tagen`, color: 'bg-blue-100 text-blue-800', urgent: false }
}
return null
}

View File

@@ -12,42 +12,18 @@
*/
import { useState, useEffect, useCallback } from 'react'
// API Base URL
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Types
interface Task {
id: string
title: string
description: string
status: 'pending' | 'in_progress' | 'completed'
priority: 'urgent' | 'high' | 'medium' | 'low'
deadline: string | null
emailId: string | null
sourceSubject: string | null
sourceSender: string | null
senderType: string | null
aiExtracted: boolean
confidenceScore: number | null
createdAt: string
updatedAt: string
}
interface DashboardStats {
totalTasks: number
pendingTasks: number
inProgressTasks: number
completedTasks: number
overdueTasks: number
dueToday: number
dueThisWeek: number
byPriority: Record<string, number>
bySenderType: Record<string, number>
}
type FilterStatus = 'all' | 'pending' | 'in_progress' | 'completed'
type FilterPriority = 'all' | 'urgent' | 'high' | 'medium' | 'low'
import {
API_BASE,
Task,
DashboardStats,
FilterStatus,
FilterPriority,
statusLabels,
priorityLabels,
} from './_components/types'
import { StatCard } from './_components/StatCard'
import { CreateTaskModal } from './_components/CreateTaskModal'
import { TaskCard } from './_components/TaskCard'
export default function TasksPage() {
const [tasks, setTasks] = useState<Task[]>([])
@@ -61,12 +37,8 @@ export default function TasksPage() {
try {
setLoading(true)
const params = new URLSearchParams()
if (filterStatus !== 'all') {
params.append('status', filterStatus)
}
if (filterPriority !== 'all') {
params.append('priority', filterPriority)
}
if (filterStatus !== 'all') params.append('status', filterStatus)
if (filterPriority !== 'all') params.append('priority', filterPriority)
params.append('include_completed', filterStatus === 'completed' ? 'true' : 'false')
const [tasksRes, statsRes] = await Promise.all([
@@ -74,15 +46,8 @@ export default function TasksPage() {
fetch(`${API_BASE}/api/v1/mail/tasks/dashboard`),
])
if (tasksRes.ok) {
const data = await tasksRes.json()
setTasks(data.tasks || [])
}
if (statsRes.ok) {
const data = await statsRes.json()
setStats(data)
}
if (tasksRes.ok) { const data = await tasksRes.json(); setTasks(data.tasks || []) }
if (statsRes.ok) { const data = await statsRes.json(); setStats(data) }
} catch (err) {
console.error('Failed to fetch tasks:', err)
} finally {
@@ -90,9 +55,7 @@ export default function TasksPage() {
}
}, [filterStatus, filterPriority])
useEffect(() => {
fetchTasks()
}, [fetchTasks])
useEffect(() => { fetchTasks() }, [fetchTasks])
const updateTaskStatus = async (taskId: string, status: string) => {
try {
@@ -107,44 +70,6 @@ export default function TasksPage() {
}
}
const priorityColors: Record<string, { bg: string; text: string; border: string }> = {
urgent: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
}
const priorityLabels: Record<string, string> = {
urgent: 'Dringend',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
const statusLabels: Record<string, string> = {
pending: 'Offen',
in_progress: 'In Bearbeitung',
completed: 'Erledigt',
}
const getOverdueIndicator = (deadline: string | null) => {
if (!deadline) return null
const deadlineDate = new Date(deadline)
const now = new Date()
const diffDays = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { label: 'Überfällig', color: 'bg-red-100 text-red-800', urgent: true }
} else if (diffDays === 0) {
return { label: 'Heute', color: 'bg-orange-100 text-orange-800', urgent: true }
} else if (diffDays <= 3) {
return { label: `In ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`, color: 'bg-yellow-100 text-yellow-800', urgent: false }
} else if (diffDays <= 7) {
return { label: `In ${diffDays} Tagen`, color: 'bg-blue-100 text-blue-800', urgent: false }
}
return null
}
return (
<div className="min-h-screen bg-slate-100">
{/* Header */}
@@ -152,22 +77,12 @@ export default function TasksPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-900">Arbeitsvorrat</h1>
<p className="text-sm text-slate-500">Aufgaben aus E-Mails und manuelle Einträge</p>
<p className="text-sm text-slate-500">Aufgaben aus E-Mails und manuelle Eintraege</p>
</div>
<div className="flex items-center gap-4">
<a
href="/mail"
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Zurück zur Inbox
</a>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-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>
<a href="/mail" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Zurueck zur Inbox</a>
<button onClick={() => setShowCreateModal(true)} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-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>
Aufgabe erstellen
</button>
</div>
@@ -182,8 +97,8 @@ export default function TasksPage() {
<StatCard label="Offen" value={stats.pendingTasks} color="blue" />
<StatCard label="In Bearbeitung" value={stats.inProgressTasks} color="yellow" />
<StatCard label="Erledigt" value={stats.completedTasks} color="green" />
<StatCard label="Überfällig" value={stats.overdueTasks} color="red" highlight={stats.overdueTasks > 0} />
<StatCard label="Heute fällig" value={stats.dueToday} color="orange" highlight={stats.dueToday > 0} />
<StatCard label="Ueberfaellig" value={stats.overdueTasks} color="red" highlight={stats.overdueTasks > 0} />
<StatCard label="Heute faellig" value={stats.dueToday} color="orange" highlight={stats.dueToday > 0} />
<StatCard label="Diese Woche" value={stats.dueThisWeek} />
</div>
)}
@@ -191,40 +106,23 @@ export default function TasksPage() {
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-wrap gap-4 items-center">
{/* Status Filter */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700">Status:</span>
<div className="flex gap-1">
{(['all', 'pending', 'in_progress', 'completed'] as FilterStatus[]).map((status) => (
<button
key={status}
onClick={() => setFilterStatus(status)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
filterStatus === status
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
<button key={status} onClick={() => setFilterStatus(status)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${filterStatus === status ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}>
{status === 'all' ? 'Alle' : statusLabels[status]}
</button>
))}
</div>
</div>
{/* Priority Filter */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700">Priorität:</span>
<span className="text-sm font-medium text-slate-700">Prioritaet:</span>
<div className="flex gap-1">
{(['all', 'urgent', 'high', 'medium', 'low'] as FilterPriority[]).map((priority) => (
<button
key={priority}
onClick={() => setFilterPriority(priority)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
filterPriority === priority
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
<button key={priority} onClick={() => setFilterPriority(priority)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${filterPriority === priority ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}>
{priority === 'all' ? 'Alle' : priorityLabels[priority]}
</button>
))}
@@ -246,288 +144,25 @@ export default function TasksPage() {
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Aufgaben</h3>
<p className="text-slate-500">
{filterStatus !== 'all' || filterPriority !== 'all'
? 'Keine Aufgaben mit den gewählten Filtern gefunden.'
? 'Keine Aufgaben mit den gewaehlten Filtern gefunden.'
: 'Lassen Sie E-Mails analysieren oder erstellen Sie Aufgaben manuell.'}
</p>
</div>
) : (
<div className="space-y-4">
{tasks.map((task) => {
const colors = priorityColors[task.priority]
const overdueInfo = getOverdueIndicator(task.deadline)
return (
<div
key={task.id}
className={`bg-white rounded-lg shadow border-l-4 ${colors.border} p-6 hover:shadow-md transition-shadow`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 text-xs font-medium rounded ${colors.bg} ${colors.text}`}>
{priorityLabels[task.priority]}
</span>
{overdueInfo && (
<span className={`px-2 py-1 text-xs font-medium rounded ${overdueInfo.color}`}>
{overdueInfo.label}
</span>
)}
{task.aiExtracted && (
<span className="px-2 py-1 text-xs font-medium rounded bg-purple-100 text-purple-700 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
KI
</span>
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-slate-900 mb-2">{task.title}</h3>
{/* Description */}
{task.description && (
<p className="text-slate-600 text-sm mb-3 line-clamp-2">{task.description}</p>
)}
{/* Source Email */}
{task.sourceSubject && (
<div className="flex items-center gap-2 text-sm text-slate-500 mb-3">
<svg className="w-4 h-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>
<span className="truncate">
Von: {task.sourceSender} - {task.sourceSubject}
</span>
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-sm text-slate-500">
{task.deadline && (
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>
{new Date(task.deadline).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</span>
</div>
)}
<div className="flex items-center gap-1">
<svg className="w-4 h-4" 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>
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{task.status === 'pending' && (
<button
onClick={() => updateTaskStatus(task.id, 'in_progress')}
className="px-3 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100"
>
Starten
</button>
)}
{task.status === 'in_progress' && (
<button
onClick={() => updateTaskStatus(task.id, 'completed')}
className="px-3 py-1.5 text-sm font-medium text-green-600 bg-green-50 rounded-lg hover:bg-green-100"
>
Erledigen
</button>
)}
{task.status === 'completed' && (
<span className="px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 rounded-lg flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Erledigt
</span>
)}
<button className="p-2 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
</div>
</div>
)
})}
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onUpdateStatus={updateTaskStatus} />
))}
</div>
)}
</div>
{/* Create Task Modal */}
{showCreateModal && (
<CreateTaskModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false)
fetchTasks()
}}
onSuccess={() => { setShowCreateModal(false); fetchTasks() }}
/>
)}
</div>
)
}
function StatCard({
label,
value,
color = 'slate',
highlight = false
}: {
label: string
value: number
color?: 'slate' | 'blue' | 'green' | 'yellow' | 'red' | 'orange'
highlight?: boolean
}) {
const colorClasses: Record<string, string> = {
slate: 'text-slate-900',
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
orange: 'text-orange-600',
}
return (
<div className={`bg-white rounded-lg shadow p-4 ${highlight ? 'ring-2 ring-red-200' : ''}`}>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-2xl font-bold ${colorClasses[color]}`}>{value}</p>
</div>
)
}
function CreateTaskModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium',
deadline: '',
})
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
priority: formData.priority,
deadline: formData.deadline || null,
}),
})
if (res.ok) {
onSuccess()
}
} catch (err) {
console.error('Failed to create task:', err)
} 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-md w-full mx-4">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Neue Aufgabe erstellen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="Was muss erledigt werden?"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={3}
placeholder="Weitere Details..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Priorität</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="urgent">Dringend</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Frist</label>
<input
type="date"
value={formData.deadline}
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</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-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Erstellen...' : 'Aufgabe erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useState } from 'react'
import type { Option, GFKPrinciple } from './types'
interface InputFormProps {
communicationType: string
setCommunicationType: (v: string) => void
tone: string
setTone: (v: string) => void
state: string
setState: (v: string) => void
studentName: string
setStudentName: (v: string) => void
parentName: string
setParentName: (v: string) => void
situation: string
setSituation: (v: string) => void
additionalInfo: string
setAdditionalInfo: (v: string) => void
types: Option[]
tones: Option[]
states: Option[]
gfkPrinciples: GFKPrinciple[]
error: string | null
loading: boolean
onGenerate: () => void
}
export function InputForm({
communicationType, setCommunicationType,
tone, setTone,
state, setState,
studentName, setStudentName,
parentName, setParentName,
situation, setSituation,
additionalInfo, setAdditionalInfo,
types, tones, states,
gfkPrinciples,
error, loading,
onGenerate,
}: InputFormProps) {
return (
<div className="lg:col-span-1 space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Nachricht erstellen</h2>
{/* Communication Type */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Art der Kommunikation *
</label>
<select
value={communicationType}
onChange={(e) => setCommunicationType(e.target.value)}
className="w-full border rounded-md p-2"
>
{types.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* Tone */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Tonalitaet
</label>
<select
value={tone}
onChange={(e) => setTone(e.target.value)}
className="w-full border rounded-md p-2"
>
{tones.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* State */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Bundesland (fuer rechtliche Referenzen)
</label>
<select
value={state}
onChange={(e) => setState(e.target.value)}
className="w-full border rounded-md p-2"
>
{states.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
{/* Student Name */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Name des Schuelers/der Schuelerin *
</label>
<input
type="text"
value={studentName}
onChange={(e) => setStudentName(e.target.value)}
placeholder="z.B. Max"
className="w-full border rounded-md p-2"
/>
</div>
{/* Parent Name */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Anrede der Eltern *
</label>
<input
type="text"
value={parentName}
onChange={(e) => setParentName(e.target.value)}
placeholder="z.B. Frau Mueller"
className="w-full border rounded-md p-2"
/>
</div>
{/* Situation */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung der Situation *
</label>
<textarea
value={situation}
onChange={(e) => setSituation(e.target.value)}
placeholder="Beschreiben Sie die Situation sachlich und konkret..."
rows={4}
className="w-full border rounded-md p-2"
/>
</div>
{/* Additional Info */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Zusaetzliche Informationen (optional)
</label>
<textarea
value={additionalInfo}
onChange={(e) => setAdditionalInfo(e.target.value)}
placeholder="Besondere Umstaende, gewuenschte Termine, etc."
rows={2}
className="w-full border rounded-md p-2"
/>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
{error}
</div>
)}
{/* Generate Button */}
<button
onClick={onGenerate}
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Wird generiert...' : 'Nachricht generieren'}
</button>
</div>
{/* GFK Info Toggle */}
<GFKInfoPanel gfkPrinciples={gfkPrinciples} />
</div>
)
}
function GFKInfoPanel({ gfkPrinciples }: { gfkPrinciples: GFKPrinciple[] }) {
const [showGFKInfo, setShowGFKInfo] = useState(false)
return (
<div className="bg-white rounded-lg shadow p-6">
<button
onClick={() => setShowGFKInfo(!showGFKInfo)}
className="flex items-center justify-between w-full text-left"
>
<span className="font-semibold">Was ist GFK?</span>
<span>{showGFKInfo ? '-' : '+'}</span>
</button>
{showGFKInfo && (
<div className="mt-4 space-y-4">
<p className="text-sm text-gray-600">
Die Gewaltfreie Kommunikation (GFK) nach Marshall Rosenberg
ist ein Kommunikationsmodell, das auf vier Schritten basiert:
</p>
{gfkPrinciples.map((p, i) => (
<div key={i} className="border-l-4 border-blue-500 pl-3">
<h4 className="font-medium">{i + 1}. {p.principle}</h4>
<p className="text-sm text-gray-600">{p.description}</p>
<p className="text-xs text-gray-500 italic mt-1">
Beispiel: {p.example}
</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,165 @@
import { useState } from 'react'
import type { ValidationResult, LegalReference } from './types'
interface OutputAreaProps {
generatedMessage: string
setGeneratedMessage: (msg: string) => void
subject: string
validation: ValidationResult | null
legalRefs: LegalReference[]
loading: boolean
onImprove: () => void
onCopy: () => void
}
export function OutputArea({
generatedMessage,
setGeneratedMessage,
subject,
validation,
legalRefs,
loading,
onImprove,
onCopy,
}: OutputAreaProps) {
const [showLegalInfo, setShowLegalInfo] = useState(false)
return (
<div className="lg:col-span-2 space-y-6">
{/* Generated Message */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Generierte Nachricht</h2>
{generatedMessage && (
<div className="flex gap-2">
<button
onClick={onImprove}
disabled={loading}
className="text-sm bg-green-100 text-green-700 px-3 py-1 rounded hover:bg-green-200"
>
Verbessern
</button>
<button
onClick={onCopy}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200"
>
Kopieren
</button>
</div>
)}
</div>
{subject && (
<div className="mb-3 p-2 bg-gray-50 rounded">
<span className="text-sm font-medium text-gray-500">Betreff: </span>
<span className="text-sm">{subject}</span>
</div>
)}
{generatedMessage ? (
<textarea
value={generatedMessage}
onChange={(e) => setGeneratedMessage(e.target.value)}
className="w-full border rounded-md p-4 min-h-[400px] font-mono text-sm"
/>
) : (
<div className="text-gray-400 text-center py-20 border-2 border-dashed rounded-md">
Hier erscheint die generierte Nachricht
</div>
)}
</div>
{/* Validation */}
{validation && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">GFK-Analyse</h3>
{/* Score */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">GFK-Score</span>
<span className={`text-sm font-bold ${
validation.gfk_score >= 0.8 ? 'text-green-600' :
validation.gfk_score >= 0.6 ? 'text-yellow-600' : 'text-red-600'
}`}>
{Math.round(validation.gfk_score * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
validation.gfk_score >= 0.8 ? 'bg-green-500' :
validation.gfk_score >= 0.6 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${validation.gfk_score * 100}%` }}
/>
</div>
</div>
{/* Issues */}
{validation.issues.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-red-600 mb-2">
Verbesserungsvorschlaege:
</h4>
<ul className="text-sm space-y-1">
{validation.issues.map((issue, i) => (
<li key={i} className="flex items-start">
<span className="text-red-500 mr-2">!</span>
<span>{issue}</span>
</li>
))}
</ul>
</div>
)}
{/* Positive Elements */}
{validation.positive_elements.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-600 mb-2">
Positive Elemente:
</h4>
<ul className="text-sm space-y-1">
{validation.positive_elements.map((elem, i) => (
<li key={i} className="flex items-start">
<span className="text-green-500 mr-2">+</span>
<span>{elem}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Legal References */}
{legalRefs.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<button
onClick={() => setShowLegalInfo(!showLegalInfo)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-lg font-semibold">Rechtliche Grundlagen</h3>
<span>{showLegalInfo ? '-' : '+'}</span>
</button>
{showLegalInfo && (
<div className="mt-4 space-y-4">
{legalRefs.map((ref, i) => (
<div key={i} className="border rounded-md p-3 bg-gray-50">
<div className="font-medium">
{ref.law} {ref.paragraph}
</div>
<div className="text-sm text-gray-600">{ref.title}</div>
<div className="text-sm mt-1">{ref.summary}</div>
<div className="text-xs text-blue-600 mt-1">
Relevanz: {ref.relevance}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
export const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export interface Option {
value: string
label: string
}
export interface LegalReference {
law: string
paragraph: string
title: string
summary: string
relevance: string
}
export interface GFKPrinciple {
principle: string
description: string
example: string
}
export interface ValidationResult {
is_valid: boolean
issues: string[]
suggestions: string[]
positive_elements: string[]
gfk_score: number
}

View File

@@ -9,35 +9,15 @@
*/
import { useState, useEffect } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
interface Option {
value: string
label: string
}
interface LegalReference {
law: string
paragraph: string
title: string
summary: string
relevance: string
}
interface GFKPrinciple {
principle: string
description: string
example: string
}
interface ValidationResult {
is_valid: boolean
issues: string[]
suggestions: string[]
positive_elements: string[]
gfk_score: number
}
import {
API_BASE,
Option,
LegalReference,
GFKPrinciple,
ValidationResult,
} from './_components/types'
import { InputForm } from './_components/InputForm'
import { OutputArea } from './_components/OutputArea'
export default function CommunicationToolPage() {
// Form state
@@ -64,8 +44,6 @@ export default function CommunicationToolPage() {
// UI state
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showGFKInfo, setShowGFKInfo] = useState(false)
const [showLegalInfo, setShowLegalInfo] = useState(false)
// Fetch options on mount
useEffect(() => {
@@ -214,304 +192,40 @@ export default function CommunicationToolPage() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Input Form */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Nachricht erstellen</h2>
<InputForm
communicationType={communicationType}
setCommunicationType={setCommunicationType}
tone={tone}
setTone={setTone}
state={state}
setState={setState}
studentName={studentName}
setStudentName={setStudentName}
parentName={parentName}
setParentName={setParentName}
situation={situation}
setSituation={setSituation}
additionalInfo={additionalInfo}
setAdditionalInfo={setAdditionalInfo}
types={types}
tones={tones}
states={states}
gfkPrinciples={gfkPrinciples}
error={error}
loading={loading}
onGenerate={handleGenerate}
/>
{/* Communication Type */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Art der Kommunikation *
</label>
<select
value={communicationType}
onChange={(e) => setCommunicationType(e.target.value)}
className="w-full border rounded-md p-2"
>
{types.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* Tone */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Tonalitaet
</label>
<select
value={tone}
onChange={(e) => setTone(e.target.value)}
className="w-full border rounded-md p-2"
>
{tones.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* State */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Bundesland (fuer rechtliche Referenzen)
</label>
<select
value={state}
onChange={(e) => setState(e.target.value)}
className="w-full border rounded-md p-2"
>
{states.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
{/* Student Name */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Name des Schuelers/der Schuelerin *
</label>
<input
type="text"
value={studentName}
onChange={(e) => setStudentName(e.target.value)}
placeholder="z.B. Max"
className="w-full border rounded-md p-2"
/>
</div>
{/* Parent Name */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Anrede der Eltern *
</label>
<input
type="text"
value={parentName}
onChange={(e) => setParentName(e.target.value)}
placeholder="z.B. Frau Mueller"
className="w-full border rounded-md p-2"
/>
</div>
{/* Situation */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung der Situation *
</label>
<textarea
value={situation}
onChange={(e) => setSituation(e.target.value)}
placeholder="Beschreiben Sie die Situation sachlich und konkret..."
rows={4}
className="w-full border rounded-md p-2"
/>
</div>
{/* Additional Info */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Zusaetzliche Informationen (optional)
</label>
<textarea
value={additionalInfo}
onChange={(e) => setAdditionalInfo(e.target.value)}
placeholder="Besondere Umstaende, gewuenschte Termine, etc."
rows={2}
className="w-full border rounded-md p-2"
/>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
{error}
</div>
)}
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Wird generiert...' : 'Nachricht generieren'}
</button>
</div>
{/* GFK Info Toggle */}
<div className="bg-white rounded-lg shadow p-6">
<button
onClick={() => setShowGFKInfo(!showGFKInfo)}
className="flex items-center justify-between w-full text-left"
>
<span className="font-semibold">Was ist GFK?</span>
<span>{showGFKInfo ? '-' : '+'}</span>
</button>
{showGFKInfo && (
<div className="mt-4 space-y-4">
<p className="text-sm text-gray-600">
Die Gewaltfreie Kommunikation (GFK) nach Marshall Rosenberg
ist ein Kommunikationsmodell, das auf vier Schritten basiert:
</p>
{gfkPrinciples.map((p, i) => (
<div key={i} className="border-l-4 border-blue-500 pl-3">
<h4 className="font-medium">{i + 1}. {p.principle}</h4>
<p className="text-sm text-gray-600">{p.description}</p>
<p className="text-xs text-gray-500 italic mt-1">
Beispiel: {p.example}
</p>
</div>
))}
</div>
)}
</div>
</div>
{/* Output Area */}
<div className="lg:col-span-2 space-y-6">
{/* Generated Message */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Generierte Nachricht</h2>
{generatedMessage && (
<div className="flex gap-2">
<button
onClick={handleImprove}
disabled={loading}
className="text-sm bg-green-100 text-green-700 px-3 py-1 rounded hover:bg-green-200"
>
Verbessern
</button>
<button
onClick={handleCopy}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200"
>
Kopieren
</button>
</div>
)}
</div>
{subject && (
<div className="mb-3 p-2 bg-gray-50 rounded">
<span className="text-sm font-medium text-gray-500">Betreff: </span>
<span className="text-sm">{subject}</span>
</div>
)}
{generatedMessage ? (
<textarea
value={generatedMessage}
onChange={(e) => setGeneratedMessage(e.target.value)}
className="w-full border rounded-md p-4 min-h-[400px] font-mono text-sm"
/>
) : (
<div className="text-gray-400 text-center py-20 border-2 border-dashed rounded-md">
Hier erscheint die generierte Nachricht
</div>
)}
</div>
{/* Validation */}
{validation && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">GFK-Analyse</h3>
{/* Score */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">GFK-Score</span>
<span className={`text-sm font-bold ${
validation.gfk_score >= 0.8 ? 'text-green-600' :
validation.gfk_score >= 0.6 ? 'text-yellow-600' : 'text-red-600'
}`}>
{Math.round(validation.gfk_score * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
validation.gfk_score >= 0.8 ? 'bg-green-500' :
validation.gfk_score >= 0.6 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${validation.gfk_score * 100}%` }}
/>
</div>
</div>
{/* Issues */}
{validation.issues.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-red-600 mb-2">
Verbesserungsvorschlaege:
</h4>
<ul className="text-sm space-y-1">
{validation.issues.map((issue, i) => (
<li key={i} className="flex items-start">
<span className="text-red-500 mr-2">!</span>
<span>{issue}</span>
</li>
))}
</ul>
</div>
)}
{/* Positive Elements */}
{validation.positive_elements.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-600 mb-2">
Positive Elemente:
</h4>
<ul className="text-sm space-y-1">
{validation.positive_elements.map((elem, i) => (
<li key={i} className="flex items-start">
<span className="text-green-500 mr-2">+</span>
<span>{elem}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Legal References */}
{legalRefs.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<button
onClick={() => setShowLegalInfo(!showLegalInfo)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-lg font-semibold">Rechtliche Grundlagen</h3>
<span>{showLegalInfo ? '-' : '+'}</span>
</button>
{showLegalInfo && (
<div className="mt-4 space-y-4">
{legalRefs.map((ref, i) => (
<div key={i} className="border rounded-md p-3 bg-gray-50">
<div className="font-medium">
{ref.law} {ref.paragraph}
</div>
<div className="text-sm text-gray-600">{ref.title}</div>
<div className="text-sm mt-1">{ref.summary}</div>
<div className="text-xs text-blue-600 mt-1">
Relevanz: {ref.relevance}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<OutputArea
generatedMessage={generatedMessage}
setGeneratedMessage={setGeneratedMessage}
subject={subject}
validation={validation}
legalRefs={legalRefs}
loading={loading}
onImprove={handleImprove}
onCopy={handleCopy}
/>
</div>
{/* Footer */}

View File

@@ -0,0 +1,55 @@
'use client'
import { useMemo } from 'react'
import { Risk, RISK_LEVEL_COLORS, calculateRiskLevel } from './risk-heatmap-types'
interface MiniRiskMatrixProps {
risks: Risk[]
size?: 'sm' | 'md'
}
export function MiniRiskMatrix({ risks, size = 'sm' }: MiniRiskMatrixProps) {
const matrix = useMemo(() => {
const m: Record<number, Record<number, number>> = {}
for (let l = 1; l <= 5; l++) {
m[l] = {}
for (let i = 1; i <= 5; i++) {
m[l][i] = 0
}
}
risks.forEach((r) => {
if (m[r.likelihood] && m[r.likelihood][r.impact] !== undefined) {
m[r.likelihood][r.impact]++
}
})
return m
}, [risks])
const cellSize = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'
const fontSize = size === 'sm' ? 'text-[8px]' : 'text-[10px]'
return (
<div className="inline-block">
{[5, 4, 3, 2, 1].map((l) => (
<div key={l} className="flex">
{[1, 2, 3, 4, 5].map((i) => {
const level = calculateRiskLevel(l, i)
const count = matrix[l][i]
const colors = RISK_LEVEL_COLORS[level]
return (
<div
key={i}
className={`${cellSize} ${colors.bg} border ${colors.border} flex items-center justify-center m-px rounded-sm`}
>
{count > 0 && (
<span className={`${fontSize} font-bold ${colors.text}`}>{count}</span>
)}
</div>
)
})}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { useMemo } from 'react'
import { Language } from '@/lib/compliance-i18n'
import { Risk, RISK_BADGE_COLORS } from './risk-heatmap-types'
interface RiskDistributionProps {
risks: Risk[]
lang?: Language
}
export function RiskDistribution({ risks, lang = 'de' }: RiskDistributionProps) {
const stats = useMemo(() => {
const byLevel: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 }
risks.forEach((r) => {
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
})
return byLevel
}, [risks])
const total = risks.length || 1
return (
<div className="space-y-2">
{['critical', 'high', 'medium', 'low'].map((level) => {
const count = stats[level]
const percentage = (count / total) * 100
return (
<div key={level} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16 capitalize">{level}</span>
<div className="flex-1 h-4 bg-slate-100 rounded overflow-hidden">
<div
className={`h-full ${RISK_BADGE_COLORS[level]} transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-xs font-medium text-slate-700 w-8 text-right">{count}</span>
</div>
)
})}
</div>
)
}

View File

@@ -14,84 +14,23 @@
*/
import { useState, useMemo } from 'react'
import { Language, getTerm } from '@/lib/compliance-i18n'
import { Language } from '@/lib/compliance-i18n'
import {
Risk,
Control,
RiskHeatmapProps,
RISK_LEVEL_COLORS,
RISK_BADGE_COLORS,
CATEGORY_OPTIONS,
STATUS_OPTIONS,
calculateRiskLevel,
} from './risk-heatmap-types'
export interface Risk {
id: string
risk_id: string
title: string
description?: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls?: string[] | null
residual_likelihood?: number | null
residual_impact?: number | null
residual_risk?: string | null
owner?: string
status: string
treatment_plan?: string
}
export interface Control {
id: string
control_id: string
title: string
domain: string
status: string
}
interface RiskHeatmapProps {
risks: Risk[]
controls?: Control[]
lang?: Language
onRiskClick?: (risk: Risk) => void
onCellClick?: (likelihood: number, impact: number, risks: Risk[]) => void
showComparison?: boolean
height?: number
}
const RISK_LEVEL_COLORS: Record<string, { bg: string; text: string; border: string }> = {
low: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' },
high: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
critical: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
}
const RISK_BADGE_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const CATEGORY_OPTIONS: Record<string, { de: string; en: string }> = {
data_breach: { de: 'Datenschutzverletzung', en: 'Data Breach' },
compliance_gap: { de: 'Compliance-Luecke', en: 'Compliance Gap' },
vendor_risk: { de: 'Lieferantenrisiko', en: 'Vendor Risk' },
operational: { de: 'Betriebsrisiko', en: 'Operational Risk' },
technical: { de: 'Technisches Risiko', en: 'Technical Risk' },
legal: { de: 'Rechtliches Risiko', en: 'Legal Risk' },
}
const STATUS_OPTIONS: Record<string, { de: string; en: string; color: string }> = {
open: { de: 'Offen', en: 'Open', color: 'bg-slate-100 text-slate-700' },
mitigated: { de: 'Gemindert', en: 'Mitigated', color: 'bg-green-100 text-green-700' },
accepted: { de: 'Akzeptiert', en: 'Accepted', color: 'bg-blue-100 text-blue-700' },
transferred: { de: 'Uebertragen', en: 'Transferred', color: 'bg-purple-100 text-purple-700' },
}
/**
* Calculate risk level from likelihood and impact
*/
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
// Re-export types and utilities for backward compatibility
export type { Risk, Control } from './risk-heatmap-types'
export { calculateRiskLevel } from './risk-heatmap-types'
export { MiniRiskMatrix } from './MiniRiskMatrix'
export { RiskDistribution } from './RiskDistribution'
export default function RiskHeatmap({
risks,
@@ -108,13 +47,11 @@ export default function RiskHeatmap({
const [selectedCell, setSelectedCell] = useState<{ l: number; i: number } | null>(null)
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
// Get unique categories from risks
const categories = useMemo(() => {
const cats = new Set(risks.map((r) => r.category))
return Array.from(cats).sort()
}, [risks])
// Filter risks
const filteredRisks = useMemo(() => {
return risks.filter((r) => {
if (filterCategory && r.category !== filterCategory) return false
@@ -123,154 +60,75 @@ export default function RiskHeatmap({
})
}, [risks, filterCategory, filterStatus])
// Build matrix data structure for inherent risk
const inherentMatrix = useMemo(() => {
const buildMatrix = (useResidual: boolean) => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
for (let l = 1; l <= 5; l++) { matrix[l] = {}; for (let i = 1; i <= 5; i++) { matrix[l][i] = [] } }
filteredRisks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
const likelihood = useResidual ? (risk.residual_likelihood ?? risk.likelihood) : risk.likelihood
const impact = useResidual ? (risk.residual_impact ?? risk.impact) : risk.impact
if (matrix[likelihood] && matrix[likelihood][impact]) matrix[likelihood][impact].push(risk)
})
return matrix
}, [filteredRisks])
}
// Build matrix data structure for residual risk
const residualMatrix = useMemo(() => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
filteredRisks.forEach((risk) => {
const likelihood = risk.residual_likelihood ?? risk.likelihood
const impact = risk.residual_impact ?? risk.impact
if (matrix[likelihood] && matrix[likelihood][impact]) {
matrix[likelihood][impact].push(risk)
}
})
return matrix
}, [filteredRisks])
const inherentMatrix = useMemo(() => buildMatrix(false), [filteredRisks])
const residualMatrix = useMemo(() => buildMatrix(true), [filteredRisks])
// Get controls for a risk
const getControlsForRisk = (risk: Risk): Control[] => {
if (!risk.mitigating_controls || risk.mitigating_controls.length === 0) return []
return controls.filter((c) => risk.mitigating_controls?.includes(c.control_id))
}
// Calculate statistics
const stats = useMemo(() => {
const total = filteredRisks.length
const byLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
const byStatus: Record<string, number> = {}
filteredRisks.forEach((r) => {
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
byStatus[r.status] = (byStatus[r.status] || 0) + 1
})
// Calculate residual stats
filteredRisks.forEach((r) => { byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1; byStatus[r.status] = (byStatus[r.status] || 0) + 1 })
const residualByLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
filteredRisks.forEach((r) => {
const level = r.residual_risk || r.inherent_risk
residualByLevel[level] = (residualByLevel[level] || 0) + 1
})
filteredRisks.forEach((r) => { const level = r.residual_risk || r.inherent_risk; residualByLevel[level] = (residualByLevel[level] || 0) + 1 })
return { total, byLevel, byStatus, residualByLevel }
}, [filteredRisks])
// Handle cell click
const handleCellClick = (likelihood: number, impact: number, matrix: Record<number, Record<number, Risk[]>>) => {
const cellRisks = matrix[likelihood][impact]
if (selectedCell?.l === likelihood && selectedCell?.i === impact) {
setSelectedCell(null)
} else {
setSelectedCell({ l: likelihood, i: impact })
}
if (selectedCell?.l === likelihood && selectedCell?.i === impact) { setSelectedCell(null) }
else { setSelectedCell({ l: likelihood, i: impact }) }
onCellClick?.(likelihood, impact, cellRisks)
}
// Handle risk click
const handleRiskClick = (risk: Risk) => {
if (selectedRisk?.id === risk.id) {
setSelectedRisk(null)
} else {
setSelectedRisk(risk)
}
if (selectedRisk?.id === risk.id) { setSelectedRisk(null) } else { setSelectedRisk(risk) }
onRiskClick?.(risk)
}
// Render single matrix
const renderMatrix = (matrix: Record<number, Record<number, Risk[]>>, title: string) => (
<div className="flex-1">
<h4 className="text-sm font-medium text-slate-700 mb-3 text-center">{title}</h4>
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-12">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-16 text-center text-xs font-medium text-slate-500 pb-1">
I{i}
</div>
))}
{[1, 2, 3, 4, 5].map((i) => (<div key={i} className="w-16 text-center text-xs font-medium text-slate-500 pb-1">I{i}</div>))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-12 text-xs font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
<div className="w-12 text-xs font-medium text-slate-500 text-right pr-2">L{likelihood}</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
const isSelected = selectedCell?.l === likelihood && selectedCell?.i === impact
const colors = RISK_LEVEL_COLORS[level]
return (
<div
key={impact}
onClick={() => handleCellClick(likelihood, impact, matrix)}
className={`
w-16 h-14 border m-0.5 rounded flex flex-col items-center justify-center
cursor-pointer transition-all
${colors.bg} ${colors.border}
${isSelected ? 'ring-2 ring-primary-500 ring-offset-1' : 'hover:ring-1 hover:ring-slate-300'}
`}
>
<div key={impact} onClick={() => handleCellClick(likelihood, impact, matrix)}
className={`w-16 h-14 border m-0.5 rounded flex flex-col items-center justify-center cursor-pointer transition-all ${colors.bg} ${colors.border} ${isSelected ? 'ring-2 ring-primary-500 ring-offset-1' : 'hover:ring-1 hover:ring-slate-300'}`}>
{cellRisks.length > 0 ? (
<div className="flex flex-wrap gap-0.5 justify-center max-h-12 overflow-hidden">
{cellRisks.slice(0, 4).map((r) => (
<button
key={r.id}
onClick={(e) => {
e.stopPropagation()
handleRiskClick(r)
}}
className={`
px-1.5 py-0.5 text-[10px] font-medium rounded text-white
${RISK_BADGE_COLORS[r.inherent_risk] || 'bg-slate-500'}
hover:opacity-80 transition-opacity
${selectedRisk?.id === r.id ? 'ring-2 ring-white' : ''}
`}
title={r.title}
>
{r.risk_id.replace('RISK-', 'R')}
</button>
<button key={r.id} onClick={(e) => { e.stopPropagation(); handleRiskClick(r) }}
className={`px-1.5 py-0.5 text-[10px] font-medium rounded text-white ${RISK_BADGE_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80 transition-opacity ${selectedRisk?.id === r.id ? 'ring-2 ring-white' : ''}`}
title={r.title}>{r.risk_id.replace('RISK-', 'R')}</button>
))}
{cellRisks.length > 4 && (
<span className="text-[10px] text-slate-600">+{cellRisks.length - 4}</span>
)}
{cellRisks.length > 4 && <span className="text-[10px] text-slate-600">+{cellRisks.length - 4}</span>}
</div>
) : (
<span className="text-[10px] text-slate-400">-</span>
)}
) : <span className="text-[10px] text-slate-400">-</span>}
</div>
)
})}
@@ -280,105 +138,35 @@ export default function RiskHeatmap({
</div>
)
// Render risk details panel
const renderRiskDetails = () => {
const risksToShow = selectedRisk
? [selectedRisk]
: selectedCell
? (viewMode === 'residual' ? residualMatrix : inherentMatrix)[selectedCell.l][selectedCell.i]
: []
if (risksToShow.length === 0) {
return (
<div className="text-center text-slate-400 py-8">
{lang === 'de'
? 'Klicken Sie auf eine Zelle oder ein Risiko fuer Details'
: 'Click a cell or risk for details'}
</div>
)
}
const risksToShow = selectedRisk ? [selectedRisk]
: selectedCell ? (viewMode === 'residual' ? residualMatrix : inherentMatrix)[selectedCell.l][selectedCell.i] : []
if (risksToShow.length === 0) return (<div className="text-center text-slate-400 py-8">{lang === 'de' ? 'Klicken Sie auf eine Zelle oder ein Risiko fuer Details' : 'Click a cell or risk for details'}</div>)
return (
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{risksToShow.map((risk) => {
const riskControls = getControlsForRisk(risk)
return (
<div
key={risk.id}
className={`
p-3 rounded-lg border transition-colors
${selectedRisk?.id === risk.id ? 'border-primary-500 bg-primary-50' : 'border-slate-200'}
`}
>
<div key={risk.id} className={`p-3 rounded-lg border transition-colors ${selectedRisk?.id === risk.id ? 'border-primary-500 bg-primary-50' : 'border-slate-200'}`}>
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<span className="font-mono text-sm font-medium text-primary-600">{risk.risk_id}</span>
<h4 className="font-medium text-slate-900">{risk.title}</h4>
</div>
<div><span className="font-mono text-sm font-medium text-primary-600">{risk.risk_id}</span><h4 className="font-medium text-slate-900">{risk.title}</h4></div>
<div className="flex gap-1.5">
<span className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_BADGE_COLORS[risk.inherent_risk]}`}>
{risk.inherent_risk}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_OPTIONS[risk.status]?.color || 'bg-slate-100'}`}>
{STATUS_OPTIONS[risk.status]?.[lang] || risk.status}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_BADGE_COLORS[risk.inherent_risk]}`}>{risk.inherent_risk}</span>
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_OPTIONS[risk.status]?.color || 'bg-slate-100'}`}>{STATUS_OPTIONS[risk.status]?.[lang] || risk.status}</span>
</div>
</div>
{risk.description && (
<p className="text-sm text-slate-600 mb-2">{risk.description}</p>
)}
{risk.description && <p className="text-sm text-slate-600 mb-2">{risk.description}</p>}
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<span className="text-slate-500">{lang === 'de' ? 'Kategorie' : 'Category'}:</span>{' '}
<span className="font-medium">{CATEGORY_OPTIONS[risk.category]?.[lang] || risk.category}</span>
</div>
<div>
<span className="text-slate-500">{lang === 'de' ? 'Verantwortlich' : 'Owner'}:</span>{' '}
<span className="font-medium">{risk.owner || '-'}</span>
</div>
<div>
<span className="text-slate-500">{lang === 'de' ? 'Inhaerent' : 'Inherent'}:</span>{' '}
<span className="font-medium">{risk.likelihood} x {risk.impact} = {risk.likelihood * risk.impact}</span>
</div>
{(risk.residual_likelihood && risk.residual_impact) && (
<div>
<span className="text-slate-500">{lang === 'de' ? 'Residual' : 'Residual'}:</span>{' '}
<span className="font-medium">{risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}</span>
</div>
)}
<div><span className="text-slate-500">{lang === 'de' ? 'Kategorie' : 'Category'}:</span> <span className="font-medium">{CATEGORY_OPTIONS[risk.category]?.[lang] || risk.category}</span></div>
<div><span className="text-slate-500">{lang === 'de' ? 'Verantwortlich' : 'Owner'}:</span> <span className="font-medium">{risk.owner || '-'}</span></div>
<div><span className="text-slate-500">{lang === 'de' ? 'Inhaerent' : 'Inherent'}:</span> <span className="font-medium">{risk.likelihood} x {risk.impact} = {risk.likelihood * risk.impact}</span></div>
{(risk.residual_likelihood && risk.residual_impact) && (<div><span className="text-slate-500">Residual:</span> <span className="font-medium">{risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}</span></div>)}
</div>
{/* Mitigating Controls */}
{riskControls.length > 0 && (
<div className="mt-2 pt-2 border-t">
<p className="text-xs text-slate-500 mb-1">
{lang === 'de' ? 'Mitigierende Massnahmen' : 'Mitigating Controls'} ({riskControls.length})
</p>
<div className="flex flex-wrap gap-1">
{riskControls.map((ctrl) => (
<span
key={ctrl.control_id}
className="px-2 py-0.5 text-xs bg-slate-100 text-slate-700 rounded"
title={ctrl.title}
>
{ctrl.control_id}
</span>
))}
</div>
</div>
)}
{risk.treatment_plan && (
<div className="mt-2 pt-2 border-t">
<p className="text-xs text-slate-500 mb-1">
{lang === 'de' ? 'Behandlungsplan' : 'Treatment Plan'}
</p>
<p className="text-xs text-slate-600">{risk.treatment_plan}</p>
</div>
<div className="mt-2 pt-2 border-t"><p className="text-xs text-slate-500 mb-1">{lang === 'de' ? 'Mitigierende Massnahmen' : 'Mitigating Controls'} ({riskControls.length})</p>
<div className="flex flex-wrap gap-1">{riskControls.map((ctrl) => (<span key={ctrl.control_id} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-700 rounded" title={ctrl.title}>{ctrl.control_id}</span>))}</div></div>
)}
{risk.treatment_plan && (<div className="mt-2 pt-2 border-t"><p className="text-xs text-slate-500 mb-1">{lang === 'de' ? 'Behandlungsplan' : 'Treatment Plan'}</p><p className="text-xs text-slate-600">{risk.treatment_plan}</p></div>)}
</div>
)
})}
@@ -387,101 +175,40 @@ export default function RiskHeatmap({
}
if (risks.length === 0) {
return (
<div className="bg-white rounded-xl shadow-sm border p-8 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-slate-500">
{lang === 'de' ? 'Keine Risiken vorhanden' : 'No risks available'}
</p>
</div>
)
return (<div className="bg-white rounded-xl shadow-sm border p-8 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<p className="text-slate-500">{lang === 'de' ? 'Keine Risiken vorhanden' : 'No risks available'}</p>
</div>)
}
return (
<div className="space-y-4">
{/* Statistics Header */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="bg-white rounded-lg border p-3">
<p className="text-xs text-slate-500">{lang === 'de' ? 'Gesamt' : 'Total'}</p>
<p className="text-xl font-bold text-slate-800">{stats.total}</p>
</div>
<div className="bg-white rounded-lg border p-3">
<p className="text-xs text-slate-500">Critical</p>
<p className="text-xl font-bold text-red-600">{stats.byLevel.critical}</p>
</div>
<div className="bg-white rounded-lg border p-3">
<p className="text-xs text-slate-500">High</p>
<p className="text-xl font-bold text-orange-600">{stats.byLevel.high}</p>
</div>
<div className="bg-white rounded-lg border p-3">
<p className="text-xs text-slate-500">Medium</p>
<p className="text-xl font-bold text-yellow-600">{stats.byLevel.medium}</p>
</div>
<div className="bg-white rounded-lg border p-3">
<p className="text-xs text-slate-500">Low</p>
<p className="text-xl font-bold text-green-600">{stats.byLevel.low}</p>
</div>
<div className="bg-white rounded-lg border p-3"><p className="text-xs text-slate-500">{lang === 'de' ? 'Gesamt' : 'Total'}</p><p className="text-xl font-bold text-slate-800">{stats.total}</p></div>
<div className="bg-white rounded-lg border p-3"><p className="text-xs text-slate-500">Critical</p><p className="text-xl font-bold text-red-600">{stats.byLevel.critical}</p></div>
<div className="bg-white rounded-lg border p-3"><p className="text-xs text-slate-500">High</p><p className="text-xl font-bold text-orange-600">{stats.byLevel.high}</p></div>
<div className="bg-white rounded-lg border p-3"><p className="text-xs text-slate-500">Medium</p><p className="text-xl font-bold text-yellow-600">{stats.byLevel.medium}</p></div>
<div className="bg-white rounded-lg border p-3"><p className="text-xs text-slate-500">Low</p><p className="text-xl font-bold text-green-600">{stats.byLevel.low}</p></div>
</div>
{/* Filters and Controls */}
<div className="bg-white rounded-xl shadow-sm border p-4">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm">
<option value="">{lang === 'de' ? 'Alle Kategorien' : 'All Categories'}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_OPTIONS[cat]?.[lang] || cat}
</option>
))}
{categories.map((cat) => <option key={cat} value={cat}>{CATEGORY_OPTIONS[cat]?.[lang] || cat}</option>)}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm">
<option value="">{lang === 'de' ? 'Alle Status' : 'All Status'}</option>
{Object.entries(STATUS_OPTIONS).map(([key, val]) => (
<option key={key} value={key}>
{val[lang]}
</option>
))}
{Object.entries(STATUS_OPTIONS).map(([key, val]) => <option key={key} value={key}>{val[lang]}</option>)}
</select>
<div className="flex-1" />
{showComparison && (
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('inherent')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'inherent' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
{lang === 'de' ? 'Inhaerent' : 'Inherent'}
</button>
<button
onClick={() => setViewMode('residual')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'residual' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Residual
</button>
<button
onClick={() => setViewMode('comparison')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'comparison' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
{lang === 'de' ? 'Vergleich' : 'Compare'}
</button>
<button onClick={() => setViewMode('inherent')} className={`px-3 py-1.5 text-sm rounded-md transition-colors ${viewMode === 'inherent' ? 'bg-white shadow text-slate-900' : 'text-slate-600'}`}>{lang === 'de' ? 'Inhaerent' : 'Inherent'}</button>
<button onClick={() => setViewMode('residual')} className={`px-3 py-1.5 text-sm rounded-md transition-colors ${viewMode === 'residual' ? 'bg-white shadow text-slate-900' : 'text-slate-600'}`}>Residual</button>
<button onClick={() => setViewMode('comparison')} className={`px-3 py-1.5 text-sm rounded-md transition-colors ${viewMode === 'comparison' ? 'bg-white shadow text-slate-900' : 'text-slate-600'}`}>{lang === 'de' ? 'Vergleich' : 'Compare'}</button>
</div>
)}
</div>
@@ -489,7 +216,6 @@ export default function RiskHeatmap({
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Matrix View(s) */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-4">
{viewMode === 'comparison' ? (
<div className="flex gap-6 overflow-x-auto">
@@ -498,183 +224,35 @@ export default function RiskHeatmap({
{renderMatrix(residualMatrix, 'Residual')}
</div>
) : viewMode === 'residual' ? (
<div className="flex justify-center">
{renderMatrix(residualMatrix, lang === 'de' ? 'Residuales Risiko' : 'Residual Risk')}
</div>
<div className="flex justify-center">{renderMatrix(residualMatrix, lang === 'de' ? 'Residuales Risiko' : 'Residual Risk')}</div>
) : (
<div className="flex justify-center">
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaeentes Risiko' : 'Inherent Risk')}
</div>
<div className="flex justify-center">{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaeentes Risiko' : 'Inherent Risk')}</div>
)}
{/* Legend */}
<div className="flex gap-4 mt-4 pt-4 border-t justify-center flex-wrap">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-xs text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-xs text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-xs text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-xs text-slate-600">Critical (20-25)</span>
</div>
<div className="flex items-center gap-2"><div className="w-4 h-4 bg-green-500 rounded" /><span className="text-xs text-slate-600">Low (1-5)</span></div>
<div className="flex items-center gap-2"><div className="w-4 h-4 bg-yellow-500 rounded" /><span className="text-xs text-slate-600">Medium (6-11)</span></div>
<div className="flex items-center gap-2"><div className="w-4 h-4 bg-orange-500 rounded" /><span className="text-xs text-slate-600">High (12-19)</span></div>
<div className="flex items-center gap-2"><div className="w-4 h-4 bg-red-500 rounded" /><span className="text-xs text-slate-600">Critical (20-25)</span></div>
</div>
</div>
{/* Risk Details Panel */}
<div className="bg-white rounded-xl shadow-sm border p-4">
<h3 className="text-sm font-medium text-slate-700 mb-3">
{lang === 'de' ? 'Risiko-Details' : 'Risk Details'}
</h3>
<h3 className="text-sm font-medium text-slate-700 mb-3">{lang === 'de' ? 'Risiko-Details' : 'Risk Details'}</h3>
{renderRiskDetails()}
</div>
</div>
{/* Risk Movement Summary (when comparison mode) */}
{/* Risk Movement Summary */}
{viewMode === 'comparison' && (
<div className="bg-white rounded-xl shadow-sm border p-4">
<h3 className="text-sm font-medium text-slate-700 mb-3">
{lang === 'de' ? 'Risikoveraenderung durch Massnahmen' : 'Risk Change from Controls'}
</h3>
<h3 className="text-sm font-medium text-slate-700 mb-3">{lang === 'de' ? 'Risikoveraenderung durch Massnahmen' : 'Risk Change from Controls'}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-green-600">
{stats.byLevel.critical - stats.residualByLevel.critical}
</p>
<p className="text-xs text-slate-500">
{lang === 'de' ? 'Critical reduziert' : 'Critical reduced'}
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-orange-600">
{stats.byLevel.high - stats.residualByLevel.high}
</p>
<p className="text-xs text-slate-500">
{lang === 'de' ? 'High reduziert' : 'High reduced'}
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-yellow-600">
{stats.byLevel.medium - stats.residualByLevel.medium}
</p>
<p className="text-xs text-slate-500">
{lang === 'de' ? 'Medium reduziert' : 'Medium reduced'}
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-slate-600">
{filteredRisks.filter((r) => r.residual_likelihood && r.residual_impact).length}
</p>
<p className="text-xs text-slate-500">
{lang === 'de' ? 'Bewertet' : 'Assessed'}
</p>
</div>
<div className="text-center"><p className="text-2xl font-bold text-green-600">{stats.byLevel.critical - stats.residualByLevel.critical}</p><p className="text-xs text-slate-500">{lang === 'de' ? 'Critical reduziert' : 'Critical reduced'}</p></div>
<div className="text-center"><p className="text-2xl font-bold text-orange-600">{stats.byLevel.high - stats.residualByLevel.high}</p><p className="text-xs text-slate-500">{lang === 'de' ? 'High reduziert' : 'High reduced'}</p></div>
<div className="text-center"><p className="text-2xl font-bold text-yellow-600">{stats.byLevel.medium - stats.residualByLevel.medium}</p><p className="text-xs text-slate-500">{lang === 'de' ? 'Medium reduziert' : 'Medium reduced'}</p></div>
<div className="text-center"><p className="text-2xl font-bold text-slate-600">{filteredRisks.filter((r) => r.residual_likelihood && r.residual_impact).length}</p><p className="text-xs text-slate-500">{lang === 'de' ? 'Bewertet' : 'Assessed'}</p></div>
</div>
</div>
)}
</div>
)
}
/**
* Mini Risk Matrix for compact display
*/
interface MiniRiskMatrixProps {
risks: Risk[]
size?: 'sm' | 'md'
}
export function MiniRiskMatrix({ risks, size = 'sm' }: MiniRiskMatrixProps) {
const matrix = useMemo(() => {
const m: Record<number, Record<number, number>> = {}
for (let l = 1; l <= 5; l++) {
m[l] = {}
for (let i = 1; i <= 5; i++) {
m[l][i] = 0
}
}
risks.forEach((r) => {
if (m[r.likelihood] && m[r.likelihood][r.impact] !== undefined) {
m[r.likelihood][r.impact]++
}
})
return m
}, [risks])
const cellSize = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'
const fontSize = size === 'sm' ? 'text-[8px]' : 'text-[10px]'
return (
<div className="inline-block">
{[5, 4, 3, 2, 1].map((l) => (
<div key={l} className="flex">
{[1, 2, 3, 4, 5].map((i) => {
const level = calculateRiskLevel(l, i)
const count = matrix[l][i]
const colors = RISK_LEVEL_COLORS[level]
return (
<div
key={i}
className={`${cellSize} ${colors.bg} border ${colors.border} flex items-center justify-center m-px rounded-sm`}
>
{count > 0 && (
<span className={`${fontSize} font-bold ${colors.text}`}>{count}</span>
)}
</div>
)
})}
</div>
))}
</div>
)
}
/**
* Risk Distribution Chart (simple bar representation)
*/
interface RiskDistributionProps {
risks: Risk[]
lang?: Language
}
export function RiskDistribution({ risks, lang = 'de' }: RiskDistributionProps) {
const stats = useMemo(() => {
const byLevel: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 }
risks.forEach((r) => {
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
})
return byLevel
}, [risks])
const total = risks.length || 1
return (
<div className="space-y-2">
{['critical', 'high', 'medium', 'low'].map((level) => {
const count = stats[level]
const percentage = (count / total) * 100
return (
<div key={level} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16 capitalize">{level}</span>
<div className="flex-1 h-4 bg-slate-100 rounded overflow-hidden">
<div
className={`h-full ${RISK_BADGE_COLORS[level]} transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-xs font-medium text-slate-700 w-8 text-right">{count}</span>
</div>
)
})}
</div>
)
}

View File

@@ -20,5 +20,7 @@ export { TrafficLightIndicator, MiniSparkline } from './ComplianceTrendChart'
export { default as DependencyMap } from './DependencyMap'
export { default as RiskHeatmap } from './RiskHeatmap'
export { MiniRiskMatrix, RiskDistribution, calculateRiskLevel } from './RiskHeatmap'
export type { Risk, Control } from './RiskHeatmap'
export { MiniRiskMatrix } from './MiniRiskMatrix'
export { RiskDistribution } from './RiskDistribution'
export { calculateRiskLevel } from './risk-heatmap-types'
export type { Risk, Control } from './risk-heatmap-types'

View File

@@ -0,0 +1,78 @@
import { Language } from '@/lib/compliance-i18n'
export interface Risk {
id: string
risk_id: string
title: string
description?: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls?: string[] | null
residual_likelihood?: number | null
residual_impact?: number | null
residual_risk?: string | null
owner?: string
status: string
treatment_plan?: string
}
export interface Control {
id: string
control_id: string
title: string
domain: string
status: string
}
export interface RiskHeatmapProps {
risks: Risk[]
controls?: Control[]
lang?: Language
onRiskClick?: (risk: Risk) => void
onCellClick?: (likelihood: number, impact: number, risks: Risk[]) => void
showComparison?: boolean
height?: number
}
export const RISK_LEVEL_COLORS: Record<string, { bg: string; text: string; border: string }> = {
low: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' },
high: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
critical: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
}
export const RISK_BADGE_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
export const CATEGORY_OPTIONS: Record<string, { de: string; en: string }> = {
data_breach: { de: 'Datenschutzverletzung', en: 'Data Breach' },
compliance_gap: { de: 'Compliance-Luecke', en: 'Compliance Gap' },
vendor_risk: { de: 'Lieferantenrisiko', en: 'Vendor Risk' },
operational: { de: 'Betriebsrisiko', en: 'Operational Risk' },
technical: { de: 'Technisches Risiko', en: 'Technical Risk' },
legal: { de: 'Rechtliches Risiko', en: 'Legal Risk' },
}
export const STATUS_OPTIONS: Record<string, { de: string; en: string; color: string }> = {
open: { de: 'Offen', en: 'Open', color: 'bg-slate-100 text-slate-700' },
mitigated: { de: 'Gemindert', en: 'Mitigated', color: 'bg-green-100 text-green-700' },
accepted: { de: 'Akzeptiert', en: 'Accepted', color: 'bg-blue-100 text-blue-700' },
transferred: { de: 'Uebertragen', en: 'Transferred', color: 'bg-purple-100 text-purple-700' },
}
/**
* Calculate risk level from likelihood and impact
*/
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}