[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:
116
website/app/admin/builds/wizard/_components/StepContent.tsx
Normal file
116
website/app/admin/builds/wizard/_components/StepContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
website/app/admin/builds/wizard/_components/WizardComponents.tsx
Normal file
123
website/app/admin/builds/wizard/_components/WizardComponents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
website/app/admin/builds/wizard/_components/types.ts
Normal file
205
website/app/admin/builds/wizard/_components/types.ts
Normal 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']
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
177
website/app/admin/communication/_components/MeetingsAndRooms.tsx
Normal file
177
website/app/admin/communication/_components/MeetingsAndRooms.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
website/app/admin/communication/_components/ServiceCards.tsx
Normal file
96
website/app/admin/communication/_components/ServiceCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
website/app/admin/communication/_components/TrafficSection.tsx
Normal file
116
website/app/admin/communication/_components/TrafficSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
website/app/admin/communication/_components/helpers.ts
Normal file
91
website/app/admin/communication/_components/helpers.ts
Normal 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)'
|
||||
}
|
||||
}
|
||||
64
website/app/admin/communication/_components/types.ts
Normal file
64
website/app/admin/communication/_components/types.ts
Normal 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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
152
website/app/admin/middleware/test-wizard/_components/types.ts
Normal file
152
website/app/admin/middleware/test-wizard/_components/types.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
131
website/app/admin/middleware/wizard/_components/TestCards.tsx
Normal file
131
website/app/admin/middleware/wizard/_components/TestCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
152
website/app/admin/middleware/wizard/_components/types.ts
Normal file
152
website/app/admin/middleware/wizard/_components/types.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
}
|
||||
140
website/app/admin/middleware/wizard/_components/useTestWizard.ts
Normal file
140
website/app/admin/middleware/wizard/_components/useTestWizard.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
59
website/app/admin/multiplayer/wizard/_components/Sidebar.tsx
Normal file
59
website/app/admin/multiplayer/wizard/_components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
website/app/admin/multiplayer/wizard/_components/StepContent.tsx
Normal file
137
website/app/admin/multiplayer/wizard/_components/StepContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
226
website/app/admin/multiplayer/wizard/_components/types.ts
Normal file
226
website/app/admin/multiplayer/wizard/_components/types.ts
Normal 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'
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
246
website/app/admin/rag/components/CollectionCard.tsx
Normal file
246
website/app/admin/rag/components/CollectionCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
106
website/app/admin/rag/components/IngestionHistory.tsx
Normal file
106
website/app/admin/rag/components/IngestionHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
92
website/app/admin/sbom/_components/SBOMTable.tsx
Normal file
92
website/app/admin/sbom/_components/SBOMTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
website/app/admin/sbom/_components/sbom-data.ts
Normal file
164
website/app/admin/sbom/_components/sbom-data.ts
Normal 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'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
100
website/app/admin/sbom/wizard/_components/CategoryDemo.tsx
Normal file
100
website/app/admin/sbom/wizard/_components/CategoryDemo.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
124
website/app/admin/sbom/wizard/_components/types.ts
Normal file
124
website/app/admin/sbom/wizard/_components/types.ts
Normal 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'],
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
134
website/app/admin/staff-search/_components/StaffDetailPanel.tsx
Normal file
134
website/app/admin/staff-search/_components/StaffDetailPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/staff-search/_components/StaffListItem.tsx
Normal file
97
website/app/admin/staff-search/_components/StaffListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
website/app/admin/staff-search/_components/helpers.ts
Normal file
26
website/app/admin/staff-search/_components/helpers.ts
Normal 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'
|
||||
}
|
||||
52
website/app/admin/staff-search/_components/types.ts
Normal file
52
website/app/admin/staff-search/_components/types.ts
Normal 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'
|
||||
120
website/app/admin/staff-search/_components/useStaffSearch.ts
Normal file
120
website/app/admin/staff-search/_components/useStaffSearch.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
89
website/app/admin/uni-crawler/_components/QueueList.tsx
Normal file
89
website/app/admin/uni-crawler/_components/QueueList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
website/app/admin/uni-crawler/_components/types.ts
Normal file
63
website/app/admin/uni-crawler/_components/types.ts
Normal 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' }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
220
website/app/admin/voice/_components/TabContent.tsx
Normal file
220
website/app/admin/voice/_components/TabContent.tsx
Normal 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: "{intent.example}"</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>
|
||||
)
|
||||
}
|
||||
83
website/app/admin/voice/_components/TabOverview.tsx
Normal file
83
website/app/admin/voice/_components/TabOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
website/app/admin/voice/_components/constants.ts
Normal file
108
website/app/admin/voice/_components/constants.ts
Normal 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
|
||||
@@ -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: "{intent.example}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DSGVO Tab */}
|
||||
{activeTab === 'dsgvo' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
||||
|
||||
{/* Key Principles */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Data Categories Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{DSGVO_CATEGORIES.map((cat) => (
|
||||
<tr key={cat.category}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="mr-2">{cat.icon}</span>
|
||||
<span className="font-medium">{cat.category}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cat.risk.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Info */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>ref_id (truncated)</li>
|
||||
<li>content_type</li>
|
||||
<li>size_bytes</li>
|
||||
<li>ttl_hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium">Verboten:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>user_name</li>
|
||||
<li>content / transcript</li>
|
||||
<li>email</li>
|
||||
<li>student_name</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
||||
|
||||
{/* REST Endpoints */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{API_ENDPOINTS.map((ep, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Protocol */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example curl commands */}
|
||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
126
website/app/mail/tasks/_components/CreateTaskModal.tsx
Normal file
126
website/app/mail/tasks/_components/CreateTaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
website/app/mail/tasks/_components/StatCard.tsx
Normal file
29
website/app/mail/tasks/_components/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
website/app/mail/tasks/_components/TaskCard.tsx
Normal file
115
website/app/mail/tasks/_components/TaskCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
website/app/mail/tasks/_components/types.ts
Normal file
71
website/app/mail/tasks/_components/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
210
website/app/tools/communication/_components/InputForm.tsx
Normal file
210
website/app/tools/communication/_components/InputForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
website/app/tools/communication/_components/OutputArea.tsx
Normal file
165
website/app/tools/communication/_components/OutputArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
website/app/tools/communication/_components/types.ts
Normal file
28
website/app/tools/communication/_components/types.ts
Normal 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
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
55
website/components/compliance/charts/MiniRiskMatrix.tsx
Normal file
55
website/components/compliance/charts/MiniRiskMatrix.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
website/components/compliance/charts/RiskDistribution.tsx
Normal file
44
website/components/compliance/charts/RiskDistribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
78
website/components/compliance/charts/risk-heatmap-types.ts
Normal file
78
website/components/compliance/charts/risk-heatmap-types.ts
Normal 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'
|
||||
}
|
||||
Reference in New Issue
Block a user