[split-required] Split website + studio-v2 monoliths (Phase 3 continued)

Website (14 monoliths split):
- compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20)
- quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11)
- i18n.ts (1,173 → 8 language files)
- unity-bridge (1,094 → 12), backlog (1,087 → 6)
- training (1,066 → 8), rag (1,063 → 8)
- Deleted index_original.ts (4,899 LOC dead backup)

Studio-v2 (5 monoliths split):
- meet/page.tsx (1,481 → 9), messages (1,166 → 9)
- AlertsB2BContext.tsx (1,165 → 5 modules)
- alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6)

All existing imports preserved. Zero new TypeScript errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

View File

@@ -0,0 +1,80 @@
'use client'
import type { AlertItem, Topic, Stats } from './types'
import { getScoreBadge } from './useAlertsData'
interface DashboardTabProps {
stats: Stats | null
topics: Topic[]
alerts: AlertItem[]
error: string | null
}
export default function DashboardTab({ stats, topics, alerts, error }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
<div className="text-sm text-slate-500">Alerts gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
<div className="text-sm text-slate-500">Neue Alerts</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
<div className="text-sm text-slate-500">Relevant</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
<div className="text-sm text-slate-500">Zur Pruefung</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
<div className="space-y-3">
{topics.slice(0, 5).map((topic) => (
<div key={topic.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<div className="font-medium text-slate-900">{topic.name}</div>
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
<div className="space-y-3">
{alerts.slice(0, 5).map((alert) => (
<div key={alert.id} className="p-3 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{alert.topic_name}</span>
{getScoreBadge(alert.relevance_score)}
</div>
</div>
))}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,430 @@
'use client'
export default function DocumentationTab() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-200px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-pre:bg-slate-900 prose-pre:text-slate-100">
{/* Header */}
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
</div>
{/* Audit Box */}
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
<p className="text-sm text-blue-800">
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
</p>
</div>
{/* Ziel des Systems */}
<h2>Ziel des Alert-Systems</h2>
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
<ul>
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
</ul>
{/* Datenschutz Compliance */}
<h2>Datenschutz-Compliance</h2>
<DocTable
headers={['Massnahme', 'Umsetzung', 'Wirkung']}
rows={[
['100% Self-Hosted', 'Alle Dienste auf eigenen Servern', 'Keine Cloud-Abhaengigkeit'],
['Lokale KI', 'Ollama/vLLM on-premise', 'Keine Daten an OpenAI etc.'],
['URL-Deduplizierung', 'SHA256-Hash, Tracking entfernt', 'Minimale Datenspeicherung'],
['Soft-Delete', 'Archivierung statt Loeschung', 'Audit-Trail erhalten'],
['RBAC', 'Rollenbasierte Zugriffskontrolle', 'Nur autorisierter Zugriff'],
]}
/>
{/* Architektur */}
<h2>1. Systemarchitektur</h2>
<h3>Gesamtarchitektur</h3>
<pre className="text-xs leading-tight overflow-x-auto">{ARCHITECTURE_DIAGRAM}</pre>
{/* Datenfluss */}
<h3>Datenfluss bei Alert-Verarbeitung</h3>
<pre className="text-xs leading-tight overflow-x-auto">{DATAFLOW_DIAGRAM}</pre>
{/* Feed Ingestion */}
<h2>2. Feed Ingestion</h2>
<h3>RSS Fetcher</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Parser', 'feedparser 6.x', 'Standard RSS/Atom Parser'],
['HTTP Client', 'httpx (async)', 'Non-blocking Fetches'],
['Timeout', '30 Sekunden', 'Konfigurierbar'],
['Parallelitaet', 'Ja (asyncio.gather)', 'Mehrere Feeds gleichzeitig'],
]}
/>
<h3>Deduplizierung</h3>
<p>Die Deduplizierung verhindert doppelte Alerts durch:</p>
<ol>
<li><strong>URL-Normalisierung</strong>: Tracking-Parameter entfernen (utm_*, fbclid, gclid), Hostname lowercase, Trailing Slash entfernen</li>
<li><strong>URL-Hash</strong>: SHA256 der normalisierten URL, erste 16 Zeichen als Index</li>
</ol>
{/* Rule Engine */}
<h2>3. Rule Engine</h2>
<h3>Unterstuetzte Operatoren</h3>
<DocTable
headers={['Operator', 'Beschreibung', 'Beispiel']}
rows={[
['contains', 'Text enthaelt', 'title contains "Inklusion"'],
['not_contains', 'Text enthaelt nicht', 'title not_contains "Werbung"'],
['equals', 'Exakte Uebereinstimmung', 'status equals "new"'],
['regex', 'Regulaerer Ausdruck', 'title regex "\\d{4}"'],
['gt / lt', 'Groesser/Kleiner', 'relevance_score gt 0.8'],
['in', 'In Liste enthalten', 'title in ["KI", "AI"]'],
]}
monoFirstCol
/>
<h3>Verfuegbare Felder</h3>
<DocTable
headers={['Feld', 'Typ', 'Beschreibung']}
rows={[
['title', 'String', 'Alert-Titel'],
['snippet', 'String', 'Textausschnitt'],
['url', 'String', 'Artikel-URL'],
['source', 'Enum', 'google_alerts_rss, rss_feed, manual'],
['relevance_score', 'Float', '0.0 - 1.0'],
['relevance_decision', 'Enum', 'KEEP, DROP, REVIEW'],
]}
monoFirstCol
/>
<h3>Aktionen</h3>
<DocTable
headers={['Aktion', 'Beschreibung', 'Konfiguration']}
rows={[
['keep', 'Als relevant markieren', '-'],
['drop', 'Archivieren', '-'],
['tag', 'Tags hinzufuegen', '{"tags": ["wichtig"]}'],
['email', 'E-Mail senden', '{"to": "x@y.de"}'],
['webhook', 'HTTP POST', '{"url": "https://..."}'],
['slack', 'Slack-Nachricht', '{"webhook_url": "..."}'],
]}
monoFirstCol
/>
{/* KI Relevanzpruefung */}
<h2>4. KI-Relevanzpruefung</h2>
<h3>LLM Scorer</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Gateway URL', 'http://localhost:8000/llm', 'LLM Gateway Endpoint'],
['Modell', 'breakpilot-teacher-8b', 'Fein-getuntes Llama 3.1'],
['Temperatur', '0.3', 'Niedrig fuer Konsistenz'],
['Max Tokens', '500', 'Response-Limit'],
]}
/>
<h3>Bewertungskriterien</h3>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
</tbody>
</table>
</div>
<h3>Few-Shot Learning</h3>
<p>Das Profil verbessert sich durch Nutzerfeedback:</p>
<ol>
<li>Nutzer markiert Alert als relevant/irrelevant</li>
<li>Alert wird als positives/negatives Beispiel gespeichert</li>
<li>Beispiele werden in den Prompt eingefuegt (max. 5 pro Kategorie)</li>
<li>LLM lernt aus konkreten Beispielen des Nutzers</li>
</ol>
{/* Relevanz Profile */}
<h2>5. Relevanz-Profile</h2>
<h3>Standard-Bildungsprofil</h3>
<DocTable
headers={['Prioritaet', 'Gewicht', 'Keywords']}
rows={[
['Inklusion', '0.9', 'inklusiv, Foerderbedarf, Behinderung'],
['Datenschutz Schule', '0.85', 'DSGVO, Schuelerfotos, Einwilligung'],
['Schulrecht Bayern', '0.8', 'BayEUG, Schulordnung, KM'],
['Digitalisierung Schule', '0.7', 'DigitalPakt, Tablet-Klasse'],
]}
/>
<h3>Standard-Ausschluesse</h3>
<ul>
<li>Stellenanzeige</li>
<li>Praktikum gesucht</li>
<li>Werbung</li>
<li>Pressemitteilung</li>
</ul>
{/* API Endpoints */}
<h2>6. API Endpoints</h2>
<h3>Alerts API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/inbox', 'GET', 'Inbox Items abrufen'],
['/api/alerts/ingest', 'POST', 'Manuell Alert importieren'],
['/api/alerts/run', 'POST', 'Scoring-Pipeline starten'],
['/api/alerts/feedback', 'POST', 'Relevanz-Feedback geben'],
['/api/alerts/stats', 'GET', 'Statistiken abrufen'],
]}
monoFirstCol
/>
<h3>Topics API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/topics', 'GET', 'Topics auflisten'],
['/api/alerts/topics', 'POST', 'Neues Topic erstellen'],
['/api/alerts/topics/{id}', 'PUT', 'Topic aktualisieren'],
['/api/alerts/topics/{id}', 'DELETE', 'Topic loeschen (CASCADE)'],
['/api/alerts/topics/{id}/fetch', 'POST', 'Manueller Feed-Abruf'],
]}
monoFirstCol
/>
<h3>Rules & Profile API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/rules', 'GET/POST', 'Regeln verwalten'],
['/api/alerts/rules/{id}', 'PUT/DELETE', 'Regel bearbeiten/loeschen'],
['/api/alerts/profile', 'GET', 'Profil abrufen'],
['/api/alerts/profile', 'PUT', 'Profil aktualisieren'],
['/api/alerts/scheduler/status', 'GET', 'Scheduler-Status'],
]}
monoFirstCol
/>
{/* Datenbank Schema */}
<h2>7. Datenbank-Schema</h2>
<h3>Tabellen</h3>
<DocTable
headers={['Tabelle', 'Beschreibung', 'Wichtige Felder']}
rows={[
['alert_topics', 'Feed-Quellen', 'name, feed_url, feed_type, is_active, fetch_interval'],
['alert_items', 'Einzelne Alerts', 'title, url, url_hash, relevance_score, relevance_decision'],
['alert_rules', 'Filterregeln', 'name, conditions (JSON), action_type, priority'],
['alert_profiles', 'Nutzer-Profile', 'priorities, exclusions, positive/negative_examples'],
]}
monoFirstCol
/>
{/* DSGVO */}
<h2>8. DSGVO-Konformitaet</h2>
<h3>Rechtsgrundlage (Art. 6 DSGVO)</h3>
<DocTable
headers={['Verarbeitung', 'Rechtsgrundlage', 'Umsetzung']}
rows={[
['Feed-Abruf', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Informationsbeschaffung'],
['Alert-Speicherung', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Nur oeffentliche Informationen'],
['LLM-Scoring', 'Art. 6(1)(f) - Berechtigtes Interesse', 'On-Premise, keine PII'],
['Profil-Learning', 'Art. 6(1)(a) - Einwilligung', 'Opt-in durch Nutzung'],
]}
/>
<h3>Technische Datenschutz-Massnahmen</h3>
<ul>
<li><strong>Datenminimierung</strong>: Nur Titel, URL, Snippet - keine personenbezogenen Daten</li>
<li><strong>Lokale Verarbeitung</strong>: Ollama/vLLM on-premise - kein Datenabfluss an Cloud</li>
<li><strong>Pseudonymisierung</strong>: UUIDs statt Namen</li>
<li><strong>Automatische Loeschung</strong>: 90 Tage Retention fuer archivierte Alerts</li>
<li><strong>Audit-Logging</strong>: Stats und Match-Counts fuer Nachvollziehbarkeit</li>
</ul>
{/* Open Source */}
<h2>9. Open Source Lizenzen (SBOM)</h2>
<h3>Python Dependencies</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['FastAPI', 'MIT', 'Ja'],
['SQLAlchemy', 'MIT', 'Ja'],
['httpx', 'BSD-3-Clause', 'Ja'],
['feedparser', 'BSD-2-Clause', 'Ja'],
['APScheduler', 'MIT', 'Ja'],
]}
greenLastCol
/>
<h3>KI-Komponenten</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['Ollama', 'MIT', 'Ja'],
['Llama 3.1', 'Meta Llama 3', 'Ja*'],
['vLLM', 'Apache-2.0', 'Ja'],
]}
greenLastCol
/>
{/* Kontakt */}
<h2>10. Kontakt & Support</h2>
<DocTable
headers={['Kontakt', 'Adresse']}
rows={[
['Technischer Support', 'support@breakpilot.de'],
['Datenschutzbeauftragter', 'dsb@breakpilot.de'],
['Dokumentation', 'docs.breakpilot.de'],
['GitHub', 'github.com/breakpilot'],
]}
/>
{/* Footer */}
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Helper component for repetitive doc tables */
/* ------------------------------------------------------------------ */
interface DocTableProps {
headers: string[]
rows: string[][]
monoFirstCol?: boolean
greenLastCol?: boolean
}
function DocTable({ headers, rows, monoFirstCol, greenLastCol }: DocTableProps) {
return (
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{headers.map((h) => (
<th key={h} className="px-4 py-2 text-left font-semibold">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={`px-4 py-2${ci === 0 && monoFirstCol ? ' font-mono text-xs' : ''}${ci === row.length - 1 && greenLastCol ? ' text-green-600' : ''}`}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ------------------------------------------------------------------ */
/* ASCII diagrams kept as constants to avoid JSX noise */
/* ------------------------------------------------------------------ */
const ARCHITECTURE_DIAGRAM = `\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 BreakPilot Alerts Frontend \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Dashboard \u2502 \u2502 Inbox \u2502 \u2502 Topics \u2502 \u2502 Profile \u2502 \u2502
\u2502 \u2502 (Stats) \u2502 \u2502 (Review) \u2502 \u2502 (Feeds) \u2502 \u2502 (Learning) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Ingestion Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 RSS Fetcher \u2502 \u2502 Email Parser \u2502 \u2502 APScheduler \u2502 \u2502
\u2502 \u2502 (feedparser) \u2502 \u2502 (geplant) \u2502 \u2502 (AsyncIO) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Deduplication (URL-Hash + SimHash) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Processing Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Rule Engine \u2502 \u2502
\u2502 \u2502 Operatoren: contains, regex, gt/lt, in, starts_with \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 LLM Relevance Scorer \u2502 \u2502
\u2502 \u2502 Output: { score, decision: KEEP/DROP/REVIEW, summary } \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Action Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Email Action \u2502 \u2502 Webhook Action \u2502 \u2502 Slack Action \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Storage Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 PostgreSQL \u2502 \u2502 Valkey \u2502 \u2502 LLM Gateway \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518`
const DATAFLOW_DIAGRAM = `1. APScheduler triggert Fetch (alle 60 Min. default)
\u2502
v
2. RSS Fetcher holt Feed von Google Alerts
\u2502
v
3. Deduplizierung prueft URL-Hash
\u2502
\u251c\u2500\u2500 URL bekannt \u2500\u2500> Uebersprungen
\u2514\u2500\u2500 URL neu \u2500\u2500> Weiter
\u2502
v
4. Alert in Datenbank gespeichert (Status: NEW)
\u2502
v
5. Rule Engine evaluiert aktive Regeln
\u2502
\u251c\u2500\u2500 Regel matcht \u2500\u2500> Aktion ausfuehren
\u2514\u2500\u2500 Keine Regel \u2500\u2500> LLM Scoring
\u2502
v
6. LLM Relevance Scorer
\u2502
\u251c\u2500\u2500 KEEP (>= 0.7) \u2500\u2500> Inbox
\u251c\u2500\u2500 REVIEW (0.4-0.7) \u2500\u2500> Inbox (Pruefung)
\u2514\u2500\u2500 DROP (< 0.4) \u2500\u2500> Archiviert
\u2502
v
7. Nutzer-Feedback \u2500\u2500> Profile aktualisieren`

View File

@@ -0,0 +1,67 @@
'use client'
import type { AlertItem } from './types'
import { formatTimeAgo, getScoreBadge, getDecisionBadge } from './useAlertsData'
interface InboxTabProps {
inboxFilter: string
setInboxFilter: (filter: string) => void
filteredAlerts: AlertItem[]
}
export default function InboxTab({ inboxFilter, setInboxFilter, filteredAlerts }: InboxTabProps) {
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'new', 'keep', 'review'].map((filter) => (
<button
key={filter}
onClick={() => setInboxFilter(filter)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
inboxFilter === filter
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' && 'Alle'}
{filter === 'new' && 'Neu'}
{filter === 'keep' && 'Relevant'}
{filter === 'review' && 'Pruefung'}
</button>
))}
</div>
{/* Alerts Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-slate-50">
<td className="p-4">
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-primary-600">
{alert.title}
</a>
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
</td>
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { Profile } from './types'
interface ProfileTabProps {
profile: Profile | null
}
export default function ProfileTab({ profile }: ProfileTabProps) {
return (
<div className="max-w-2xl space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Prioritaeten (wichtige Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.priorities?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ausschluesse (unerwuenschte Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.exclusions?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert KEEP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.keep_threshold || 0.7}
>
<option value={0.8}>80% (sehr streng)</option>
<option value={0.7}>70% (empfohlen)</option>
<option value={0.6}>60% (weniger streng)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert DROP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.drop_threshold || 0.3}
>
<option value={0.4}>40% (strenger)</option>
<option value={0.3}>30% (empfohlen)</option>
<option value={0.2}>20% (lockerer)</option>
</select>
</div>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
Profil speichern
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { Rule } from './types'
interface RulesTabProps {
rules: Rule[]
}
export default function RulesTab({ rules }: RulesTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
/>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import type { Topic } from './types'
import { formatTimeAgo } from './useAlertsData'
interface TopicsTabProps {
topics: Topic[]
}
export default function TopicsTab({ topics }: TopicsTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Topic hinzufuegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex justify-between items-start mb-3">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
<div className="text-sm">
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
<span className="text-slate-500"> Alerts</span>
</div>
<div className="text-xs text-slate-500">
{formatTimeAgo(topic.last_fetched_at)}
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
/**
* System Info configuration for Audit tab
*/
export const alertsSystemConfig = {
title: 'Alerts Agent System',
description: 'Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung',
version: '1.0.0',
architecture: {
layers: [
{
title: 'Ingestion Layer',
components: ['RSS Fetcher', 'Email Parser', 'Webhook Receiver', 'APScheduler'],
color: '#3b82f6',
},
{
title: 'Processing Layer',
components: ['Deduplication', 'Rule Engine', 'LLM Relevance Scorer'],
color: '#8b5cf6',
},
{
title: 'Action Layer',
components: ['Email Actions', 'Webhook Actions', 'Slack Actions'],
color: '#22c55e',
},
{
title: 'Storage Layer',
components: ['PostgreSQL', 'Valkey Cache'],
color: '#f59e0b',
},
],
},
features: [
{ name: 'RSS Feed Parsing', status: 'active' as const, description: 'Google Alerts und andere RSS/Atom Feeds' },
{ name: 'LLM Relevance Scoring', status: 'active' as const, description: 'KI-basierte Relevanzpruefung mit Few-Shot Learning' },
{ name: 'Rule Engine', status: 'active' as const, description: 'Regelbasierte Filterung mit Conditions' },
{ name: 'Email Actions', status: 'active' as const, description: 'E-Mail-Benachrichtigungen bei Matches' },
{ name: 'Webhook Actions', status: 'active' as const, description: 'HTTP Webhooks fuer Integrationen' },
{ name: 'Slack Actions', status: 'active' as const, description: 'Slack Block Kit Nachrichten' },
{ name: 'Email Parsing', status: 'planned' as const, description: 'Google Alerts per E-Mail empfangen' },
{ name: 'Microsoft Teams', status: 'planned' as const, description: 'Teams Adaptive Cards' },
],
roadmap: [
{
phase: 'Phase 1 (Completed)',
priority: 'high' as const,
items: ['PostgreSQL Persistenz', 'Repository Pattern', 'Alembic Migrations'],
},
{
phase: 'Phase 2 (Completed)',
priority: 'high' as const,
items: ['Topic CRUD API', 'APScheduler Integration', 'Email Parser'],
},
{
phase: 'Phase 3 (Completed)',
priority: 'medium' as const,
items: ['Rule Engine', 'Condition Operators', 'Rule API'],
},
{
phase: 'Phase 4 (Completed)',
priority: 'medium' as const,
items: ['Action Dispatcher', 'Email/Webhook/Slack Actions'],
},
{
phase: 'Phase 5 (Current)',
priority: 'high' as const,
items: ['Studio Frontend', 'Admin Frontend', 'Audit & Documentation'],
},
],
technicalDetails: [
{ component: 'Backend', technology: 'FastAPI', version: '0.100+', description: 'Async REST API' },
{ component: 'ORM', technology: 'SQLAlchemy', version: '2.0', description: 'Async ORM mit PostgreSQL' },
{ component: 'Scheduler', technology: 'APScheduler', version: '3.x', description: 'AsyncIO Scheduler' },
{ component: 'HTTP Client', technology: 'httpx', description: 'Async HTTP fuer Webhooks' },
{ component: 'Feed Parser', technology: 'feedparser', version: '6.x', description: 'RSS/Atom Parsing' },
{ component: 'LLM Gateway', technology: 'Ollama/vLLM/Claude', description: 'Multi-Provider LLM' },
],
privacyNotes: [
'Alle Daten werden in Deutschland gespeichert (PostgreSQL)',
'Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)',
'LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen',
'DSGVO-konforme Datenverarbeitung',
],
auditInfo: [
{
category: 'Datenbank',
items: [
{ label: 'Tabellen', value: '4 (topics, items, rules, profiles)', status: 'ok' as const },
{ label: 'Indizes', value: 'URL-Hash, Topic-ID, Status', status: 'ok' as const },
{ label: 'Backups', value: 'PostgreSQL pg_dump', status: 'ok' as const },
],
},
{
category: 'API Sicherheit',
items: [
{ label: 'Authentifizierung', value: 'Bearer Token (geplant)', status: 'warning' as const },
{ label: 'Rate Limiting', value: 'Nicht implementiert', status: 'warning' as const },
{ label: 'Input Validation', value: 'Pydantic Models', status: 'ok' as const },
],
},
{
category: 'Logging & Monitoring',
items: [
{ label: 'Structured Logging', value: 'Python logging', status: 'ok' as const },
{ label: 'Metriken', value: 'Stats Endpoint', status: 'ok' as const },
{ label: 'Health Checks', value: '/api/alerts/health', status: 'ok' as const },
],
},
],
fullDocumentation: `
<h3>Alerts Agent - Entwicklerdokumentation</h3>
<h4>API Endpoints</h4>
<ul>
<li><code>GET /api/alerts/inbox</code> - Alerts auflisten</li>
<li><code>POST /api/alerts/ingest</code> - Alert hinzufuegen</li>
<li><code>GET /api/alerts/topics</code> - Topics auflisten</li>
<li><code>POST /api/alerts/topics</code> - Topic erstellen</li>
<li><code>GET /api/alerts/rules</code> - Regeln auflisten</li>
<li><code>POST /api/alerts/rules</code> - Regel erstellen</li>
<li><code>GET /api/alerts/profile</code> - Profil abrufen</li>
<li><code>PUT /api/alerts/profile</code> - Profil aktualisieren</li>
</ul>
<h4>Architektur</h4>
<p>Der Alerts Agent verwendet ein Pipeline-basiertes Design:</p>
<ol>
<li><strong>Ingestion</strong>: RSS Feeds werden periodisch abgerufen</li>
<li><strong>Deduplication</strong>: SimHash-basierte Duplikaterkennung</li>
<li><strong>Scoring</strong>: LLM-basierte Relevanzpruefung</li>
<li><strong>Rules</strong>: Regelbasierte Filterung und Aktionen</li>
<li><strong>Actions</strong>: Email/Webhook/Slack Benachrichtigungen</li>
</ol>
`,
}

View File

@@ -0,0 +1,68 @@
/**
* Types for Alerts Monitoring Admin Page
*/
export interface AlertItem {
id: string
title: string
url: string
snippet: string
topic_name: string
relevance_score: number | null
relevance_decision: string | null
status: string
fetched_at: string
published_at: string | null
matched_rule: string | null
tags: string[]
}
export interface Topic {
id: string
name: string
feed_url: string
feed_type: string
is_active: boolean
fetch_interval_minutes: number
last_fetched_at: string | null
alert_count: number
}
export interface Rule {
id: string
name: string
topic_id: string | null
conditions: Array<{
field: string
operator: string
value: string | number
}>
action_type: string
action_config: Record<string, unknown>
priority: number
is_active: boolean
}
export interface Profile {
priorities: string[]
exclusions: string[]
positive_examples: Array<{ title: string; url: string }>
negative_examples: Array<{ title: string; url: string }>
policies: {
keep_threshold: number
drop_threshold: number
}
}
export interface Stats {
total_alerts: number
new_alerts: number
kept_alerts: number
review_alerts: number
dropped_alerts: number
total_topics: number
active_topics: number
total_rules: number
}
export type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'

View File

@@ -0,0 +1,193 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { AlertItem, Topic, Rule, Profile, Stats } from './types'
const API_BASE = '/api/alerts'
export function useAlertsData() {
const [stats, setStats] = useState<Stats | null>(null)
const [alerts, setAlerts] = useState<AlertItem[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inboxFilter, setInboxFilter] = useState<string>('all')
const fetchData = useCallback(async () => {
try {
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
fetch(`${API_BASE}/stats`),
fetch(`${API_BASE}/inbox?limit=50`),
fetch(`${API_BASE}/topics`),
fetch(`${API_BASE}/rules`),
fetch(`${API_BASE}/profile`),
])
if (statsRes.ok) setStats(await statsRes.json())
if (alertsRes.ok) {
const data = await alertsRes.json()
setAlerts(data.items || [])
}
if (topicsRes.ok) {
const data = await topicsRes.json()
setTopics(data.items || data || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.items || data || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStats({
total_alerts: 147,
new_alerts: 23,
kept_alerts: 89,
review_alerts: 12,
dropped_alerts: 23,
total_topics: 5,
active_topics: 4,
total_rules: 8,
})
setAlerts([
{
id: 'demo_1',
title: 'Neue Studie zur digitalen Bildung an Schulen',
url: 'https://example.com/artikel1',
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
topic_name: 'Digitale Bildung',
relevance_score: 0.85,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date().toISOString(),
published_at: null,
matched_rule: null,
tags: ['bildung', 'digital'],
},
{
id: 'demo_2',
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
url: 'https://example.com/artikel2',
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
topic_name: 'Inklusion',
relevance_score: 0.72,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date(Date.now() - 3600000).toISOString(),
published_at: null,
matched_rule: null,
tags: ['inklusion'],
},
])
setTopics([
{
id: 'topic_1',
name: 'Digitale Bildung',
feed_url: 'https://google.com/alerts/feeds/123',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date().toISOString(),
alert_count: 47,
},
{
id: 'topic_2',
name: 'Inklusion',
feed_url: 'https://google.com/alerts/feeds/456',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
alert_count: 32,
},
])
setRules([
{
id: 'rule_1',
name: 'Stellenanzeigen ausschliessen',
topic_id: null,
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
action_type: 'drop',
action_config: {},
priority: 10,
is_active: true,
},
])
setProfile({
priorities: ['Inklusion', 'digitale Bildung'],
exclusions: ['Stellenanzeigen', 'Werbung'],
positive_examples: [],
negative_examples: [],
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const filteredAlerts = alerts.filter((alert) => {
if (inboxFilter === 'all') return true
if (inboxFilter === 'new') return alert.status === 'new'
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
return true
})
return {
stats,
alerts,
topics,
rules,
profile,
loading,
error,
inboxFilter,
setInboxFilter,
filteredAlerts,
}
}
export function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
export function getScoreBadge(score: number | null) {
if (score === null) return null
const pct = Math.round(score * 100)
let cls = 'bg-slate-100 text-slate-600'
if (pct >= 70) cls = 'bg-green-100 text-green-800'
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
else cls = 'bg-red-100 text-red-800'
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
}
export function getDecisionBadge(decision: string | null) {
if (!decision) return null
const styles: Record<string, string> = {
KEEP: 'bg-green-100 text-green-800',
REVIEW: 'bg-amber-100 text-amber-800',
DROP: 'bg-red-100 text-red-800',
}
return (
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
{decision}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
'use client'
import type { BacklogItem, BacklogCategory } from './types'
import { statusLabels, priorityLabels } from './types'
export function BacklogItemCard({
item,
category,
isExpanded,
onToggleExpand,
onUpdateStatus,
onToggleSubtask,
}: {
item: BacklogItem
category: BacklogCategory | undefined
isExpanded: boolean
onToggleExpand: () => void
onUpdateStatus: (status: BacklogItem['status']) => void
onToggleSubtask: (subtaskId: string) => void
}) {
const completedSubtasks = item.subtasks?.filter((st) => st.completed).length || 0
const totalSubtasks = item.subtasks?.length || 0
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start gap-4">
{/* Expand Icon */}
<button className="mt-1 text-slate-400">
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-semibold text-slate-900 truncate">{item.title}</h3>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
priorityLabels[item.priority].color
}`}
>
{priorityLabels[item.priority].label}
</span>
</div>
<p className="text-sm text-slate-500 mb-2">{item.description}</p>
<div className="flex items-center gap-4 text-xs">
<span className={`px-2 py-1 rounded border ${category?.color}`}>
{category?.name}
</span>
{totalSubtasks > 0 && (
<span className="text-slate-500">
{completedSubtasks}/{totalSubtasks} Teilaufgaben
</span>
)}
</div>
</div>
{/* Status */}
<select
value={item.status}
onChange={(e) => {
e.stopPropagation()
onUpdateStatus(e.target.value as BacklogItem['status'])
}}
onClick={(e) => e.stopPropagation()}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border-0 cursor-pointer ${
statusLabels[item.status].color
}`}
>
{Object.entries(statusLabels).map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Progress Bar for subtasks */}
{totalSubtasks > 0 && (
<div className="mt-3 ml-9">
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-green-500 h-1.5 rounded-full transition-all"
style={{ width: `${(completedSubtasks / totalSubtasks) * 100}%` }}
/>
</div>
</div>
)}
</div>
{/* Expanded Subtasks */}
{isExpanded && item.subtasks && item.subtasks.length > 0 && (
<div className="border-t border-slate-200 bg-slate-50 p-4 pl-14">
<h4 className="text-sm font-medium text-slate-700 mb-3">Teilaufgaben</h4>
<ul className="space-y-2">
{item.subtasks.map((subtask) => (
<li key={subtask.id} className="flex items-center gap-3">
<input
type="checkbox"
checked={subtask.completed}
onChange={() => onToggleSubtask(subtask.id)}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span
className={`text-sm ${
subtask.completed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{subtask.title}
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import type { BacklogItem } from './types'
export const initialBacklogItems: BacklogItem[] = [
// ==================== MODULE PROGRESS ====================
{
id: 'mod-1',
title: 'Consent Service (Go) - 85% fertig',
description: 'DSGVO Consent Management Microservice - Near Production Ready',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8081. 19 Test-Dateien vorhanden. JWT Auth, OAuth 2.0, TOTP 2FA, DSR Workflow, Matrix/Jitsi Integration.',
subtasks: [
{ id: 'mod-1-1', title: 'Core Consent API (CRUD, Versioning)', completed: true },
{ id: 'mod-1-2', title: 'Authentication (JWT, OAuth 2.0, TOTP)', completed: true },
{ id: 'mod-1-3', title: 'DSR Workflow (Art. 15-21)', completed: true },
{ id: 'mod-1-4', title: 'Email Templates & Notifications', completed: true },
{ id: 'mod-1-5', title: 'Matrix/Jitsi Integration', completed: true },
{ id: 'mod-1-6', title: 'Performance Tests (High-Load)', completed: false },
{ id: 'mod-1-7', title: 'E2E API Contract Tests', completed: false },
],
},
{
id: 'mod-2',
title: 'School Service (Go) - 75% fertig',
description: 'Klassen, Noten, Zeugnisse Microservice',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8084. 6 Test-Dateien. Zeugnis-Workflow mit RBAC (Fachlehrer, Klassenlehrer, Schulleitung).',
subtasks: [
{ id: 'mod-2-1', title: 'Klassen & Schueler Management', completed: true },
{ id: 'mod-2-2', title: 'Noten-System mit Statistik', completed: true },
{ id: 'mod-2-3', title: 'Zeugnis-Workflow & Rollen', completed: true },
{ id: 'mod-2-4', title: 'Exam Variant Generation (LLM)', completed: true },
{ id: 'mod-2-5', title: 'Seed Data Generator', completed: true },
{ id: 'mod-2-6', title: 'Integration Tests zwischen Services', completed: false },
{ id: 'mod-2-7', title: 'Performance Tests (grosse Klassen)', completed: false },
{ id: 'mod-2-8', title: 'Trend-Analyse & Comparative Analytics', completed: false },
],
},
{
id: 'mod-3',
title: 'Billing Service (Go) - 80% fertig',
description: 'Stripe Integration & Task-Based Billing',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8083. 5 Test-Dateien. Task-Konsum mit 5-Monats-Carryover, Fair Use Mode.',
subtasks: [
{ id: 'mod-3-1', title: 'Subscription Lifecycle', completed: true },
{ id: 'mod-3-2', title: 'Stripe Checkout & Webhooks', completed: true },
{ id: 'mod-3-3', title: 'Task-Based Consumption Tracking', completed: true },
{ id: 'mod-3-4', title: 'Feature Gating / Entitlements', completed: true },
{ id: 'mod-3-5', title: 'Customer Portal Integration', completed: true },
{ id: 'mod-3-6', title: 'Refund & Chargeback Handling', completed: false },
{ id: 'mod-3-7', title: 'Advanced Analytics & Reporting', completed: false },
],
},
{
id: 'mod-4',
title: 'Klausur Service (Python) - 70% fertig',
description: 'BYOEH Abitur-Klausurkorrektur System',
category: 'modules',
priority: 'critical',
status: 'in_progress',
notes: 'Port 8086. 2 Test-Dateien. BYOEH mit AES-256-GCM Encryption, Qdrant Vector DB, Key-Sharing.',
subtasks: [
{ id: 'mod-4-1', title: 'BYOEH Upload & Encryption', completed: true },
{ id: 'mod-4-2', title: 'Key-Sharing zwischen Pruefern', completed: true },
{ id: 'mod-4-3', title: 'Qdrant RAG Integration', completed: true },
{ id: 'mod-4-4', title: 'RBAC fuer Pruefer-Rollen', completed: true },
{ id: 'mod-4-5', title: 'OCR Pipeline Implementation', completed: false },
{ id: 'mod-4-6', title: 'KI-gestuetzte Korrektur API', completed: false },
{ id: 'mod-4-7', title: 'Gutachten-Generierung', completed: false },
{ id: 'mod-4-8', title: 'Frontend: KorrekturPage fertigstellen', completed: false },
],
},
{
id: 'mod-5',
title: 'Admin Frontend (Next.js) - 60% fertig',
description: 'Next.js 15 Admin Dashboard',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 3000. Nur 1 Test-Datei! Viele Admin-Seiten sind nur Skelett-Implementierungen.',
subtasks: [
{ id: 'mod-5-1', title: 'AdminLayout & Navigation', completed: true },
{ id: 'mod-5-2', title: 'SBOM & Architecture Pages', completed: true },
{ id: 'mod-5-3', title: 'Security Dashboard', completed: true },
{ id: 'mod-5-4', title: 'Backlog Page', completed: true },
{ id: 'mod-5-5', title: 'Consent Management Page', completed: true },
{ id: 'mod-5-6', title: 'Edu-Search Implementation', completed: false },
{ id: 'mod-5-7', title: 'DSMS Page Implementation', completed: false },
{ id: 'mod-5-8', title: 'Component Unit Tests', completed: false },
{ id: 'mod-5-9', title: 'E2E Tests mit Playwright', completed: false },
{ id: 'mod-5-10', title: 'Authentication Implementation', completed: false },
],
},
{
id: 'mod-6',
title: 'Backend Studio (Python) - 65% fertig',
description: 'Lehrer-Frontend Module (FastAPI)',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Port 8000. Keine dedizierten Tests! Integration mit allen APIs.',
subtasks: [
{ id: 'mod-6-1', title: 'Studio Router & Module Loading', completed: true },
{ id: 'mod-6-2', title: 'School Module UI', completed: true },
{ id: 'mod-6-3', title: 'Meetings/Jitsi Integration', completed: true },
{ id: 'mod-6-4', title: 'Customer Portal (Slim)', completed: true },
{ id: 'mod-6-5', title: 'Dev Admin Panel', completed: true },
{ id: 'mod-6-6', title: 'Component Unit Tests', completed: false },
{ id: 'mod-6-7', title: 'UI Component Dokumentation', completed: false },
{ id: 'mod-6-8', title: 'Accessibility Compliance', completed: false },
],
},
{
id: 'mod-7',
title: 'DSMS/IPFS Service - 40% fertig',
description: 'Dezentrales Speichersystem',
category: 'modules',
priority: 'low',
status: 'not_started',
notes: 'Port 8082. Keine Tests! Grundstruktur vorhanden aber Core-Logic fehlt.',
subtasks: [
{ id: 'mod-7-1', title: 'Service Struktur', completed: true },
{ id: 'mod-7-2', title: 'IPFS Configuration', completed: true },
{ id: 'mod-7-3', title: 'Upload/Download Handlers', completed: false },
{ id: 'mod-7-4', title: 'Encryption/Decryption Layer', completed: false },
{ id: 'mod-7-5', title: 'Pinning Strategy', completed: false },
{ id: 'mod-7-6', title: 'Replication Logic', completed: false },
{ id: 'mod-7-7', title: 'Tests & Dokumentation', completed: false },
],
},
{
id: 'mod-8',
title: 'Security Module (Python) - 75% fertig',
description: 'DevSecOps Dashboard & Scans',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Teil von Backend. Keine dedizierten Tests! Gitleaks, Semgrep, Bandit, Trivy Integration.',
subtasks: [
{ id: 'mod-8-1', title: 'Security Tool Status API', completed: true },
{ id: 'mod-8-2', title: 'Findings Aggregation', completed: true },
{ id: 'mod-8-3', title: 'Report Parsing (alle Tools)', completed: true },
{ id: 'mod-8-4', title: 'SBOM Generation (CycloneDX)', completed: true },
{ id: 'mod-8-5', title: 'Scan Triggering', completed: true },
{ id: 'mod-8-6', title: 'Unit Tests fuer Parser', completed: false },
{ id: 'mod-8-7', title: 'Vulnerability Tracking', completed: false },
{ id: 'mod-8-8', title: 'Compliance Reporting (SOC2)', completed: false },
],
},
// ==================== TESTING & QUALITY ====================
{ id: 'test-1', title: 'Test Coverage Erhöhen - Consent Service', description: 'Integration & E2E Tests fuer produktionskritischen Service', category: 'testing', priority: 'high', status: 'not_started', subtasks: [{ id: 'test-1-1', title: 'E2E API Tests mit echter DB', completed: false }, { id: 'test-1-2', title: 'Load Testing mit k6 oder vegeta', completed: false }, { id: 'test-1-3', title: 'Edge Cases in DSR Workflow', completed: false }] },
{ id: 'test-2', title: 'Admin Frontend Test Suite', description: 'Component & E2E Tests fuer Next.js Admin', category: 'testing', priority: 'critical', status: 'not_started', notes: 'Aktuell nur 1 Test-Datei! Kritischer Mangel.', subtasks: [{ id: 'test-2-1', title: 'Jest/Vitest fuer Component Tests', completed: false }, { id: 'test-2-2', title: 'Playwright E2E Setup', completed: false }, { id: 'test-2-3', title: 'API Mock Layer', completed: false }, { id: 'test-2-4', title: 'Visual Regression Tests', completed: false }] },
{ id: 'test-3', title: 'Klausur Service Tests erweitern', description: 'OCR & KI-Korrektur Pipeline testen', category: 'testing', priority: 'high', status: 'not_started', subtasks: [{ id: 'test-3-1', title: 'BYOEH Encryption Tests', completed: true }, { id: 'test-3-2', title: 'Key-Sharing Tests', completed: true }, { id: 'test-3-3', title: 'OCR Pipeline Integration Tests', completed: false }, { id: 'test-3-4', title: 'Large File Upload Tests', completed: false }] },
{ id: 'test-4', title: 'Security Module Unit Tests', description: 'Parser-Funktionen und Findings-Aggregation testen', category: 'testing', priority: 'medium', status: 'not_started', subtasks: [{ id: 'test-4-1', title: 'Gitleaks Parser Tests', completed: false }, { id: 'test-4-2', title: 'Trivy Parser Tests', completed: false }, { id: 'test-4-3', title: 'SBOM Generation Tests', completed: false }, { id: 'test-4-4', title: 'Mock Security Reports', completed: false }] },
// ==================== CI/CD PIPELINES ====================
{ id: 'cicd-1', title: 'GitHub Actions Workflow Setup', description: 'Basis CI/CD Pipeline mit GitHub Actions aufsetzen', category: 'cicd', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/workflows/ci.yml', subtasks: [{ id: 'cicd-1-1', title: 'Build-Job fuer alle Services', completed: true }, { id: 'cicd-1-2', title: 'Test-Job mit Coverage Report', completed: true }, { id: 'cicd-1-3', title: 'Docker Image Build & Push', completed: true }, { id: 'cicd-1-4', title: 'Deploy to Staging Environment', completed: true }] },
{ id: 'cicd-2', title: 'Staging Environment einrichten', description: 'Separate Staging-Umgebung fuer Pre-Production Tests', category: 'cicd', priority: 'high', status: 'not_started', subtasks: [{ id: 'cicd-2-1', title: 'Staging Server/Cluster provisionieren', completed: false }, { id: 'cicd-2-2', title: 'Staging Datenbank mit anonymisierten Daten', completed: false }, { id: 'cicd-2-3', title: 'Automatisches Deployment bei Merge to main', completed: false }] },
{ id: 'cicd-3', title: 'Production Deployment Pipeline', description: 'Kontrolliertes Deployment in Production mit Rollback', category: 'cicd', priority: 'critical', status: 'not_started', subtasks: [{ id: 'cicd-3-1', title: 'Blue-Green oder Canary Deployment Strategy', completed: false }, { id: 'cicd-3-2', title: 'Automatischer Rollback bei Fehlern', completed: false }, { id: 'cicd-3-3', title: 'Health Check nach Deployment', completed: false }, { id: 'cicd-3-4', title: 'Deployment Notifications (Slack/Email)', completed: false }] },
// Security & Vulnerability
{ id: 'sec-1', title: 'Dependency Vulnerability Scanning', description: 'Automatische Pruefung auf bekannte Schwachstellen', category: 'security', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/dependabot.yml', subtasks: [{ id: 'sec-1-1', title: 'Dependabot fuer Go aktivieren', completed: true }, { id: 'sec-1-2', title: 'Dependabot fuer Python aktivieren', completed: true }, { id: 'sec-1-3', title: 'Dependabot fuer npm aktivieren', completed: true }, { id: 'sec-1-4', title: 'CI-Job: Block Merge bei kritischen Vulnerabilities', completed: true }] },
{ id: 'sec-2', title: 'Container Image Scanning', description: 'Sicherheitspruefung aller Docker Images', category: 'security', priority: 'high', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-2-1', title: 'Trivy oder Snyk in CI integrieren', completed: true }, { id: 'sec-2-2', title: 'Base Image Policy definieren', completed: true }, { id: 'sec-2-3', title: 'Scan Report bei jedem Build', completed: true }] },
{ id: 'sec-3', title: 'SAST (Static Application Security Testing)', description: 'Code-Analyse auf Sicherheitsluecken', category: 'security', priority: 'high', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-3-1', title: 'CodeQL fuer Go/Python aktivieren', completed: true }, { id: 'sec-3-2', title: 'Semgrep Regeln konfigurieren', completed: false }, { id: 'sec-3-3', title: 'OWASP Top 10 Checks integrieren', completed: true }] },
{ id: 'sec-4', title: 'Secret Scanning', description: 'Verhindern, dass Secrets in Git landen', category: 'security', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-4-1', title: 'GitHub Secret Scanning aktivieren', completed: true }, { id: 'sec-4-2', title: 'Pre-commit Hook mit gitleaks', completed: true }, { id: 'sec-4-3', title: 'Historische Commits scannen', completed: true }] },
// RBAC & Access Control
{ id: 'rbac-1', title: 'GitHub Team & Repository Permissions', description: 'Team-basierte Zugriffsrechte auf Repository', category: 'rbac', priority: 'high', status: 'not_started', subtasks: [{ id: 'rbac-1-1', title: 'Team "Maintainers" erstellen', completed: false }, { id: 'rbac-1-2', title: 'Team "Developers" erstellen', completed: false }, { id: 'rbac-1-3', title: 'Team "Reviewers" erstellen', completed: false }, { id: 'rbac-1-4', title: 'External Collaborators Policy', completed: false }] },
{ id: 'rbac-2', title: 'Infrastructure Access Control', description: 'Zugriffsrechte auf Server und Cloud-Ressourcen', category: 'rbac', priority: 'critical', status: 'not_started', subtasks: [{ id: 'rbac-2-1', title: 'SSH Key Management Policy', completed: false }, { id: 'rbac-2-2', title: 'Production Server Access Audit Log', completed: false }, { id: 'rbac-2-3', title: 'Database Access nur ueber Jump Host', completed: false }, { id: 'rbac-2-4', title: 'Secrets Management (Vault/AWS Secrets)', completed: false }] },
{ id: 'rbac-3', title: 'Admin Panel Access Control', description: 'Rollenbasierte Zugriffsrechte im Admin Frontend', category: 'rbac', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rbac-3-1', title: 'Admin Authentication implementieren', completed: false }, { id: 'rbac-3-2', title: 'Role-based Views (Admin vs. Support)', completed: false }, { id: 'rbac-3-3', title: 'Audit Log fuer Admin-Aktionen', completed: false }] },
// Git & Branch Protection
{ id: 'git-1', title: 'Protected Branches Setup', description: 'Schutz fuer main/develop Branches', category: 'git', priority: 'critical', status: 'not_started', subtasks: [{ id: 'git-1-1', title: 'main Branch: No direct push', completed: false }, { id: 'git-1-2', title: 'Require PR with min. 1 Approval', completed: false }, { id: 'git-1-3', title: 'Require Status Checks (CI muss gruen sein)', completed: false }, { id: 'git-1-4', title: 'Require Signed Commits', completed: false }] },
{ id: 'git-2', title: 'Pull Request Template', description: 'Standardisierte PR-Beschreibung mit Checkliste', category: 'git', priority: 'medium', status: 'not_started', subtasks: [{ id: 'git-2-1', title: 'PR Template mit Description, Testing, Checklist', completed: false }, { id: 'git-2-2', title: 'Issue Template fuer Bugs und Features', completed: false }, { id: 'git-2-3', title: 'Automatische Labels basierend auf Aenderungen', completed: false }] },
{ id: 'git-3', title: 'Code Review Guidelines', description: 'Dokumentierte Richtlinien fuer Code Reviews', category: 'git', priority: 'medium', status: 'not_started', subtasks: [{ id: 'git-3-1', title: 'Code Review Checklist erstellen', completed: false }, { id: 'git-3-2', title: 'Review Turnaround Time Policy', completed: false }, { id: 'git-3-3', title: 'CODEOWNERS Datei pflegen', completed: false }] },
// Release Management
{ id: 'rel-1', title: 'Semantic Versioning Setup', description: 'Automatische Versionierung nach SemVer', category: 'release', priority: 'high', status: 'not_started', subtasks: [{ id: 'rel-1-1', title: 'Conventional Commits erzwingen', completed: false }, { id: 'rel-1-2', title: 'semantic-release oder release-please einrichten', completed: false }, { id: 'rel-1-3', title: 'Automatische Git Tags', completed: false }] },
{ id: 'rel-2', title: 'Changelog Automation', description: 'Automatisch generierte Release Notes', category: 'release', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rel-2-1', title: 'CHANGELOG.md automatisch generieren', completed: false }, { id: 'rel-2-2', title: 'GitHub Release mit Notes erstellen', completed: false }, { id: 'rel-2-3', title: 'Breaking Changes hervorheben', completed: false }] },
{ id: 'rel-3', title: 'Release Branches Strategy', description: 'Branching-Modell fuer Releases definieren', category: 'release', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rel-3-1', title: 'Git Flow oder GitHub Flow definieren', completed: false }, { id: 'rel-3-2', title: 'Hotfix Branch Process', completed: false }, { id: 'rel-3-3', title: 'Release Branch Protection', completed: false }] },
// Data Protection
{ id: 'data-1', title: 'Database Backup Strategy', description: 'Automatische Backups mit Retention Policy', category: 'data', priority: 'critical', status: 'not_started', subtasks: [{ id: 'data-1-1', title: 'Taegliche automatische Backups', completed: false }, { id: 'data-1-2', title: 'Point-in-Time Recovery aktivieren', completed: false }, { id: 'data-1-3', title: 'Backup Encryption at Rest', completed: false }, { id: 'data-1-4', title: 'Backup Restore Test dokumentieren', completed: false }] },
{ id: 'data-2', title: 'Customer Data Protection', description: 'Schutz von Stammdaten, Consent & Dokumenten', category: 'data', priority: 'critical', status: 'not_started', subtasks: [{ id: 'data-2-1', title: 'Stammdaten: Encryption at Rest', completed: false }, { id: 'data-2-2', title: 'Consent-Daten: Audit Log fuer Aenderungen', completed: false }, { id: 'data-2-3', title: 'Dokumente: Secure Storage (S3 mit Encryption)', completed: false }, { id: 'data-2-4', title: 'PII Data Masking in Logs', completed: false }] },
{ id: 'data-3', title: 'Migration Safety', description: 'Sichere Datenbank-Migrationen ohne Datenverlust', category: 'data', priority: 'high', status: 'not_started', subtasks: [{ id: 'data-3-1', title: 'Migration Pre-Check Script', completed: false }, { id: 'data-3-2', title: 'Rollback-faehige Migrationen', completed: false }, { id: 'data-3-3', title: 'Staging Migration Test vor Production', completed: false }] },
{ id: 'data-4', title: 'Data Anonymization', description: 'Anonymisierte Daten fuer Staging/Test', category: 'data', priority: 'medium', status: 'not_started', subtasks: [{ id: 'data-4-1', title: 'Anonymisierungs-Script fuer Staging DB', completed: false }, { id: 'data-4-2', title: 'Test-Datengenerator', completed: false }, { id: 'data-4-3', title: 'DSGVO-konforme Loeschung in Test-Systemen', completed: false }] },
// Compliance & SBOM
{ id: 'sbom-1', title: 'Software Bill of Materials (SBOM) erstellen', description: 'Vollstaendige Liste aller Open Source Komponenten', category: 'compliance', priority: 'high', status: 'completed', notes: 'Implementiert in /admin/sbom', subtasks: [{ id: 'sbom-1-1', title: 'Go Dependencies auflisten', completed: true }, { id: 'sbom-1-2', title: 'Python Dependencies auflisten', completed: true }, { id: 'sbom-1-3', title: 'npm Dependencies auflisten', completed: true }, { id: 'sbom-1-4', title: 'Docker Base Images dokumentieren', completed: true }, { id: 'sbom-1-5', title: 'SBOM in CycloneDX/SPDX Format exportieren', completed: true }] },
{ id: 'sbom-2', title: 'Open Source Lizenz-Compliance', description: 'Pruefung aller Lizenzen auf Kompatibilitaet', category: 'compliance', priority: 'high', status: 'in_progress', subtasks: [{ id: 'sbom-2-1', title: 'Alle Lizenzen identifizieren', completed: true }, { id: 'sbom-2-2', title: 'Lizenz-Kompatibilitaet pruefen', completed: false }, { id: 'sbom-2-3', title: 'LICENSES.md Datei erstellen', completed: false }, { id: 'sbom-2-4', title: 'Third-Party Notices generieren', completed: false }] },
{ id: 'sbom-3', title: 'Open Source Policy', description: 'Richtlinien fuer Verwendung von Open Source', category: 'compliance', priority: 'medium', status: 'not_started', subtasks: [{ id: 'sbom-3-1', title: 'Erlaubte Lizenzen definieren', completed: false }, { id: 'sbom-3-2', title: 'Genehmigungsprozess fuer neue Dependencies', completed: false }, { id: 'sbom-3-3', title: 'Automatische Lizenz-Checks in CI', completed: false }] },
{ id: 'sbom-4', title: 'Aktuelle Open Source Komponenten dokumentieren', description: 'BreakPilot Open Source Stack dokumentieren', category: 'compliance', priority: 'medium', status: 'completed', notes: 'Implementiert in /admin/sbom und /admin/architecture', subtasks: [{ id: 'sbom-4-1', title: 'Backend: FastAPI, Pydantic, httpx, anthropic', completed: true }, { id: 'sbom-4-2', title: 'Go Services: Gin, GORM, goquery', completed: true }, { id: 'sbom-4-3', title: 'Frontend: Next.js, React, Tailwind', completed: true }, { id: 'sbom-4-4', title: 'Infrastructure: PostgreSQL, OpenSearch, Ollama', completed: true }] },
// Approval Workflow
{ id: 'appr-1', title: 'Release Approval Gates', description: 'Mehrstufige Freigabe vor Production Deploy', category: 'approval', priority: 'critical', status: 'not_started', subtasks: [{ id: 'appr-1-1', title: 'QA Sign-off erforderlich', completed: false }, { id: 'appr-1-2', title: 'Security Review fuer kritische Aenderungen', completed: false }, { id: 'appr-1-3', title: 'Product Owner Freigabe', completed: false }, { id: 'appr-1-4', title: 'GitHub Environments mit Required Reviewers', completed: false }] },
{ id: 'appr-2', title: 'Deployment Windows', description: 'Definierte Zeitfenster fuer Production Deployments', category: 'approval', priority: 'medium', status: 'not_started', subtasks: [{ id: 'appr-2-1', title: 'Deployment-Kalender definieren', completed: false }, { id: 'appr-2-2', title: 'Freeze Periods (z.B. vor Feiertagen)', completed: false }, { id: 'appr-2-3', title: 'Emergency Hotfix Process', completed: false }] },
{ id: 'appr-3', title: 'Post-Deployment Verification', description: 'Checks nach erfolgreichem Deployment', category: 'approval', priority: 'high', status: 'not_started', subtasks: [{ id: 'appr-3-1', title: 'Smoke Tests automatisieren', completed: false }, { id: 'appr-3-2', title: 'Error Rate Monitoring (erste 30 Min)', completed: false }, { id: 'appr-3-3', title: 'Rollback-Kriterien definieren', completed: false }] },
]

View File

@@ -0,0 +1,114 @@
import type { BacklogCategory } from './types'
export const categories: BacklogCategory[] = [
{
id: 'modules',
name: 'Module Progress',
icon: (
<svg className="w-5 h-5" 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>
),
color: 'bg-violet-100 text-violet-700 border-violet-300',
description: 'Fertigstellungsgrad aller Services & Module',
},
{
id: 'cicd',
name: 'CI/CD Pipelines',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
color: 'bg-blue-100 text-blue-700 border-blue-300',
description: 'Build, Test & Deployment Automation',
},
{
id: 'security',
name: 'Security & Vulnerability',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
color: 'bg-red-100 text-red-700 border-red-300',
description: 'Security Scans, Dependency Checks & Penetration Testing',
},
{
id: 'testing',
name: 'Testing & Quality',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
color: 'bg-emerald-100 text-emerald-700 border-emerald-300',
description: 'Unit Tests, Integration Tests & E2E Testing',
},
{
id: 'rbac',
name: 'RBAC & Access Control',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
color: 'bg-purple-100 text-purple-700 border-purple-300',
description: 'Developer Roles, Permissions & Team Management',
},
{
id: 'git',
name: 'Git & Branch Protection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
),
color: 'bg-orange-100 text-orange-700 border-orange-300',
description: 'Protected Branches, Merge Requests & Code Reviews',
},
{
id: 'release',
name: 'Release Management',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
),
color: 'bg-green-100 text-green-700 border-green-300',
description: 'Versioning, Changelog & Release Notes',
},
{
id: 'data',
name: 'Data Protection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
color: 'bg-cyan-100 text-cyan-700 border-cyan-300',
description: 'Backup, Migration & Customer Data Safety',
},
{
id: 'compliance',
name: 'Compliance & SBOM',
icon: (
<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>
),
color: 'bg-teal-100 text-teal-700 border-teal-300',
description: 'SBOM, Lizenzen & Open Source Compliance',
},
{
id: 'approval',
name: 'Approval Workflow',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
color: 'bg-indigo-100 text-indigo-700 border-indigo-300',
description: 'Developer Approval, QA Sign-off & Release Gates',
},
]

View File

@@ -0,0 +1,35 @@
export interface BacklogItem {
id: string
title: string
description: string
category: string
priority: 'critical' | 'high' | 'medium' | 'low'
status: 'not_started' | 'in_progress' | 'review' | 'completed' | 'blocked'
assignee?: string
dueDate?: string
notes?: string
subtasks?: { id: string; title: string; completed: boolean }[]
}
export interface BacklogCategory {
id: string
name: string
icon: React.ReactNode
color: string
description: string
}
export const statusLabels: Record<BacklogItem['status'], { label: string; color: string }> = {
not_started: { label: 'Nicht begonnen', color: 'bg-slate-100 text-slate-600' },
in_progress: { label: 'In Arbeit', color: 'bg-blue-100 text-blue-700' },
review: { label: 'In Review', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
blocked: { label: 'Blockiert', color: 'bg-red-100 text-red-700' },
}
export const priorityLabels: Record<BacklogItem['priority'], { label: string; color: string }> = {
critical: { label: 'Kritisch', color: 'bg-red-500 text-white' },
high: { label: 'Hoch', color: 'bg-orange-500 text-white' },
medium: { label: 'Mittel', color: 'bg-yellow-500 text-white' },
low: { label: 'Niedrig', color: 'bg-slate-500 text-white' },
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect } from 'react'
import type { BacklogItem } from './types'
import { initialBacklogItems } from './backlogData'
import { categories } from './categories'
export function useBacklog() {
const [items, setItems] = useState<BacklogItem[]>(initialBacklogItems)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedPriority, setSelectedPriority] = useState<string | null>(null)
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
// Load saved state from localStorage
useEffect(() => {
const saved = localStorage.getItem('backlogItems')
if (saved) {
try {
setItems(JSON.parse(saved))
} catch (e) {
console.error('Failed to load backlog items:', e)
}
}
}, [])
// Save state to localStorage
useEffect(() => {
localStorage.setItem('backlogItems', JSON.stringify(items))
}, [items])
const filteredItems = items.filter((item) => {
if (selectedCategory && item.category !== selectedCategory) return false
if (selectedPriority && item.priority !== selectedPriority) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.subtasks?.some((st) => st.title.toLowerCase().includes(query))
)
}
return true
})
const toggleExpand = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
const updateItemStatus = (id: string, status: BacklogItem['status']) => {
setItems(items.map((item) => (item.id === id ? { ...item, status } : item)))
}
const toggleSubtask = (itemId: string, subtaskId: string) => {
setItems(
items.map((item) => {
if (item.id !== itemId) return item
return {
...item,
subtasks: item.subtasks?.map((st) =>
st.id === subtaskId ? { ...st, completed: !st.completed } : st
),
}
})
)
}
const getProgress = () => {
const total = items.length
const completed = items.filter((i) => i.status === 'completed').length
return { total, completed, percentage: Math.round((completed / total) * 100) }
}
const getCategoryProgress = (categoryId: string) => {
const categoryItems = items.filter((i) => i.category === categoryId)
const completed = categoryItems.filter((i) => i.status === 'completed').length
return { total: categoryItems.length, completed }
}
const clearFilters = () => {
setSelectedCategory(null)
setSelectedPriority(null)
setSearchQuery('')
}
return {
items,
filteredItems,
selectedCategory,
setSelectedCategory,
selectedPriority,
setSelectedPriority,
expandedItems,
searchQuery,
setSearchQuery,
toggleExpand,
updateItemStatus,
toggleSubtask,
getProgress,
getCategoryProgress,
clearFilters,
categories,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
export default function ArchitekturTab() {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Systemarchitektur</h2>
<p className="text-slate-600 mb-6">
Das Compliance & Audit Framework ist modular aufgebaut und integriert sich nahtlos in die bestehende Breakpilot-Infrastruktur.
</p>
{/* Architecture Diagram */}
<div className="bg-slate-50 rounded-lg p-6 mb-6">
<pre className="text-sm text-slate-700 font-mono whitespace-pre overflow-x-auto">{`
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLIANCE FRAMEWORK │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Next.js │ │ FastAPI │ │ PostgreSQL │ │
│ │ Frontend │───▶│ Backend │───▶│ Database │ │
│ │ (Port 3000)│ │ (Port 8000)│ │ (Port 5432)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Admin UI │ │ Compliance │ │ 7 Tables │ │
│ │ /admin/ │ │ Module │ │ compliance_│ │
│ │compliance/│ │ /backend/ │ │ regulations│ │
│ └───────────┘ │compliance/ │ │ _controls │ │
│ └───────────┘ │ _evidence │ │
│ │ _risks │ │
│ │ ... │ │
│ └───────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
{/* Component Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Frontend (Next.js)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Dashboard mit Compliance Score</li>
<li>- Control Catalogue mit Filtern</li>
<li>- Evidence Upload & Management</li>
<li>- Risk Matrix Visualisierung</li>
<li>- Audit Export Wizard</li>
</ul>
</div>
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Backend (FastAPI)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- REST API Endpoints</li>
<li>- Repository Pattern</li>
<li>- Pydantic Schemas</li>
<li>- Seeder Service</li>
<li>- Export Generator</li>
</ul>
</div>
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Datenbank (PostgreSQL)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- compliance_regulations</li>
<li>- compliance_requirements</li>
<li>- compliance_controls</li>
<li>- compliance_control_mappings</li>
<li>- compliance_evidence</li>
<li>- compliance_risks</li>
<li>- compliance_audit_exports</li>
</ul>
</div>
</div>
</div>
{/* Data Flow */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h3>
<div className="space-y-4">
{[
{ num: 1, bg: 'bg-blue-50', numBg: 'bg-blue-500', title: 'Regulations & Requirements', desc: 'EU-Verordnungen, BSI-Standards werden als Seed-Daten geladen' },
{ num: 2, bg: 'bg-green-50', numBg: 'bg-green-500', title: 'Controls & Mappings', desc: 'Technische Controls werden Requirements zugeordnet' },
{ num: 3, bg: 'bg-purple-50', numBg: 'bg-purple-500', title: 'Evidence Collection', desc: 'Nachweise werden manuell oder automatisiert erfasst' },
{ num: 4, bg: 'bg-orange-50', numBg: 'bg-orange-500', title: 'Audit Export', desc: 'ZIP-Pakete fuer externe Pruefer generieren' },
].map((step) => (
<div key={step.num} className={`flex items-center gap-4 p-4 ${step.bg} rounded-lg`}>
<div className={`w-8 h-8 ${step.numBg} text-white rounded-full flex items-center justify-center font-bold`}>{step.num}</div>
<div>
<p className="font-medium text-slate-900">{step.title}</p>
<p className="text-sm text-slate-600">{step.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import Link from 'next/link'
export default function AuditTab() {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Export</h3>
<Link
href="/admin/compliance/export"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
Export Wizard oeffnen
</Link>
</div>
<p className="text-slate-600 mb-6">
Erstellen Sie ZIP-Pakete mit allen relevanten Compliance-Daten fuer externe Pruefer.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Vollstaendiger Export</h4>
<p className="text-sm text-slate-600">Alle Daten inkl. Regulations, Controls, Evidence, Risks</p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Nur Controls</h4>
<p className="text-sm text-slate-600">Control Catalogue mit Mappings</p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Nur Evidence</h4>
<p className="text-sm text-slate-600">Evidence-Dateien und Metadaten</p>
</div>
</div>
</div>
{/* Export Format */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export Format</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm">
<pre className="whitespace-pre overflow-x-auto">{`
audit_export_2026-01-16/
├── index.html # Navigations-Uebersicht
├── summary.json # Maschinenlesbare Zusammenfassung
├── regulations/
│ ├── gdpr.json
│ ├── aiact.json
│ └── ...
├── controls/
│ ├── control_catalogue.json
│ └── control_catalogue.xlsx
├── evidence/
│ ├── scan_reports/
│ ├── policies/
│ └── configs/
├── risks/
│ └── risk_register.json
└── README.md # Erklaerung fuer Pruefer
`}</pre>
</div>
</div>
{/* Audit Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link
href="/admin/compliance/controls"
className="bg-white rounded-xl shadow-sm border p-6 hover:border-primary-500 transition-colors"
>
<h4 className="font-semibold text-slate-900 mb-2">Control Reviews</h4>
<p className="text-sm text-slate-600">Controls ueberpruefen und Status aktualisieren</p>
</Link>
<Link
href="/admin/compliance/evidence"
className="bg-white rounded-xl shadow-sm border p-6 hover:border-primary-500 transition-colors"
>
<h4 className="font-semibold text-slate-900 mb-2">Evidence Management</h4>
<p className="text-sm text-slate-600">Nachweise hochladen und verwalten</p>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { DOMAIN_LABELS } from '../types'
export default function DokumentationTab() {
return (
<div className="space-y-6">
{/* Quick Start Guide */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quick Start Guide</h3>
<div className="space-y-4">
{[
{ num: 1, title: 'Datenbank initialisieren', desc: 'Klicken Sie auf "Datenbank initialisieren" im Dashboard, um die Seed-Daten zu laden.' },
{ num: 2, title: 'Controls reviewen', desc: 'Gehen Sie zum Control Catalogue und bewerten Sie den Status jedes Controls.' },
{ num: 3, title: 'Evidence hochladen', desc: 'Laden Sie Nachweise (Scan-Reports, Policies, Screenshots) fuer Ihre Controls hoch.' },
{ num: 4, title: 'Risiken bewerten', desc: 'Dokumentieren Sie identifizierte Risiken in der Risk Matrix.' },
{ num: 5, title: 'Audit Export', desc: 'Generieren Sie ein ZIP-Paket fuer externe Pruefer.' },
].map((step) => (
<div key={step.num} className="flex gap-4">
<div className="w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold flex-shrink-0">{step.num}</div>
<div>
<p className="font-medium text-slate-900">{step.title}</p>
<p className="text-sm text-slate-600">{step.desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Regulatory Framework */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Abgedeckte Verordnungen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-2">EU-Verordnungen & Richtlinien</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- DSGVO (Datenschutz-Grundverordnung)</li>
<li>- AI Act (KI-Verordnung)</li>
<li>- CRA (Cyber Resilience Act)</li>
<li>- NIS2 (Netzwerk- und Informationssicherheit)</li>
<li>- DSA (Digital Services Act)</li>
<li>- Data Act (Datenverordnung)</li>
<li>- DGA (Data Governance Act)</li>
<li>- ePrivacy-Richtlinie</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-900 mb-2">Deutsche Standards</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- BSI-TR-03161-1 (Mobile Anwendungen Teil 1)</li>
<li>- BSI-TR-03161-2 (Mobile Anwendungen Teil 2)</li>
<li>- BSI-TR-03161-3 (Mobile Anwendungen Teil 3)</li>
<li>- TDDDG (Telekommunikation-Digitale-Dienste-Datenschutz)</li>
</ul>
</div>
</div>
</div>
{/* Control Domains */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Control Domains</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
<div key={key} className="border rounded-lg p-4">
<span className="font-mono text-xs text-primary-600 bg-primary-50 px-2 py-0.5 rounded">{key}</span>
<p className="font-medium text-slate-900 mt-2">{label}</p>
</div>
))}
</div>
</div>
{/* External Links */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Externe Ressourcen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ href: 'https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng', label: 'DSGVO - EUR-Lex' },
{ href: 'https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng', label: 'AI Act - EUR-Lex' },
{ href: 'https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng', label: 'CRA - EUR-Lex' },
{ href: 'https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03161/tr-03161.html', label: 'BSI-TR-03161 - BSI' },
].map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 border rounded-lg hover:bg-slate-50"
>
<svg className="w-5 h-5 text-slate-400" 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>
<span className="text-sm text-slate-700">{link.label}</span>
</a>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { ExecutiveDashboardData } from '../types'
const ComplianceTrendChart = dynamic(
() => import('@/components/compliance/charts/ComplianceTrendChart'),
{ ssr: false, loading: () => <div className="h-48 bg-slate-100 animate-pulse rounded" /> }
)
interface ExecutiveTabProps {
loading: boolean
onRefresh: () => void
}
export default function ExecutiveTab({ loading, onRefresh }: ExecutiveTabProps) {
const [executiveData, setExecutiveData] = useState<ExecutiveDashboardData | null>(null)
const [execLoading, setExecLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadExecutiveData()
}, [])
const loadExecutiveData = async () => {
setExecLoading(true)
setError(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/dashboard/executive`)
if (res.ok) {
setExecutiveData(await res.json())
} else {
setError('Executive Dashboard konnte nicht geladen werden')
}
} catch (err) {
console.error('Failed to load executive dashboard:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setExecLoading(false)
}
}
if (execLoading) {
return (
<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>
)
}
if (error || !executiveData) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-700">{error || 'Keine Daten verfuegbar'}</p>
<button
onClick={loadExecutiveData}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Erneut versuchen
</button>
</div>
)
}
const { traffic_light_status, overall_score, score_trend, score_change, top_risks, upcoming_deadlines, team_workload } = executiveData
const trafficLightColors = {
green: { bg: 'bg-green-500', ring: 'ring-green-200', text: 'text-green-700', label: 'Gut' },
yellow: { bg: 'bg-yellow-500', ring: 'ring-yellow-200', text: 'text-yellow-700', label: 'Achtung' },
red: { bg: 'bg-red-500', ring: 'ring-red-200', text: 'text-red-700', label: 'Kritisch' },
}
const tlConfig = trafficLightColors[traffic_light_status]
return (
<div className="space-y-6">
{/* Header Row: Traffic Light + Key Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<TrafficLightCard
tlConfig={tlConfig}
overall_score={overall_score}
score_change={score_change}
/>
<MetricCard
label="Verordnungen"
value={executiveData.total_regulations}
detail={`${executiveData.total_requirements} Anforderungen`}
iconBg="bg-blue-100"
iconColor="text-blue-600"
iconPath="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"
/>
<MetricCard
label="Massnahmen"
value={executiveData.total_controls}
detail="Technische Controls"
iconBg="bg-green-100"
iconColor="text-green-600"
iconPath="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<MetricCard
label="Offene Risiken"
value={executiveData.open_risks}
detail="Unmitigiert"
iconBg="bg-red-100"
iconColor="text-red-600"
iconPath="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"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TrendChartCard score_trend={score_trend} onRefresh={loadExecutiveData} />
<TopRisksCard top_risks={top_risks} />
</div>
{/* Bottom Row: Deadlines + Workload */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DeadlinesCard upcoming_deadlines={upcoming_deadlines} />
<WorkloadCard team_workload={team_workload} />
</div>
{/* Last Updated */}
<div className="text-right text-sm text-slate-400">
Zuletzt aktualisiert: {new Date(executiveData.last_updated).toLocaleString('de-DE')}
</div>
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function TrafficLightCard({ tlConfig, overall_score, score_change }: {
tlConfig: { bg: string; ring: string; text: string; label: string }
overall_score: number
score_change: number | null
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6 flex flex-col items-center justify-center">
<div
className={`w-28 h-28 rounded-full flex items-center justify-center ${tlConfig.bg} ring-8 ${tlConfig.ring} shadow-lg mb-4`}
>
<span className="text-4xl font-bold text-white">{overall_score.toFixed(0)}%</span>
</div>
<p className={`text-lg font-semibold ${tlConfig.text}`}>{tlConfig.label}</p>
<p className="text-sm text-slate-500 mt-1">Erfuellungsgrad</p>
{score_change !== null && (
<p className={`text-sm mt-2 ${score_change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{score_change >= 0 ? '\u2191' : '\u2193'} {Math.abs(score_change).toFixed(1)}% zum Vormonat
</p>
)}
</div>
)
}
function MetricCard({ label, value, detail, iconBg, iconColor, iconPath }: {
label: string
value: number
detail: string
iconBg: string
iconColor: string
iconPath: string
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-slate-500">{label}</p>
<span className={`w-8 h-8 ${iconBg} rounded-lg flex items-center justify-center`}>
<svg className={`w-4 h-4 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
</svg>
</span>
</div>
<p className="text-3xl font-bold text-slate-900">{value}</p>
<p className="text-sm text-slate-500 mt-1">{detail}</p>
</div>
)
}
function TrendChartCard({ score_trend, onRefresh }: {
score_trend: { date: string; score: number; label: string }[]
onRefresh: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Compliance-Trend (12 Monate)</h3>
<button onClick={onRefresh} className="text-sm text-primary-600 hover:text-primary-700">
Aktualisieren
</button>
</div>
<div className="h-48">
<ComplianceTrendChart data={score_trend} lang="de" height={180} />
</div>
</div>
)
}
function TopRisksCard({ top_risks }: { top_risks: ExecutiveDashboardData['top_risks'] }) {
const riskColors: Record<string, string> = {
critical: 'bg-red-100 text-red-700 border-red-200',
high: 'bg-orange-100 text-orange-700 border-orange-200',
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
low: 'bg-green-100 text-green-700 border-green-200',
}
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Top 5 Risiken</h3>
{top_risks.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine offenen Risiken</p>
) : (
<div className="space-y-3">
{top_risks.map((risk) => (
<div key={risk.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<span className={`px-2 py-1 text-xs font-medium rounded border ${riskColors[risk.risk_level] || riskColors.medium}`}>
{risk.risk_level.toUpperCase()}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 truncate">{risk.title}</p>
<p className="text-xs text-slate-500">{risk.owner || 'Kein Owner'}</p>
</div>
<span className="text-xs text-slate-400">{risk.risk_id}</span>
</div>
))}
</div>
)}
</div>
)
}
function DeadlinesCard({ upcoming_deadlines }: { upcoming_deadlines: ExecutiveDashboardData['upcoming_deadlines'] }) {
const statusColors: Record<string, string> = {
overdue: 'bg-red-100 text-red-700',
at_risk: 'bg-yellow-100 text-yellow-700',
on_track: 'bg-green-100 text-green-700',
}
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Naechste Fristen</h3>
{upcoming_deadlines.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine anstehenden Fristen</p>
) : (
<div className="space-y-2">
{upcoming_deadlines.slice(0, 5).map((deadline) => (
<div key={deadline.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[deadline.status] || statusColors.on_track}`}>
{deadline.days_remaining < 0
? `${Math.abs(deadline.days_remaining)}d ueberfaellig`
: deadline.days_remaining === 0
? 'Heute'
: `${deadline.days_remaining}d`}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 truncate">{deadline.title}</p>
<p className="text-xs text-slate-500">{new Date(deadline.deadline).toLocaleDateString('de-DE')}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}
function WorkloadCard({ team_workload }: { team_workload: ExecutiveDashboardData['team_workload'] }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Team-Auslastung</h3>
{team_workload.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine Daten verfuegbar</p>
) : (
<div className="space-y-3">
{team_workload.slice(0, 5).map((member) => (
<div key={member.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">{member.name}</span>
<span className="text-slate-500">
{member.completed_tasks}/{member.total_tasks} ({member.completion_rate.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div
className="bg-green-500 h-full"
style={{ width: `${(member.completed_tasks / member.total_tasks) * 100}%` }}
/>
<div
className="bg-yellow-500 h-full"
style={{ width: `${(member.in_progress_tasks / member.total_tasks) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { BACKLOG_ITEMS } from '../types'
export default function RoadmapTab() {
const completedCount = BACKLOG_ITEMS.filter(i => i.status === 'completed').length
const inProgressCount = BACKLOG_ITEMS.filter(i => i.status === 'in_progress').length
const plannedCount = BACKLOG_ITEMS.filter(i => i.status === 'planned').length
return (
<div className="space-y-6">
{/* Progress Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<p className="text-sm text-green-600 font-medium">Abgeschlossen</p>
<p className="text-3xl font-bold text-green-700">{completedCount}</p>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6">
<p className="text-sm text-yellow-600 font-medium">In Bearbeitung</p>
<p className="text-3xl font-bold text-yellow-700">{inProgressCount}</p>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-6">
<p className="text-sm text-slate-600 font-medium">Geplant</p>
<p className="text-3xl font-bold text-slate-700">{plannedCount}</p>
</div>
</div>
{/* Implemented Features */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Implementierte Features (v2.0 - Stand: 2026-01-17)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
'558 Requirements aus 19 Regulations extrahiert',
'44 Controls in 9 Domains mit Auto-Mapping',
'474 Control-Mappings automatisch generiert',
'30 Service-Module in Registry kartiert',
'AI-Interpretation fuer alle Requirements',
'EU-Lex Scraper fuer Live-Regulation-Fetch',
'BSI-TR-03161 PDF Parser (alle 3 Teile)',
'Evidence Management mit File Upload',
'Risk Matrix (5x5 Likelihood x Impact)',
'Audit Export Wizard (ZIP Generator)',
'Compliance Score Berechnung',
'Dashboard mit Echtzeit-Statistiken',
].map((feature, idx) => (
<div key={idx} className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-slate-700">{feature}</span>
</div>
))}
</div>
</div>
{/* Backlog */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold text-slate-900">Backlog</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<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-center text-xs font-medium text-slate-500 uppercase">Prioritaet</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{BACKLOG_ITEMS.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-medium text-slate-900">{item.title}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-600">{item.category}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.priority === 'high' ? 'bg-red-100 text-red-700' :
item.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
{item.priority}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.status === 'completed' ? 'bg-green-100 text-green-700' :
item.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{item.status === 'completed' ? 'Fertig' :
item.status === 'in_progress' ? 'In Arbeit' : 'Geplant'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
export default function TechnischTab() {
return (
<div className="space-y-6">
{/* API Endpoints */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">API Endpoints</h3>
<div className="space-y-3">
{[
{ method: 'GET', path: '/api/v1/compliance/regulations', desc: 'Liste aller Verordnungen' },
{ method: 'GET', path: '/api/v1/compliance/controls', desc: 'Control Catalogue' },
{ method: 'PUT', path: '/api/v1/compliance/controls/{id}/review', desc: 'Control Review' },
{ method: 'GET', path: '/api/v1/compliance/evidence', desc: 'Evidence Liste' },
{ method: 'POST', path: '/api/v1/compliance/evidence/upload', desc: 'Evidence Upload' },
{ method: 'GET', path: '/api/v1/compliance/risks', desc: 'Risk Register' },
{ method: 'GET', path: '/api/v1/compliance/risks/matrix', desc: 'Risk Matrix' },
{ method: 'GET', path: '/api/v1/compliance/dashboard', desc: 'Dashboard Stats' },
{ method: 'POST', path: '/api/v1/compliance/export', desc: 'Audit Export erstellen' },
{ method: 'POST', path: '/api/v1/compliance/seed', desc: 'Datenbank seeden' },
].map((ep, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg font-mono text-sm">
<span className={`px-2 py-1 rounded text-xs font-bold ${
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{ep.method}
</span>
<span className="text-slate-700 flex-1">{ep.path}</span>
<span className="text-slate-500 text-xs">{ep.desc}</span>
</div>
))}
</div>
</div>
{/* Database Schema */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datenmodell</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ table: 'compliance_regulations', fields: 'id, code, name, regulation_type, source_url, effective_date' },
{ table: 'compliance_requirements', fields: 'id, regulation_id, article, title, description, is_applicable' },
{ table: 'compliance_controls', fields: 'id, control_id, domain, title, status, is_automated, owner' },
{ table: 'compliance_control_mappings', fields: 'id, requirement_id, control_id, coverage_level' },
{ table: 'compliance_evidence', fields: 'id, control_id, evidence_type, title, artifact_path, status' },
{ table: 'compliance_risks', fields: 'id, risk_id, title, likelihood, impact, inherent_risk, status' },
{ table: 'compliance_audit_exports', fields: 'id, export_type, status, file_path, file_hash' },
].map((t, idx) => (
<div key={idx} className="border rounded-lg p-4">
<h4 className="font-mono font-semibold text-primary-600 mb-2">{t.table}</h4>
<p className="text-xs text-slate-500 font-mono">{t.fields}</p>
</div>
))}
</div>
</div>
{/* Enums */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Enums</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">ControlDomainEnum</h4>
<div className="flex flex-wrap gap-1">
{['gov', 'priv', 'iam', 'crypto', 'sdlc', 'ops', 'ai', 'cra', 'aud'].map((d) => (
<span key={d} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{d}</span>
))}
</div>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">ControlStatusEnum</h4>
<div className="flex flex-wrap gap-1">
{['pass', 'partial', 'fail', 'planned', 'n/a'].map((s) => (
<span key={s} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{s}</span>
))}
</div>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">RiskLevelEnum</h4>
<div className="flex flex-wrap gap-1">
{['low', 'medium', 'high', 'critical'].map((l) => (
<span key={l} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{l}</span>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import Link from 'next/link'
import { DashboardData, Regulation, AIStatus, DOMAIN_LABELS } from '../types'
interface UebersichtTabProps {
dashboard: DashboardData | null
regulations: Regulation[]
aiStatus: AIStatus | null
loading: boolean
onRefresh: () => void
}
export default function UebersichtTab({
dashboard,
regulations,
aiStatus,
loading,
onRefresh,
}: UebersichtTabProps) {
if (loading) {
return (
<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>
)
}
const score = dashboard?.compliance_score || 0
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
return (
<div className="space-y-6">
{/* AI Status Banner */}
<AIStatusBanner aiStatus={aiStatus} />
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<ScoreCard score={score} scoreColor={scoreColor} dashboard={dashboard} />
<StatCard
label="Verordnungen"
value={dashboard?.total_regulations || 0}
detail={`${dashboard?.total_requirements || 0} Anforderungen`}
iconBg="bg-blue-100"
iconColor="text-blue-600"
iconPath="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"
/>
<StatCard
label="Controls"
value={dashboard?.total_controls || 0}
detail={`${dashboard?.controls_by_status?.pass || 0} bestanden`}
iconBg="bg-green-100"
iconColor="text-green-600"
iconPath="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<StatCard
label="Nachweise"
value={dashboard?.total_evidence || 0}
detail={`${dashboard?.evidence_by_status?.valid || 0} aktiv`}
iconBg="bg-purple-100"
iconColor="text-purple-600"
iconPath="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
<StatCard
label="Risiken"
value={dashboard?.total_risks || 0}
detail={`${(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch`}
iconBg="bg-red-100"
iconColor="text-red-600"
iconPath="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"
/>
</div>
{/* Domain Chart and Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<DomainChart dashboard={dashboard} />
<QuickActions />
</div>
{/* Regulations Table */}
<RegulationsTable regulations={regulations} onRefresh={onRefresh} />
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function AIStatusBanner({ aiStatus }: { aiStatus: AIStatus | null }) {
if (!aiStatus) return null
return (
<div className={`rounded-lg p-4 flex items-center justify-between ${
aiStatus.is_available && !aiStatus.is_mock
? 'bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-200'
: aiStatus.is_mock
? 'bg-yellow-50 border border-yellow-200'
: 'bg-red-50 border border-red-200'
}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">🤖</span>
<div>
<div className="font-medium text-slate-900">
AI-Compliance-Assistent {aiStatus.is_available ? 'aktiv' : 'nicht verfuegbar'}
</div>
<div className="text-sm text-slate-600">
{aiStatus.is_mock ? (
<span className="text-yellow-700">Mock-Modus (kein API-Key konfiguriert)</span>
) : (
<>Provider: <span className="font-mono">{aiStatus.provider}</span> | Modell: <span className="font-mono">{aiStatus.model}</span></>
)}
</div>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
aiStatus.is_available && !aiStatus.is_mock
? 'bg-green-100 text-green-700'
: aiStatus.is_mock
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{aiStatus.is_available && !aiStatus.is_mock ? 'Online' : aiStatus.is_mock ? 'Mock' : 'Offline'}
</div>
</div>
)
}
function ScoreCard({ score, scoreColor, dashboard }: { score: number; scoreColor: string; dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
)
}
function StatCard({ label, value, detail, iconBg, iconColor, iconPath }: {
label: string
value: number
detail: string
iconBg: string
iconColor: string
iconPath: string
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<p className="text-2xl font-bold text-slate-900">{value}</p>
</div>
<div className={`w-10 h-10 ${iconBg} rounded-lg flex items-center justify-center`}>
<svg className={`w-5 h-5 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{detail}</p>
</div>
)
}
function DomainChart({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="space-y-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}
function QuickActions() {
const actions = [
{ href: '/admin/compliance/controls', label: 'Controls', color: 'primary', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-primary-600', iconPath: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ href: '/admin/compliance/evidence', label: 'Evidence', color: 'purple', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-purple-600', iconPath: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
{ href: '/admin/compliance/risks', label: 'Risiken', color: 'red', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-red-600', iconPath: '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' },
{ href: '/admin/compliance/scraper', label: 'Scraper', color: 'orange', hoverBorder: 'hover:border-orange-500', hoverBg: 'hover:bg-orange-50', iconColor: 'text-orange-600', iconPath: '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' },
{ href: '/admin/compliance/export', label: 'Export', color: 'green', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-green-600', iconPath: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' },
{ href: '/admin/compliance/audit-workspace', label: 'Audit Workspace', color: 'blue', hoverBorder: 'hover:border-blue-500', hoverBg: 'hover:bg-blue-50', iconColor: 'text-blue-600', iconPath: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01' },
{ href: '/admin/compliance/modules', label: 'Service Module Registry', color: 'pink', hoverBorder: 'hover:border-pink-500', hoverBg: 'hover:bg-pink-50', iconColor: 'text-pink-600', iconPath: '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' },
]
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellaktionen</h3>
<div className="grid grid-cols-2 gap-3">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className={`p-4 rounded-lg border border-slate-200 ${action.hoverBorder} ${action.hoverBg} transition-colors`}
>
<div className={`${action.iconColor} mb-2`}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={action.iconPath} />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">{action.label}</p>
</Link>
))}
</div>
</div>
)
}
function RegulationsTable({ regulations, onRefresh }: { regulations: Regulation[]; onRefresh: () => void }) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards</h3>
<button onClick={onRefresh} className="text-sm text-primary-600 hover:text-primary-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 10).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
/**
* Types and constants for the Compliance & Audit Framework Dashboard
*/
// ============================================================================
// Data Types
// ============================================================================
export interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
export interface AIStatus {
provider: string
model: string
is_available: boolean
is_mock: boolean
}
export interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
export interface ExecutiveDashboardData {
traffic_light_status: 'green' | 'yellow' | 'red'
overall_score: number
score_trend: { date: string; score: number; label: string }[]
previous_score: number | null
score_change: number | null
total_regulations: number
total_requirements: number
total_controls: number
open_risks: number
top_risks: {
id: string
risk_id: string
title: string
risk_level: string
owner: string | null
status: string
category: string
impact: number
likelihood: number
}[]
upcoming_deadlines: {
id: string
title: string
deadline: string
days_remaining: number
type: string
status: string
owner: string | null
}[]
team_workload: {
name: string
pending_tasks: number
in_progress_tasks: number
completed_tasks: number
total_tasks: number
completion_rate: number
}[]
last_updated: string
}
// ============================================================================
// Tab Definitions
// ============================================================================
export type TabId = 'executive' | 'uebersicht' | 'architektur' | 'roadmap' | 'technisch' | 'audit' | 'dokumentation'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'executive',
name: 'Executive',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'uebersicht',
name: 'Uebersicht',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'architektur',
name: 'Architektur',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'roadmap',
name: 'Roadmap',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
id: 'technisch',
name: 'Technisch',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
{
id: 'dokumentation',
name: 'Dokumentation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
},
]
// ============================================================================
// Constants
// ============================================================================
export const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export const STATUS_COLORS: Record<string, string> = {
pass: 'bg-green-500',
partial: 'bg-yellow-500',
fail: 'bg-red-500',
planned: 'bg-slate-400',
'n/a': 'bg-slate-300',
}
export const BACKLOG_ITEMS = [
{ id: 1, title: 'EU-Lex Live-Fetch (19 Regulations)', priority: 'high', status: 'completed', category: 'Integration' },
{ id: 2, title: 'BSI-TR-03161 PDF Parser (3 PDFs)', priority: 'high', status: 'completed', category: 'Data Import' },
{ id: 3, title: 'AI-Interpretation fuer Requirements', priority: 'high', status: 'completed', category: 'AI' },
{ id: 4, title: 'Auto-Mapping Controls zu Requirements (474)', priority: 'high', status: 'completed', category: 'Automation' },
{ id: 5, title: 'Service-Modul-Registry (30 Module)', priority: 'high', status: 'completed', category: 'Architecture' },
{ id: 6, title: 'Audit Trail fuer alle Aenderungen', priority: 'high', status: 'completed', category: 'Audit' },
{ id: 7, title: 'Automatische Evidence-Sammlung aus CI/CD', priority: 'high', status: 'planned', category: 'Automation' },
{ id: 8, title: 'Control-Review Workflow mit Benachrichtigungen', priority: 'medium', status: 'planned', category: 'Workflow' },
{ id: 9, title: 'Risk Treatment Plan Tracking', priority: 'medium', status: 'planned', category: 'Risk Management' },
{ id: 10, title: 'Compliance Score Trend-Analyse', priority: 'low', status: 'planned', category: 'Analytics' },
{ id: 11, title: 'SBOM Integration fuer CRA Compliance', priority: 'medium', status: 'planned', category: 'Integration' },
{ id: 12, title: 'Multi-Mandanten Compliance Trennung', priority: 'medium', status: 'planned', category: 'Architecture' },
{ id: 13, title: 'Compliance Report PDF Generator', priority: 'medium', status: 'planned', category: 'Export' },
]

View File

@@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import { services } from '../data'
import { getServiceTypeColor, getMethodColor } from '../helpers'
export default function ApiReferenceTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
return (
<div className="space-y-6">
{services.filter(s => s.endpoints.length > 0).map((service) => (
<div key={service.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">{service.name}</h3>
<div className="text-sm text-slate-500">Base URL: http://localhost:{service.port}</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</div>
</div>
<div className="divide-y divide-slate-100">
{service.endpoints.map((endpoint, idx) => {
const endpointId = `${service.id}-${idx}`
const curlCommand = `curl -X ${endpoint.method} http://localhost:${service.port}${endpoint.path}`
return (
<div key={idx} className="px-6 py-3 hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3">
<span className={`text-xs font-mono font-semibold px-2 py-1 rounded ${getMethodColor(endpoint.method)}`}>
{endpoint.method}
</span>
<code className="text-sm font-mono text-slate-700 flex-1">{endpoint.path}</code>
<button
onClick={() => copyToClipboard(curlCommand, endpointId)}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
title="Copy curl command"
>
{copiedEndpoint === endpointId ? (
<span className="text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
<div className="text-sm text-slate-500 mt-1 ml-14">{endpoint.description}</div>
</div>
)
})}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import { services } from '../data'
import { getServiceTypeColor } from '../helpers'
export default function DockerTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
const commonCommands = [
{ label: 'Alle Services starten', cmd: 'docker compose up -d' },
{ label: 'Logs anzeigen', cmd: 'docker compose logs -f [service]' },
{ label: 'Service neu bauen', cmd: 'docker compose build [service] --no-cache' },
{ label: 'Container Status', cmd: 'docker ps --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}"' },
{ label: 'In Container einloggen', cmd: 'docker exec -it [container] /bin/sh' },
]
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Docker Compose Services</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-700">Container</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Port</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Type</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Health Check</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{services.map((service) => (
<tr key={service.id} className="hover:bg-slate-50">
<td className="py-3 px-4">
<code className="text-sm bg-slate-100 px-2 py-0.5 rounded">{service.container}</code>
</td>
<td className="py-3 px-4 font-mono">{service.port}</td>
<td className="py-3 px-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</td>
<td className="py-3 px-4">
{service.healthEndpoint ? (
<code className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded">
{service.healthEndpoint}
</code>
) : (
<span className="text-slate-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Common Commands */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Haeufige Befehle</h2>
<div className="space-y-4">
{commonCommands.map((item, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-600 w-40">{item.label}</div>
<code className="flex-1 text-sm font-mono bg-slate-900 text-green-400 px-3 py-2 rounded">
{item.cmd}
</code>
<button
onClick={() => copyToClipboard(item.cmd, `cmd-${idx}`)}
className="text-slate-400 hover:text-slate-600"
>
{copiedEndpoint === `cmd-${idx}` ? (
<span className="text-xs text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
))}
</div>
</div>
{/* Environment Variables */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wichtige Umgebungsvariablen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{services.filter(s => s.envVars.length > 0).map((service) => (
<div key={service.id} className="p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">{service.name}</h4>
<div className="flex flex-wrap gap-2">
{service.envVars.map((env) => (
<code key={env} className="text-xs bg-slate-200 text-slate-700 px-2 py-1 rounded">
{env}
</code>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useState } from 'react'
import type { ServiceNode } from '../types'
import { ARCHITECTURE_SERVICES, LAYERS, DATAFLOW_DIAGRAM } from '../data'
import { getArchTypeColor, getArchTypeLabel } from '../helpers'
import ServiceDetailPanel from './ServiceDetailPanel'
export default function OverviewTab() {
const [selectedArchService, setSelectedArchService] = useState<ServiceNode | null>(null)
const [activeLayer, setActiveLayer] = useState<string>('all')
const getServicesForLayer = (layer: typeof LAYERS[0]) => {
return ARCHITECTURE_SERVICES.filter(s => layer.types.includes(s.type))
}
const archStats = {
total: ARCHITECTURE_SERVICES.length,
frontends: ARCHITECTURE_SERVICES.filter(s => s.type === 'frontend').length,
backends: ARCHITECTURE_SERVICES.filter(s => s.type === 'backend').length,
databases: ARCHITECTURE_SERVICES.filter(s => s.type === 'database').length,
infrastructure: ARCHITECTURE_SERVICES.filter(s => ['cache', 'search', 'storage', 'security', 'communication', 'ai', 'erp'].includes(s.type)).length,
}
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-slate-800">{archStats.total}</div>
<div className="text-sm text-slate-500">Services Total</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-blue-600">{archStats.frontends}</div>
<div className="text-sm text-slate-500">Frontends</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-green-600">{archStats.backends}</div>
<div className="text-sm text-slate-500">Backends</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-purple-600">{archStats.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-orange-600">{archStats.infrastructure}</div>
<div className="text-sm text-slate-500">Infrastruktur</div>
</div>
</div>
{/* ASCII Architecture Diagram with Arrows */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-4">Datenfluss-Diagramm</h2>
<div className="bg-slate-900 rounded-lg p-6 overflow-x-auto">
<pre className="text-green-400 font-mono text-xs whitespace-pre">{DATAFLOW_DIAGRAM}</pre>
</div>
</div>
{/* Layer Filter */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setActiveLayer('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeLayer === 'all'
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Alle Layer
</button>
{LAYERS.map((layer) => (
<button
key={layer.id}
onClick={() => setActiveLayer(layer.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeLayer === layer.id
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{layer.name}
</button>
))}
</div>
</div>
{/* Architecture Diagram - Layered View */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-6">System-Architektur Diagramm</h2>
<div className="space-y-6">
{LAYERS.map((layer) => {
const layerServices = getServicesForLayer(layer)
if (activeLayer !== 'all' && activeLayer !== layer.id) return null
return (
<div key={layer.id} className="border-2 border-dashed border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-semibold text-slate-700">{layer.name}</h3>
<span className="text-sm text-slate-500">- {layer.description}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{layerServices.map((service) => {
const colors = getArchTypeColor(service.type)
const isSelected = selectedArchService?.id === service.id
return (
<div
key={service.id}
onClick={() => setSelectedArchService(isSelected ? null : service)}
className={`cursor-pointer rounded-lg border-2 p-3 transition-all ${
isSelected
? `${colors.border} ${colors.light} shadow-lg scale-105`
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className={`w-3 h-3 rounded-full ${colors.bg}`}></div>
{service.port && service.port !== '-' && (
<span className="text-xs font-mono text-slate-500">:{service.port}</span>
)}
</div>
<h4 className="font-medium text-sm text-slate-800">{service.name}</h4>
<p className="text-xs text-slate-500">{service.technology}</p>
<p className="text-xs text-slate-600 mt-2 line-clamp-2">{service.description}</p>
{service.connections && service.connections.length > 0 && (
<div className="mt-2 flex items-center gap-1 text-xs text-slate-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{service.connections.length} Verbindung{service.connections.length > 1 ? 'en' : ''}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
{/* Service Detail Panel */}
{selectedArchService && (
<ServiceDetailPanel
service={selectedArchService}
onClose={() => setSelectedArchService(null)}
onSelectService={setSelectedArchService}
/>
)}
{/* Legend */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-slate-700 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4">
{(['frontend', 'backend', 'database', 'cache', 'search', 'storage', 'security', 'communication', 'ai', 'erp'] as const).map((type) => {
const colors = getArchTypeColor(type)
return (
<div key={type} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${colors.bg}`}></div>
<span className="text-sm text-slate-600">{getArchTypeLabel(type)}</span>
</div>
)
})}
</div>
</div>
{/* Technical Details Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-4">Technische Details</h2>
<div className="grid md:grid-cols-2 gap-6">
{/* Data Flow */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Datenfluss</h3>
<div className="space-y-2 text-sm text-slate-600">
<p><strong>1. Request:</strong> Browser &rarr; Next.js/FastAPI Frontend</p>
<p><strong>2. API:</strong> Frontend &rarr; Python Backend / Go Microservices</p>
<p><strong>3. Auth:</strong> Keycloak/Vault fuer SSO & Secrets</p>
<p><strong>4. Data:</strong> PostgreSQL (ACID) / Redis (Cache)</p>
<p><strong>5. Search:</strong> Qdrant (Vector) / Meilisearch (Fulltext)</p>
<p><strong>6. Storage:</strong> MinIO (Files) / IPFS (Dezentral)</p>
</div>
</div>
{/* Security */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Sicherheit</h3>
<div className="space-y-2 text-sm text-slate-600">
<p><strong>Auth:</strong> JWT + Keycloak OIDC</p>
<p><strong>Secrets:</strong> HashiCorp Vault (encrypted)</p>
<p><strong>Communication:</strong> Matrix E2EE, TLS everywhere</p>
<p><strong>DSGVO:</strong> Consent Service fuer Einwilligungen</p>
<p><strong>DevSecOps:</strong> Trivy, Gitleaks, Semgrep, Bandit</p>
<p><strong>SBOM:</strong> CycloneDX fuer alle Komponenten</p>
</div>
</div>
{/* Languages */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Programmiersprachen</h3>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm">Python 3.12</span>
<span className="px-3 py-1 bg-sky-100 text-sky-700 rounded-full text-sm">Go 1.21</span>
<span className="px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-sm">TypeScript 5.x</span>
<span className="px-3 py-1 bg-lime-100 text-lime-700 rounded-full text-sm">JavaScript ES2022</span>
</div>
</div>
{/* Frameworks */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Frameworks</h3>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">Next.js 15</span>
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">FastAPI</span>
<span className="px-3 py-1 bg-cyan-100 text-cyan-700 rounded-full text-sm">Gin (Go)</span>
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">Vue 3</span>
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm">Angular 17</span>
<span className="px-3 py-1 bg-pink-100 text-pink-700 rounded-full text-sm">NestJS</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import type { ServiceNode } from '../types'
import { ARCHITECTURE_SERVICES } from '../data'
import { getArchTypeColor, getArchTypeLabel } from '../helpers'
interface ServiceDetailPanelProps {
service: ServiceNode
onClose: () => void
onSelectService: (service: ServiceNode) => void
}
export default function ServiceDetailPanel({ service, onClose, onSelectService }: ServiceDetailPanelProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-slate-800">{service.name}</h2>
<p className="text-slate-600">{service.description}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg"
>
<svg className="w-5 h-5 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>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Typ</div>
<div className={`text-sm font-medium ${getArchTypeColor(service.type).text}`}>
{getArchTypeLabel(service.type)}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Technologie</div>
<div className="text-sm font-medium text-slate-800">{service.technology}</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Port</div>
<div className="text-sm font-mono font-medium text-slate-800">
{service.port || '-'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Verbindungen</div>
<div className="text-sm font-medium text-slate-800">
{service.connections?.length || 0} Services
</div>
</div>
</div>
{service.connections && service.connections.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-slate-700 mb-2">Verbunden mit:</h4>
<div className="flex flex-wrap gap-2">
{service.connections.map((connId) => {
const connService = ARCHITECTURE_SERVICES.find(s => s.id === connId)
if (!connService) return null
const colors = getArchTypeColor(connService.type)
return (
<button
key={connId}
onClick={() => onSelectService(connService)}
className={`px-3 py-1 rounded-full text-sm ${colors.light} ${colors.text} hover:opacity-80`}
>
{connService.name}
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import { services, docPaths, PROJECT_BASE_PATH } from '../data'
import { getServiceTypeColor } from '../helpers'
export default function ServicesTab() {
const [selectedService, setSelectedService] = useState<string | null>(null)
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((service) => (
<div
key={service.id}
className={`bg-white rounded-xl border border-slate-200 p-5 cursor-pointer transition-all hover:shadow-md ${
selectedService === service.id ? 'ring-2 ring-primary-600' : ''
}`}
onClick={() => setSelectedService(selectedService === service.id ? null : service.id)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{service.name}</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</div>
<div className="text-right">
<div className="text-sm font-mono text-slate-600">:{service.port}</div>
</div>
</div>
<p className="text-sm text-slate-600 mb-3">{service.description}</p>
<div className="flex flex-wrap gap-1 mb-3">
{service.tech.map((t) => (
<span key={t} className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded">
{t}
</span>
))}
</div>
{selectedService === service.id && (
<div className="mt-4 pt-4 border-t border-slate-200 space-y-2">
{/* Purpose/Warum dieser Service */}
<div className="bg-primary-50 border border-primary-200 rounded-lg p-3 mb-3">
<div className="text-xs font-medium text-primary-700 mb-1">Warum dieser Service?</div>
<div className="text-sm text-primary-900">{service.purpose}</div>
</div>
<div className="text-xs text-slate-500">Container: {service.container}</div>
{service.healthEndpoint && (
<div className="text-xs text-slate-500">
Health: <code className="bg-slate-100 px-1 rounded">localhost:{service.port}{service.healthEndpoint}</code>
</div>
)}
<div className="text-xs text-slate-500">
Endpoints: {service.endpoints.length}
</div>
{/* VS Code Link */}
{docPaths[service.id] && (
<a
href={`vscode://file/${PROJECT_BASE_PATH}/${docPaths[service.id]}`}
onClick={(e) => e.stopPropagation()}
className="mt-3 flex items-center gap-2 text-xs bg-blue-50 text-blue-700 px-3 py-2 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/>
</svg>
In VS Code oeffnen
</a>
)}
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { TabType } from '../types'
import { TAB_DEFINITIONS } from '../data'
interface TabNavigationProps {
activeTab: TabType
onTabChange: (tab: TabType) => void
}
export default function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
return (
<div className="border-b border-slate-200 mb-6">
<nav className="flex gap-6">
{TAB_DEFINITIONS.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id as TabType)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { PROJECT_BASE_PATH } from '../data'
const testDocLinks = [
{ file: 'docs/testing/README.md', label: 'Test-Uebersicht', desc: 'Teststrategie & Coverage-Ziele' },
{ file: 'docs/testing/QUICKSTART.md', label: 'Quickstart', desc: 'Schnellstart fuer Tests' },
{ file: 'docs/testing/INTEGRATION_TESTS.md', label: 'Integrationstests', desc: 'API & DB Tests' },
]
const coverageTargets = [
{ component: 'Go Consent Service', target: '80%', current: '~75%', color: 'green' },
{ component: 'Python Backend', target: '70%', current: '~65%', color: 'yellow' },
{ component: 'Critical Paths (Auth, OAuth)', target: '95%', current: '~90%', color: 'green' },
]
const testCommands = [
{ label: 'Go Tests (alle)', cmd: 'cd consent-service && go test -v ./...', lang: 'Go' },
{ label: 'Go Tests mit Coverage', cmd: 'cd consent-service && go test -cover ./...', lang: 'Go' },
{ label: 'Python Tests (alle)', cmd: 'cd backend && source venv/bin/activate && pytest -v', lang: 'Python' },
{ label: 'Python Tests mit Coverage', cmd: 'cd backend && pytest --cov=. --cov-report=html', lang: 'Python' },
]
export default function TestingTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
return (
<div className="space-y-6">
{/* Quick Links to Docs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Test-Dokumentation</h2>
<a
href={`vscode://file/${PROJECT_BASE_PATH}/docs/testing/README.md`}
className="flex items-center gap-2 text-sm bg-blue-50 text-blue-700 px-3 py-2 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/>
</svg>
Vollstaendige Docs in VS Code
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{testDocLinks.map((doc) => (
<a
key={doc.file}
href={`vscode://file/${PROJECT_BASE_PATH}/${doc.file}`}
className="p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div className="font-medium text-slate-900">{doc.label}</div>
<div className="text-sm text-slate-500">{doc.desc}</div>
</a>
))}
</div>
</div>
{/* Test Pyramid */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Pyramide</h2>
<div className="bg-slate-900 rounded-lg p-6 text-center">
<pre className="text-green-400 font-mono text-sm whitespace-pre inline-block text-left">{` /\\
/ \\ E2E (10%)
/----\\
/ \\ Integration (20%)
/--------\\
/ \\ Unit Tests (70%)
/--------------\\`}</pre>
</div>
</div>
{/* Coverage Ziele */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Coverage-Ziele</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{coverageTargets.map((item) => (
<div key={item.component} className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-900">{item.component}</div>
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full ${item.color === 'green' ? 'bg-green-500' : 'bg-yellow-500'}`}
style={{ width: item.current.replace('~', '') }}
/>
</div>
<span className="text-sm text-slate-600">{item.current}</span>
</div>
<div className="text-xs text-slate-500 mt-1">Ziel: {item.target}</div>
</div>
))}
</div>
</div>
{/* Test Commands */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Befehle</h2>
<div className="space-y-4">
{testCommands.map((item, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<span className={`text-xs px-2 py-0.5 rounded-full ${item.lang === 'Go' ? 'bg-cyan-100 text-cyan-700' : 'bg-yellow-100 text-yellow-700'}`}>
{item.lang}
</span>
<div className="text-sm text-slate-600 w-40">{item.label}</div>
<code className="flex-1 text-sm font-mono bg-slate-900 text-green-400 px-3 py-2 rounded">
{item.cmd}
</code>
<button
onClick={() => copyToClipboard(item.cmd, `test-${idx}`)}
className="text-slate-400 hover:text-slate-600"
>
{copiedEndpoint === `test-${idx}` ? (
<span className="text-xs text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
))}
</div>
</div>
{/* Test-Struktur */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Struktur</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Go Tests */}
<div>
<h3 className="font-medium text-slate-900 mb-2 flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-700">Go</span>
Consent Service
</h3>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-green-400">
<div>consent-service/</div>
<div className="ml-4">internal/</div>
<div className="ml-8">handlers/handlers_test.go</div>
<div className="ml-8">services/auth_service_test.go</div>
<div className="ml-8">services/oauth_service_test.go</div>
<div className="ml-8">services/totp_service_test.go</div>
<div className="ml-8">middleware/middleware_test.go</div>
</div>
</div>
{/* Python Tests */}
<div>
<h3 className="font-medium text-slate-900 mb-2 flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Python</span>
Backend
</h3>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-green-400">
<div>backend/</div>
<div className="ml-4">tests/</div>
<div className="ml-8">test_consent_client.py</div>
<div className="ml-8">test_gdpr_api.py</div>
<div className="ml-8">test_dsms_webui.py</div>
<div className="ml-4">conftest.py</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
/**
* Sticky header bar with document title, TOC toggle, and print button.
*/
interface AuditHeaderProps {
showToc: boolean
onToggleToc: () => void
}
export function AuditHeader({ showToc, onToggleToc }: AuditHeaderProps) {
return (
<div className="bg-white border-b border-slate-200 px-8 py-4 sticky top-16 z-10">
<div className="flex items-center justify-between max-w-4xl">
<div>
<h1 className="text-xl font-bold text-slate-900">DSGVO-Audit-Dokumentation</h1>
<p className="text-sm text-slate-500">OCR-Labeling-System | Version 1.0.0 | 21. Januar 2026</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={onToggleToc}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:bg-slate-50"
>
{showToc ? 'TOC ausblenden' : 'TOC anzeigen'}
</button>
<button
onClick={() => window.print()}
className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
PDF drucken
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
/**
* Document title block with version, date, classification, and review date.
*/
export function AuditTitleBlock() {
return (
<div className="mb-8 pb-6 border-b-2 border-slate-200">
<h1 className="text-3xl font-bold text-slate-900 mb-4">
DSGVO-Audit-Dokumentation: OCR-Labeling-System für Handschrifterkennung
</h1>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="font-semibold">Dokumentversion:</span> 1.0.0</div>
<div><span className="font-semibold">Datum:</span> 21. Januar 2026</div>
<div><span className="font-semibold">Klassifizierung:</span> Vertraulich - Nur für internen Gebrauch und Auditoren</div>
<div><span className="font-semibold">Nächste Überprüfung:</span> 21. Januar 2027</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
/**
* Code block component for rendering preformatted text
* in the audit documentation (diagrams, config examples).
*/
export function CodeBlock({ children }: { children: string }) {
return (
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono my-4 whitespace-pre">
{children}
</pre>
)
}

View File

@@ -0,0 +1,33 @@
/**
* Reusable table component for the audit documentation.
* Renders a striped HTML table with headers and rows.
*/
export function Table({ headers, rows }: { headers: string[]; rows: string[][] }) {
return (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-slate-300">
<thead>
<tr className="bg-slate-100">
{headers.map((header, i) => (
<th key={i} className="border border-slate-300 px-4 py-2 text-left text-sm font-semibold text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
{row.map((cell, j) => (
<td key={j} className="border border-slate-300 px-4 py-2 text-sm text-slate-600">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
/**
* Sidebar table of contents for navigating audit documentation sections.
*/
import { SECTIONS } from '../constants'
interface TableOfContentsProps {
activeSection: string
onScrollToSection: (sectionId: string) => void
}
export function TableOfContents({ activeSection, onScrollToSection }: TableOfContentsProps) {
return (
<aside className="w-64 flex-shrink-0 border-r border-slate-200 bg-slate-50 overflow-y-auto fixed left-64 top-16 bottom-0 z-20">
<div className="p-4">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider mb-4">
Inhaltsverzeichnis
</h2>
<nav className="space-y-0.5">
{SECTIONS.map((section) => (
<button
key={section.id}
onClick={() => onScrollToSection(section.id)}
className={`w-full text-left px-2 py-1.5 text-sm rounded transition-colors ${
section.level === 2 ? 'font-medium' : 'ml-3 text-xs'
} ${
activeSection === section.id
? 'bg-primary-100 text-primary-700'
: 'text-slate-600 hover:bg-slate-200 hover:text-slate-900'
}`}
>
{section.id.includes('-') ? '' : `${section.id}. `}{section.title}
</button>
))}
</nav>
</div>
</aside>
)
}

View File

@@ -0,0 +1,64 @@
/**
* Anhaenge (Appendices): TOM-Checkliste, Vendor-Dokumentation, Voice Service TOM
* Plus document footer.
*/
import { Table } from '../Table'
export function Anhaenge() {
return (
<>
<section className="mb-10">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
Anhänge
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">Anhang B: TOM-Checkliste</h3>
<Table
headers={['Kategorie', 'Maßnahme', 'Status']}
rows={[
['Zutrittskontrolle', 'Serverraum verschlossen', '✓'],
['Zugangskontrolle', 'Passwort-Policy', '✓'],
['Zugriffskontrolle', 'RBAC implementiert', '✓'],
['Weitergabekontrolle', 'Netzwerkisolation', '✓'],
['Eingabekontrolle', 'Audit-Logging', '✓'],
['Verfügbarkeit', 'Backup + USV', '✓'],
['Trennungskontrolle', 'Mandantentrennung', '✓'],
['Verschlüsselung', 'FileVault + TLS', '✓'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">Anhang E: Vendor-Dokumentation</h3>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li><strong>llama3.2-vision:</strong> https://llama.meta.com/</li>
<li><strong>TrOCR:</strong> https://github.com/microsoft/unilm/tree/master/trocr</li>
<li><strong>Ollama:</strong> https://ollama.ai/</li>
<li><strong>PersonaPlex-7B:</strong> https://developer.nvidia.com (MIT + NVIDIA Open Model License)</li>
<li><strong>TaskOrchestrator:</strong> Proprietary - Agent-Orchestrierung</li>
<li><strong>Mimi Codec:</strong> MIT License - 24kHz Audio, 80ms Frames</li>
</ul>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">Anhang F: Voice Service TOM</h3>
<Table
headers={['Maßnahme', 'Umsetzung', 'Status']}
rows={[
['Audio-Persistenz verboten', 'AUDIO_PERSISTENCE=false (zwingend)', '✓'],
['Client-side Encryption', 'AES-256-GCM vor Übertragung', '✓'],
['Namespace-Isolation', 'Pro-Lehrer-Schlüssel', '✓'],
['TTL-basierte Löschung', 'Valkey mit automatischem Expire', '✓'],
['Transport-Verschlüsselung', 'TLS 1.3 + WSS', '✓'],
['Audit ohne PII', 'Nur Metadaten protokolliert', '✓'],
['Key-Hash statt Klartext', 'SHA-256 Hash zum Server', '✓'],
]}
/>
</section>
{/* Footer */}
<div className="border-t-2 border-slate-200 pt-6 mt-10 text-center text-slate-500 text-sm">
<p><strong>Dokumentende</strong></p>
<p className="mt-2">Diese Dokumentation wird jährlich oder bei wesentlichen Änderungen aktualisiert.</p>
<p className="mt-1">Letzte Aktualisierung: 26. Januar 2026</p>
</div>
</>
)
}

View File

@@ -0,0 +1,129 @@
/**
* Section 18: BQAS Lokaler Scheduler (QA-System)
* Subsections: 18.1-18.5
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function BQASScheduler() {
return (
<section id="section-18" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
18. BQAS Lokaler Scheduler (QA-System)
</h2>
<div id="section-18-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.1 GitHub Actions Alternative</h3>
<p className="text-slate-600 mb-4">
Das BQAS (Breakpilot Quality Assurance System) nutzt einen <strong>lokalen Scheduler</strong> anstelle von GitHub Actions.
Dies gewährleistet, dass <strong>keine Testdaten oder Ergebnisse</strong> an externe Cloud-Dienste übertragen werden.
</p>
<Table
headers={['Feature', 'GitHub Actions', 'Lokaler Scheduler', 'DSGVO-Relevanz']}
rows={[
['Tägliche Tests', 'schedule: cron', 'macOS launchd', 'Keine Datenübertragung'],
['Push-Tests', 'on: push (Cloud)', 'Git post-commit Hook (lokal)', 'Keine Datenübertragung'],
['PR-Tests', 'on: pull_request', 'Nicht verfügbar', '-'],
['Benachrichtigungen', 'GitHub Issues (US)', 'Desktop/Slack/Email', 'Konfigurierbar'],
['Datenverarbeitung', 'GitHub Server (US)', '100% lokal', 'DSGVO-konform'],
]}
/>
</div>
<div id="section-18-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.2 Datenschutz-Vorteile</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800 font-medium">
<strong>DSGVO-Konformität:</strong> Der lokale Scheduler verarbeitet alle Testdaten ausschließlich auf dem schuleigenen Mac Mini.
Es erfolgt keine Übertragung von Schülerdaten, Testergebnissen oder Modell-Outputs an externe Server.
</p>
</div>
<Table
headers={['Aspekt', 'Umsetzung']}
rows={[
['Verarbeitungsort', '100% auf lokalem Mac Mini'],
['Drittlandübermittlung', 'Keine'],
['Cloud-Abhängigkeit', 'Keine - vollständig offline-fähig'],
['Testdaten', 'Verbleiben lokal, keine Synchronisation'],
['Logs', '/var/log/bqas/ - lokal, ohne PII'],
]}
/>
</div>
<div id="section-18-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.3 Komponenten</h3>
<p className="text-slate-600 mb-4">Der lokale Scheduler besteht aus folgenden Komponenten:</p>
<Table
headers={['Komponente', 'Beschreibung', 'Datenschutz']}
rows={[
['run_bqas.sh', 'Hauptscript für Test-Ausführung', 'Keine Netzwerk-Calls außer lokalem API'],
['launchd Job', 'macOS-nativer Scheduler (07:00 täglich)', 'System-Level, keine Cloud'],
['Git Hook', 'post-commit für automatische Quick-Tests', 'Rein lokal'],
['Notifier', 'Benachrichtigungsmodul (Python)', 'Desktop lokal, Slack/Email optional'],
['LLM Judge', 'Qwen2.5-32B via lokalem Ollama', 'Keine externe API'],
['RAG Judge', 'Korrektur-Evaluierung lokal', 'Keine externe API'],
]}
/>
<CodeBlock>{`# Dateistruktur
voice-service/
├── scripts/
│ ├── run_bqas.sh # Haupt-Runner
│ ├── install_bqas_scheduler.sh # Installation
│ ├── com.breakpilot.bqas.plist # launchd Template
│ └── post-commit.hook # Git Hook
└── bqas/
├── judge.py # LLM Judge
├── rag_judge.py # RAG Judge
├── notifier.py # Benachrichtigungen
└── regression_tracker.py # Score-Historie`}</CodeBlock>
</div>
<div id="section-18-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.4 Datenverarbeitung</h3>
<p className="text-slate-600 mb-4">Folgende Daten werden während der Test-Ausführung verarbeitet:</p>
<Table
headers={['Datentyp', 'Verarbeitung', 'Speicherung', 'Löschung']}
rows={[
['Test-Inputs (Golden Suite)', 'Lokal via pytest', 'Im Speicher während Test', 'Nach Test-Ende'],
['LLM-Antworten', 'Lokales Ollama', 'Temporär im Speicher', 'Nach Bewertung'],
['Test-Ergebnisse', 'SQLite DB', 'bqas_history.db (lokal)', 'Nach Konfiguration'],
['Logs', 'Dateisystem', '/var/log/bqas/', 'Manuelle Rotation'],
['Benachrichtigungen', 'Log + Optional Slack/Email', 'notifications.log', 'Manuelle Rotation'],
]}
/>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-4">
<p className="text-amber-800">
<strong>Wichtig:</strong> Die Test-Inputs (Golden Suite YAML-Dateien) enthalten <strong>keine echten Schülerdaten</strong>,
sondern ausschließlich synthetische Beispiele zur Qualitätssicherung der Intent-Erkennung und RAG-Funktionalität.
</p>
</div>
</div>
<div id="section-18-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.5 Benachrichtigungen</h3>
<p className="text-slate-600 mb-4">Das Notifier-Modul unterstützt verschiedene Benachrichtigungskanäle:</p>
<Table
headers={['Kanal', 'Standard', 'Konfiguration', 'Datenschutz-Hinweis']}
rows={[
['Desktop (macOS)', 'Aktiviert', 'BQAS_NOTIFY_DESKTOP=true', 'Rein lokal, keine Übertragung'],
['Log-Datei', 'Immer', '/var/log/bqas/notifications.log', 'Lokal, nur Metadaten'],
['Slack Webhook', 'Deaktiviert', 'BQAS_NOTIFY_SLACK=true', 'Externe Übertragung - nur Status, keine PII'],
['E-Mail', 'Deaktiviert', 'BQAS_NOTIFY_EMAIL=true', 'Via lokalen Mailserver möglich'],
]}
/>
<p className="text-slate-600 mt-4 mb-2"><strong>Empfohlene Konfiguration für maximale Datenschutz-Konformität:</strong></p>
<CodeBlock>{`# Nur lokale Benachrichtigungen (Standard)
BQAS_NOTIFY_DESKTOP=true
BQAS_NOTIFY_SLACK=false
BQAS_NOTIFY_EMAIL=false
# Benachrichtigungs-Inhalt (ohne PII):
# - Status: success/failure/warning
# - Anzahl bestandener/fehlgeschlagener Tests
# - Test-IDs (keine Schülernamen oder Inhalte)`}</CodeBlock>
</div>
</section>
)
}

View File

@@ -0,0 +1,97 @@
/**
* Section 9: BSI-Anforderungen und Sicherheitsrichtlinien
* Section 10: EU AI Act Compliance (KI-Verordnung)
*/
import { Table } from '../Table'
export function BSIAndEUAIAct() {
return (
<>
{/* Section 9 */}
<section id="section-9" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
9. BSI-Anforderungen und Sicherheitsrichtlinien
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">9.1 Angewandte BSI-Publikationen</h3>
<Table
headers={['Publikation', 'Relevanz', 'Umsetzung']}
rows={[
['IT-Grundschutz-Kompendium', 'Basis-Absicherung', 'TOM nach Abschnitt 8'],
['BSI TR-03116-4', 'Kryptographische Verfahren', 'AES-256, TLS 1.3'],
['Kriterienkatalog KI (Juni 2025)', 'KI-Sicherheit', 'Siehe 9.2'],
['QUAIDAL (Juli 2025)', 'Trainingsdaten-Qualität', 'Siehe 9.3'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">9.2 KI-Sicherheitsanforderungen (BSI Kriterienkatalog)</h3>
<Table
headers={['Kriterium', 'Anforderung', 'Umsetzung']}
rows={[
['Modellintegrität', 'Schutz vor Manipulation', 'Lokale Modelle, keine Updates ohne Review'],
['Eingabevalidierung', 'Schutz vor Adversarial Attacks', 'Bildformat-Prüfung, Größenlimits'],
['Ausgabevalidierung', 'Plausibilitätsprüfung', 'Konfidenz-Schwellwerte'],
['Protokollierung', 'Nachvollziehbarkeit', 'Vollständiges Audit-Log'],
['Incident Response', 'Reaktion auf Fehlfunktionen', 'Eskalationsprozess definiert'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">9.3 Trainingsdaten-Qualität (QUAIDAL)</h3>
<Table
headers={['Qualitätskriterium', 'Umsetzung']}
rows={[
['Herkunftsdokumentation', 'Alle Trainingsdaten aus eigenem Labeling-Prozess'],
['Repräsentativität', 'Diverse Handschriften aus verschiedenen Klassenstufen'],
['Qualitätskontrolle', 'Lehrkraft-Verifikation jedes Samples'],
['Bias-Prüfung', 'Regelmäßige Stichproben-Analyse'],
['Versionierung', 'Git-basierte Versionskontrolle für Datasets'],
]}
/>
</section>
{/* Section 10 */}
<section id="section-10" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
10. EU AI Act Compliance (KI-Verordnung)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">10.1 Risikoklassifizierung</h3>
<p className="text-slate-600 mb-4"><strong>Prüfung nach Anhang III der KI-Verordnung:</strong></p>
<Table
headers={['Hochrisiko-Kategorie', 'Anwendbar', 'Begründung']}
rows={[
['3(a) Biometrische Identifizierung', 'Nein', 'Keine biometrische Verarbeitung'],
['3(b) Kritische Infrastruktur', 'Nein', 'Keine kritische Infrastruktur'],
['3(c) Allgemeine/berufliche Bildung', 'Prüfen', 'Bildungsbereich'],
['3(d) Beschäftigung', 'Nein', 'Nicht anwendbar'],
]}
/>
<p className="text-slate-600 mt-4 mb-2">Das System wird <strong>nicht</strong> für folgende Hochrisiko-Anwendungen genutzt:</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>Entscheidung über Zugang zu Bildungseinrichtungen</li>
<li>Zuweisung zu Bildungseinrichtungen oder -programmen</li>
<li>Bewertung von Lernergebnissen (nur Unterstützung, keine automatische Bewertung)</li>
<li>Überwachung von Prüfungen</li>
</ul>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Kein Hochrisiko-KI-System nach aktuellem Stand.
</p>
</div>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">10.2 Verbotsprüfung (Art. 5)</h3>
<Table
headers={['Verbotene Praxis', 'Geprüft', 'Ergebnis']}
rows={[
['Unterschwellige Manipulation', '✓', 'Nicht vorhanden'],
['Ausnutzung von Schwächen', '✓', 'Nicht vorhanden'],
['Social Scoring', '✓', 'Nicht vorhanden'],
['Echtzeit-Biometrie', '✓', 'Nicht vorhanden'],
['Emotionserkennung in Bildung', '✓', 'Nicht vorhanden'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,131 @@
/**
* Section 4: Datenschutz-Folgenabschaetzung (Art. 35 DSGVO)
* Subsections: 4.1-4.5
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function DatenschutzFolgen() {
return (
<section id="section-4" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
4. Datenschutz-Folgenabschätzung (Art. 35 DSGVO)
</h2>
<div id="section-4-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.1 Schwellwertanalyse - Erforderlichkeit der DSFA</h3>
<Table
headers={['Kriterium', 'Erfüllt', 'Begründung']}
rows={[
['Neue Technologien (KI/ML)', '✓', 'Vision-LLM für OCR'],
['Umfangreiche Verarbeitung', '✗', 'Begrenzt auf einzelne Schule'],
['Daten von Minderjährigen', '✓', 'Schülerarbeiten'],
['Systematische Überwachung', '✗', 'Keine Überwachung'],
['Scoring/Profiling', '✗', 'Keine automatische Bewertung'],
]}
/>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-4">
<p className="text-amber-800 font-medium">
<strong>Ergebnis:</strong> DSFA erforderlich aufgrund KI-Einsatz und Verarbeitung von Daten Minderjähriger.
</p>
</div>
</div>
<div id="section-4-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.2 Systematische Beschreibung der Verarbeitung</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Datenfluss-Diagramm</h4>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────────────────┐
│ OCR-LABELING DATENFLUSS │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 1. SCAN │───►│ 2. UPLOAD │───►│ 3. OCR │───►│ 4. LABELING │ │
│ │ (Lehrkraft) │ │ (MinIO) │ │ (Ollama) │ │ (Lehrkraft) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Papierdokument Verschlüsselte Lokale LLM- Bestätigung/ │
│ → digitaler Scan Bildspeicherung Verarbeitung Korrektur │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ SPEICHERUNG (PostgreSQL) │ │
│ │ • Session-ID (UUID) • Status (pending/confirmed/corrected) │ │
│ │ • Bild-Hash (SHA256) • Ground Truth (korrigierter Text) │ │
│ │ • OCR-Text • Zeitstempel │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 5. EXPORT │ Pseudonymisierte Trainingsdaten (JSONL) │
│ │ (Optional) │ → Lokal gespeichert für Fine-Tuning │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">Verarbeitungsschritte im Detail</h4>
<Table
headers={['Schritt', 'Beschreibung', 'Datenschutzmaßnahme']}
rows={[
['1. Scan', 'Lehrkraft scannt Papierklausur', 'Physischer Zugang nur für Lehrkräfte'],
['2. Upload', 'Bild wird in lokales MinIO hochgeladen', 'SHA256-Deduplizierung, verschlüsselte Speicherung'],
['3. OCR', 'llama3.2-vision erkennt Text', '100% lokal, kein Internet'],
['4. Labeling', 'Lehrkraft prüft/korrigiert OCR-Ergebnis', 'Protokollierung aller Aktionen'],
['5. Export', 'Optional: Pseudonymisierte Trainingsdaten', 'Entfernung direkter Identifikatoren'],
]}
/>
</div>
<div id="section-4-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.3 Notwendigkeit und Verhältnismäßigkeit</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Prüfung der Erforderlichkeit</h4>
<Table
headers={['Prinzip', 'Umsetzung']}
rows={[
['Zweckbindung', 'Ausschließlich für schulische Leistungsbewertung und Modelltraining'],
['Datenminimierung', 'Nur Bildausschnitte mit Text, keine vollständigen Klausuren nötig'],
['Speicherbegrenzung', 'Automatische Löschung nach definierter Aufbewahrungsfrist'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">Alternativenprüfung</h4>
<Table
headers={['Alternative', 'Bewertung']}
rows={[
['Manuelle Transkription', 'Zeitaufwändig, fehleranfällig, nicht praktikabel'],
['Cloud-OCR (Google, Azure)', 'Datenschutzrisiken durch Drittlandübermittlung'],
['Kommerzielles lokales OCR', 'Hohe Kosten, Lizenzabhängigkeit'],
['Gewählte Lösung', 'Open-Source lokal - optimale Balance'],
]}
/>
</div>
<div id="section-4-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.4 Risikobewertung</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Identifizierte Risiken</h4>
<Table
headers={['Risiko', 'Eintrittswahrscheinlichkeit', 'Schwere', 'Risikostufe', 'Mitigationsmaßnahme']}
rows={[
['R1: Unbefugter Zugriff auf Schülerdaten', 'Gering', 'Hoch', 'Mittel', 'Rollenbasierte Zugriffskontrolle, MFA'],
['R2: Datenleck durch Systemkompromittierung', 'Gering', 'Hoch', 'Mittel', 'Verschlüsselung, Netzwerkisolation'],
['R3: Fehlerhaftes OCR beeinflusst Bewertung', 'Mittel', 'Mittel', 'Mittel', 'Pflicht-Review durch Lehrkraft'],
['R4: Re-Identifizierung aus Handschrift', 'Gering', 'Mittel', 'Gering', 'Pseudonymisierung, keine Handschriftanalyse'],
['R5: Bias im OCR-Modell', 'Mittel', 'Mittel', 'Mittel', 'Regelmäßige Qualitätsprüfung'],
]}
/>
</div>
<div id="section-4-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.5 Maßnahmen zur Risikominderung</h3>
<Table
headers={['Risiko', 'Maßnahme', 'Umsetzungsstatus']}
rows={[
['R1', 'RBAC, MFA, Audit-Logging', '✓ Implementiert'],
['R2', 'FileVault-Verschlüsselung, lokales Netz', '✓ Implementiert'],
['R3', 'Pflicht-Bestätigung durch Lehrkraft', '✓ Implementiert'],
['R4', 'Pseudonymisierung bei Export', '✓ Implementiert'],
['R5', 'Diverse Trainingssamples, manuelle Reviews', '○ In Entwicklung'],
]}
/>
</div>
</section>
)
}

View File

@@ -0,0 +1,89 @@
/**
* Section 5: Informationspflichten (Art. 13/14 DSGVO)
* Section 6: Automatisierte Entscheidungsfindung (Art. 22 DSGVO)
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function InformationspflichtenAndArt22() {
return (
<>
{/* Section 5 */}
<section id="section-5" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
5. Informationspflichten (Art. 13/14 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">5.1 Pflichtangaben nach Art. 13 DSGVO</h3>
<Table
headers={['Information', 'Bereitstellung']}
rows={[
['Identität des Verantwortlichen', 'Schulwebsite, Datenschutzerklärung'],
['Kontakt DSB', 'Schulwebsite, Aushang'],
['Verarbeitungszwecke', 'Datenschutzinformation bei Einschulung'],
['Rechtsgrundlage', 'Datenschutzinformation'],
['Empfänger/Kategorien', 'Datenschutzinformation'],
['Speicherdauer', 'Datenschutzinformation'],
['Betroffenenrechte', 'Datenschutzinformation, auf Anfrage'],
['Beschwerderecht', 'Datenschutzinformation'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">5.2 KI-spezifische Transparenz</h3>
<Table
headers={['Information', 'Inhalt']}
rows={[
['Art der KI', 'Vision-LLM für Texterkennung, kein automatisches Bewerten'],
['Menschliche Aufsicht', 'Jedes OCR-Ergebnis wird von Lehrkraft geprüft'],
['Keine automatische Entscheidung', 'System macht Vorschläge, Lehrkraft entscheidet'],
['Widerspruchsrecht', 'Opt-out von Training-Verwendung möglich'],
]}
/>
</section>
{/* Section 6 */}
<section id="section-6" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
6. Automatisierte Entscheidungsfindung (Art. 22 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">6.1 Anwendbarkeitsprüfung</h3>
<Table
headers={['Merkmal', 'Erfüllt', 'Begründung']}
rows={[
['Automatisierte Verarbeitung', 'Ja', 'KI-gestützte Texterkennung'],
['Entscheidung', 'Nein', 'OCR liefert nur Vorschlag'],
['Rechtliche Wirkung/erhebliche Beeinträchtigung', 'Nein', 'Lehrkraft trifft finale Bewertungsentscheidung'],
]}
/>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mt-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Art. 22 DSGVO ist <strong>nicht anwendbar</strong>, da keine automatisierte Entscheidung mit rechtlicher Wirkung erfolgt.
</p>
</div>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">6.2 Teacher-in-the-Loop Garantie</h3>
<p className="text-slate-600 mb-4">Das System implementiert obligatorische menschliche Aufsicht:</p>
<CodeBlock>{`┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ OCR-System │────►│ Lehrkraft │────►│ Bewertung │
│ (Vorschlag) │ │ (Prüfung) │ │ (Final) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ ▼ │
│ ┌──────────────┐ │
└───────────►│ Korrektur │◄───────────┘
│ (Optional) │
└──────────────┘`}</CodeBlock>
<p className="text-slate-600 mt-4 mb-2"><strong>Workflow-Garantien:</strong></p>
<ol className="list-decimal list-inside text-slate-600 space-y-1 ml-4">
<li>Kein OCR-Ergebnis wird automatisch als korrekt übernommen</li>
<li>Lehrkraft muss explizit bestätigen ODER korrigieren</li>
<li>Bewertungsentscheidung liegt ausschließlich bei der Lehrkraft</li>
<li>System gibt keine Notenvorschläge</li>
</ol>
</section>
</>
)
}

View File

@@ -0,0 +1,164 @@
/**
* Section 16: Kontakte
* Section 17: Voice Service DSGVO-Compliance
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function KontakteAndVoice() {
return (
<>
{/* Section 16 */}
<section id="section-16" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
16. Kontakte
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">16.1 Interne Kontakte</h3>
<Table
headers={['Rolle', 'Name', 'Kontakt']}
rows={[
['Schulleitung', '[Name]', '[E-Mail]'],
['IT-Administrator', '[Name]', '[E-Mail]'],
['Datenschutzbeauftragter', '[Name]', '[E-Mail]'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">16.2 Externe Kontakte</h3>
<Table
headers={['Institution', 'Kontakt']}
rows={[
['LfD Niedersachsen', 'poststelle@lfd.niedersachsen.de'],
['BSI', 'bsi@bsi.bund.de'],
]}
/>
</section>
{/* Section 17 */}
<section id="section-17" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
17. Voice Service DSGVO-Compliance
</h2>
<div className="bg-teal-50 border border-teal-200 rounded-lg p-4 mb-6">
<p className="text-teal-800">
<strong>NEU:</strong> Das Voice Service implementiert eine Voice-First Schnittstelle fuer Lehrkraefte mit
PersonaPlex-7B (Full-Duplex Speech-to-Speech) und TaskOrchestrator (Agent-Orchestrierung).
<strong> Alle Audiodaten werden ausschliesslich transient im RAM verarbeitet und niemals persistiert.</strong>
</p>
</div>
<div id="section-17-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.1 Architektur & Datenfluss</h3>
<CodeBlock>{`┌──────────────────────────────────────────────────────────────────┐
│ LEHRERGERÄT (PWA / App) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
│ └────────────────────────────────────────────────────────────┘ │
│ Namespace-Key: NIEMALS verlässt dieses Gerät │
└───────────────────────────┬──────────────────────────────────────┘
│ WebSocket (wss://) - verschlüsselt
┌──────────────────────────────────────────────────────────────────┐
│ VOICE SERVICE (Port 8091) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ TRANSIENT ONLY: Audio nur im RAM, nie persistiert! │ │
│ │ Kein Klartext-PII: Nur Pseudonyme serverseitig erlaubt │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
│ (A100 GPU) │ │ (Mac Mini) │ │ (nur Session- │
│ Full-Duplex │ │ Text-only │ │ Metadaten) │
└─────────────────┘ └─────────────────┘ └─────────────────┘`}</CodeBlock>
</div>
<div id="section-17-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.2 Datenklassifizierung</h3>
<Table
headers={['Datenklasse', 'Verarbeitung', 'Speicherort', 'Beispiele']}
rows={[
['PII (Personenbezogen)', 'NUR auf Lehrergerät', 'Client-side IndexedDB', 'Schülernamen, Noten, Vorfälle'],
['Pseudonyme', 'Server erlaubt', 'Valkey Cache', 'student_ref, class_ref'],
['Content (Transkripte)', 'NUR verschlüsselt', 'Valkey (TTL 7d)', 'Voice-Transkripte'],
['Audio-Daten', 'NIEMALS persistiert', 'NUR RAM (transient)', 'Sprachaufnahmen'],
]}
/>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<p className="text-red-800 font-medium">
<strong>KRITISCH:</strong> Audio-Daten dürfen unter keinen Umständen persistiert werden (AUDIO_PERSISTENCE=false).
Dies ist eine harte DSGVO-Anforderung zum Schutz der Privatsphäre.
</p>
</div>
</div>
<div id="section-17-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.3 Verschlüsselung</h3>
<Table
headers={['Bereich', 'Verfahren', 'Key-Management']}
rows={[
['Client-side Encryption', 'AES-256-GCM', 'Master-Key in IndexedDB (nie Server)'],
['Key-Identifikation', 'SHA-256 Hash', 'Nur Hash wird zum Server gesendet'],
['Transport', 'TLS 1.3 + WSS', 'Standard-Zertifikate'],
['Namespace-Isolation', 'Pro-Lehrer-Namespace', 'Schlüssel verlässt nie das Gerät'],
]}
/>
<p className="text-slate-600 mt-4">
<strong>Wichtig:</strong> Der Server erhält niemals den Klartext-Schlüssel. Es wird nur ein SHA-256 Hash
zur Verifizierung übermittelt. Alle sensiblen Daten werden <em>vor</em> der Übertragung client-seitig verschlüsselt.
</p>
</div>
<div id="section-17-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.4 TTL & Automatische Löschung</h3>
<Table
headers={['Datentyp', 'TTL', 'Löschung', 'Beschreibung']}
rows={[
['Audio-Frames', '0 (keine Speicherung)', 'Sofort nach Verarbeitung', 'Nur transient im RAM'],
['Voice-Transkripte', '7 Tage', 'Automatisch', 'Verschlüsselte Transkripte in Valkey'],
['Task State', '30 Tage', 'Automatisch', 'Workflow-Daten (Draft, Queued, etc.)'],
['Audit Logs', '90 Tage', 'Automatisch', 'Compliance-Nachweise (ohne PII)'],
]}
/>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mt-4">
<p className="text-green-800 font-medium">
<strong>Compliance:</strong> Die TTL-basierte Auto-Löschung ist durch Valkey-Mechanismen sichergestellt und
erfordert keine manuelle Intervention.
</p>
</div>
</div>
<div id="section-17-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.5 Audit-Logs (ohne PII)</h3>
<p className="text-slate-600 mb-4">Audit-Logs enthalten ausschließlich nicht-personenbezogene Metadaten:</p>
<Table
headers={['Erlaubt', 'Verboten']}
rows={[
['ref_id (truncated hash)', 'user_name'],
['content_type', 'content / transcript'],
['size_bytes', 'email'],
['ttl_hours', 'student_name'],
['timestamp', 'Klartext-Audio'],
]}
/>
<CodeBlock>{`// Beispiel: Erlaubter Audit-Log-Eintrag
{
"ref_id": "abc123...", // truncated
"content_type": "transcript",
"size_bytes": 1234,
"ttl_hours": 168, // 7 Tage
"timestamp": "2026-01-26T10:30:00Z"
}
// VERBOTEN:
// user_name, content, transcript, email, student_name, audio_data`}</CodeBlock>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,106 @@
/**
* Section 11: ML/AI Training Dokumentation
* Section 12: Betroffenenrechte
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function MLTrainingAndRechte() {
return (
<>
{/* Section 11 */}
<section id="section-11" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
11. ML/AI Training Dokumentation
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">11.1 Trainingsdaten-Quellen</h3>
<Table
headers={['Datensatz', 'Quelle', 'Rechtsgrundlage', 'Volumen']}
rows={[
['Klausur-Scans', 'Schulinterne Prüfungen', 'Art. 6(1)(e) + Einwilligung', 'Variabel'],
['Lehrer-Korrekturen', 'Labeling-System', 'Art. 6(1)(e)', 'Variabel'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.2 Datenqualitätsmaßnahmen</h3>
<Table
headers={['Maßnahme', 'Beschreibung']}
rows={[
['Deduplizierung', 'SHA256-Hash zur Vermeidung von Duplikaten'],
['Qualitätskontrolle', 'Jedes Sample von Lehrkraft geprüft'],
['Repräsentativität', 'Samples aus verschiedenen Fächern/Klassenstufen'],
['Dokumentation', 'Metadaten zu jedem Sample'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.3 Labeling-Prozess</h3>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────┐
│ LABELING WORKFLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Bild-Upload 2. OCR-Vorschlag 3. Review │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Scan │─────────►│ LLM-OCR │─────────►│ Lehrkraft │ │
│ │ Upload │ │ (lokal) │ │ prüft │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌──────────────────────┴─────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────┐ │
│ │ Bestätigt │ │Korrigiert│ │
│ │ (korrekt) │ │(manuell) │ │
│ └─────────────┘ └─────────┘ │
│ │ │ │
│ └──────────┬─────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Ground Truth │ │
│ │ (verifiziert) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.4 Export-Prozeduren</h3>
<Table
headers={['Schritt', 'Beschreibung', 'Datenschutzmaßnahme']}
rows={[
['1. Auswahl', 'Sessions/Items für Export wählen', 'Nur bestätigte/korrigierte Items'],
['2. Pseudonymisierung', 'Entfernung direkter Identifikatoren', 'UUID statt Schüler-ID'],
['3. Format-Konvertierung', 'TrOCR/Llama/Generic Format', 'Nur notwendige Felder'],
['4. Speicherung', 'Lokal in /app/ocr-exports/', 'Verschlüsselt, zugriffsbeschränkt'],
]}
/>
</section>
{/* Section 12 */}
<section id="section-12" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
12. Betroffenenrechte
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">12.1 Implementierte Rechte</h3>
<Table
headers={['Recht', 'Art. DSGVO', 'Umsetzung']}
rows={[
['Auskunft', '15', 'Schriftliche Anfrage an DSB'],
['Berichtigung', '16', 'Korrektur falscher OCR-Ergebnisse'],
['Löschung', '17', 'Nach Aufbewahrungsfrist oder auf Antrag'],
['Einschränkung', '18', 'Sperrung der Verarbeitung auf Antrag'],
['Datenportabilität', '20', 'Export eigener Daten in JSON'],
['Widerspruch', '21', 'Opt-out von Training-Verwendung'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">12.2 Sonderrechte bei KI-Training</h3>
<Table
headers={['Recht', 'Umsetzung']}
rows={[
['Widerspruch gegen Training', 'Daten werden nicht für Fine-Tuning verwendet'],
['Löschung aus Trainingsset', '"Machine Unlearning" durch Re-Training ohne betroffene Daten'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Section 1: Management Summary
* Subsections: 1.1 Systemuebersicht, 1.2 Datenschutz-Garantien, 1.3 Compliance-Status
*/
import { Table } from '../Table'
export function ManagementSummary() {
return (
<section id="section-1" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
1. Management Summary
</h2>
<div id="section-1-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.1 Systemübersicht</h3>
<p className="text-slate-600 mb-4">
Das OCR-Labeling-System ist eine <strong>vollständig lokal betriebene</strong> Lösung zur Digitalisierung und Auswertung handschriftlicher Schülerarbeiten (Klausuren, Aufsätze). Das System nutzt:
</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li><strong>llama3.2-vision:11b</strong> - Open-Source Vision-Language-Modell für OCR (lokal via Ollama)</li>
<li><strong>TrOCR</strong> - Microsoft Transformer OCR für Handschrifterkennung (lokal)</li>
<li><strong>qwen2.5:14b</strong> - Open-Source LLM für Korrekturassistenz (lokal via Ollama)</li>
</ul>
</div>
<div id="section-1-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.2 Datenschutz-Garantien</h3>
<Table
headers={['Merkmal', 'Umsetzung']}
rows={[
['Verarbeitungsort', '100% lokal auf schuleigenem Mac Mini'],
['Cloud-Dienste', 'Keine - vollständig offline-fähig'],
['Datenübertragung', 'Keine Übertragung an externe Server'],
['KI-Modelle', 'Open-Source, lokal ausgeführt, keine Telemetrie'],
['Speicherung', 'Lokale PostgreSQL-Datenbank, MinIO Object Storage'],
]}
/>
</div>
<div id="section-1-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.3 Compliance-Status</h3>
<p className="text-slate-600 mb-2">Das System erfüllt die Anforderungen der:</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li>DSGVO (Verordnung (EU) 2016/679)</li>
<li>BDSG (Bundesdatenschutzgesetz)</li>
<li>Niedersächsisches Schulgesetz (NSchG) §31</li>
<li>EU AI Act (Verordnung (EU) 2024/1689)</li>
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,117 @@
/**
* Section 7: Privacy by Design und Default (Art. 25 DSGVO)
* Section 8: Technisch-Organisatorische Massnahmen (Art. 32 DSGVO)
*/
import { Table } from '../Table'
export function PrivacyByDesignAndTOM() {
return (
<>
{/* Section 7 */}
<section id="section-7" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
7. Privacy by Design und Default (Art. 25 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">7.1 Design-Prinzipien</h3>
<Table
headers={['Prinzip', 'Implementierung']}
rows={[
['Proaktive Maßnahmen', 'Datenschutz von Anfang an im System-Design berücksichtigt'],
['Standard-Datenschutz', 'Minimale Datenerhebung als Default'],
['Eingebetteter Datenschutz', 'Technische Maßnahmen nicht umgehbar'],
['Volle Funktionalität', 'Kein Trade-off Datenschutz vs. Funktionalität'],
['End-to-End Sicherheit', 'Verschlüsselung vom Upload bis zur Löschung'],
['Sichtbarkeit/Transparenz', 'Alle Verarbeitungen protokolliert und nachvollziehbar'],
['Nutzerzentrierung', 'Betroffenenrechte einfach ausübbar'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">7.2 Vendor-Auswahl</h3>
<p className="text-slate-600 mb-4">Die verwendeten KI-Modelle wurden nach Datenschutzkriterien ausgewählt:</p>
<Table
headers={['Modell', 'Anbieter', 'Lizenz', 'Lokale Ausführung', 'Telemetrie']}
rows={[
['llama3.2-vision:11b', 'Meta', 'Llama 3.2 Community', '✓', 'Keine'],
['qwen2.5:14b', 'Alibaba', 'Apache 2.0', '✓', 'Keine'],
['TrOCR', 'Microsoft', 'MIT', '✓', 'Keine'],
]}
/>
</section>
{/* Section 8 */}
<section id="section-8" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
8. Technisch-Organisatorische Maßnahmen (Art. 32 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">8.1 Vertraulichkeit</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">8.1.1 Zutrittskontrolle</h4>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Physische Sicherung', 'Server in abgeschlossenem Raum'],
['Zugangsprotokoll', 'Elektronisches Schloss mit Protokollierung'],
['Berechtigte Personen', 'IT-Administrator, Schulleitung'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.2 Zugangskontrolle</h4>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Authentifizierung', 'Benutzername + Passwort'],
['Passwort-Policy', 'Min. 12 Zeichen, Komplexitätsanforderungen'],
['Session-Timeout', '30 Minuten Inaktivität'],
['Fehlversuche', 'Account-Sperrung nach 5 Fehlversuchen'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.3 Zugriffskontrolle (RBAC)</h4>
<Table
headers={['Rolle', 'Berechtigungen']}
rows={[
['Admin', 'Vollzugriff, Benutzerverwaltung'],
['Lehrkraft', 'Eigene Sessions, Labeling, Export'],
['Viewer', 'Nur Lesezugriff auf Statistiken'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.4 Verschlüsselung</h4>
<Table
headers={['Bereich', 'Maßnahme']}
rows={[
['Festplatte', 'FileVault 2 (AES-256)'],
['Datenbank', 'Transparent Data Encryption'],
['MinIO Storage', 'Server-Side Encryption (SSE)'],
['Netzwerk', 'TLS 1.3 für lokale Verbindungen'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-8 mb-3">8.2 Integrität</h3>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Audit-Log', 'Alle Aktionen mit Timestamp und User-ID'],
['Unveränderlichkeit', 'Append-only Logging'],
['Log-Retention', '1 Jahr'],
['Netzwerkisolation', 'Lokales Netz, keine Internet-Verbindung erforderlich'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-8 mb-3">8.3 Verfügbarkeit</h3>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Backup', 'Tägliches inkrementelles Backup'],
['USV', 'Unterbrechungsfreie Stromversorgung'],
['RAID', 'RAID 1 Spiegelung für Datenträger'],
['Recovery-Test', 'Halbjährlich'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,67 @@
/**
* Section 3: Rechtsgrundlagen (Art. 6 DSGVO)
* Subsections: 3.1 Primaere, 3.2 Landesrecht, 3.3 Besondere Kategorien
*/
import { Table } from '../Table'
export function Rechtsgrundlagen() {
return (
<section id="section-3" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
3. Rechtsgrundlagen (Art. 6 DSGVO)
</h2>
<div id="section-3-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.1 Primäre Rechtsgrundlagen</h3>
<Table
headers={['Verarbeitungsschritt', 'Rechtsgrundlage', 'Begründung']}
rows={[
['Scan von Klausuren', 'Art. 6 Abs. 1 lit. e DSGVO', 'Öffentliche Aufgabe der schulischen Leistungsbewertung'],
['OCR-Verarbeitung', 'Art. 6 Abs. 1 lit. e DSGVO', 'Teil der Bewertungsaufgabe, Effizienzsteigerung'],
['Lehrerkorrektur', 'Art. 6 Abs. 1 lit. e DSGVO', 'Kernaufgabe der Leistungsbewertung'],
['Export für Training', 'Art. 6 Abs. 1 lit. f DSGVO', 'Berechtigtes Interesse an Modellverbesserung'],
]}
/>
</div>
<div id="section-3-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.2 Landesrechtliche Grundlagen</h3>
<p className="text-slate-600 mb-2"><strong>Niedersachsen:</strong></p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>§31 NSchG: Erhebung, Verarbeitung und Nutzung personenbezogener Daten</li>
<li>Ergänzende Bestimmungen zur VO-DV I</li>
</ul>
<p className="text-slate-600 mb-2"><strong>Interesse-Abwägung für Training (Art. 6 Abs. 1 lit. f):</strong></p>
<Table
headers={['Aspekt', 'Bewertung']}
rows={[
['Interesse des Verantwortlichen', 'Verbesserung der OCR-Qualität für effizientere Klausurkorrektur'],
['Erwartung der Betroffenen', 'Schüler erwarten, dass Prüfungsarbeiten für schulische Zwecke verarbeitet werden'],
['Auswirkung auf Betroffene', 'Minimal - Daten werden pseudonymisiert, rein lokale Verarbeitung'],
['Schutzmaßnahmen', 'Pseudonymisierung, keine Weitergabe, lokale Verarbeitung'],
['Ergebnis', 'Berechtigtes Interesse überwiegt'],
]}
/>
</div>
<div id="section-3-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.3 Besondere Kategorien (Art. 9 DSGVO)</h3>
<p className="text-slate-600 mb-2"><strong>Prüfung auf besondere Kategorien:</strong></p>
<p className="text-slate-600 mb-4">
Handschriftproben könnten theoretisch Rückschlüsse auf Gesundheitszustände ermöglichen (z.B. Tremor). Dies wird wie folgt adressiert:
</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>OCR-Modelle analysieren ausschließlich Textinhalt, nicht Handschriftcharakteristiken</li>
<li>Keine Speicherung von Handschriftanalysen</li>
<li>Bei Training werden nur Textinhalte verwendet, keine biometrischen Merkmale</li>
</ul>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Art. 9 ist nicht anwendbar, da keine Verarbeitung besonderer Kategorien erfolgt.
</p>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,98 @@
/**
* Section 13: Schulung und Awareness
* Section 14: Review und Audit
* Section 15: Vorfallmanagement
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function SchulungReviewVorfall() {
return (
<>
{/* Section 13 */}
<section id="section-13" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
13. Schulung und Awareness
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">13.1 Schulungskonzept</h3>
<Table
headers={['Schulung', 'Zielgruppe', 'Frequenz', 'Dokumentation']}
rows={[
['DSGVO-Grundlagen', 'Alle Lehrkräfte', 'Jährlich', 'Teilnehmerliste'],
['OCR-System-Nutzung', 'Nutzende Lehrkräfte', 'Bei Einführung', 'Zertifikat'],
['KI-Kompetenz (AI Act Art. 4)', 'Alle Nutzenden', 'Jährlich', 'Nachweis'],
]}
/>
</section>
{/* Section 14 */}
<section id="section-14" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
14. Review und Audit
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">14.1 Regelmäßige Überprüfungen</h3>
<Table
headers={['Prüfung', 'Frequenz', 'Verantwortlich']}
rows={[
['DSFA-Review', 'Jährlich', 'DSB'],
['TOM-Wirksamkeit', 'Jährlich', 'IT-Administrator'],
['Zugriffsrechte', 'Halbjährlich', 'IT-Administrator'],
['Backup-Test', 'Halbjährlich', 'IT-Administrator'],
['Modell-Bias-Prüfung', 'Jährlich', 'IT + Lehrkräfte'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">14.2 Audit-Trail</h3>
<Table
headers={['Protokollierte Daten', 'Aufbewahrung', 'Format']}
rows={[
['Benutzeraktionen', '1 Jahr', 'PostgreSQL'],
['Systemereignisse', '1 Jahr', 'Syslog'],
['Sicherheitsvorfälle', '3 Jahre', 'Incident-Dokumentation'],
]}
/>
</section>
{/* Section 15 */}
<section id="section-15" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
15. Vorfallmanagement
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">15.1 Datenpannen-Prozess</h3>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────┐
│ INCIDENT RESPONSE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Erkennung ──► Bewertung ──► Meldung ──► Eindämmung ──► Behebung │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ Monitoring Risiko- 72h an LfD Isolation Ursachen- │
│ Audit-Log einschätzung (Art.33) Forensik analyse │
└─────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">15.2 Meldepflichten</h3>
<Table
headers={['Ereignis', 'Frist', 'Empfänger']}
rows={[
['Datenpanne mit Risiko', '72 Stunden', 'Landesbeauftragte/r für Datenschutz'],
['Hohes Risiko für Betroffene', 'Unverzüglich', 'Betroffene Personen'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">15.3 KI-spezifische Vorfälle</h3>
<Table
headers={['Vorfall', 'Reaktion']}
rows={[
['Systematisch falsche OCR-Ergebnisse', 'Modell-Rollback, Analyse'],
['Bias-Erkennung', 'Untersuchung, ggf. Re-Training'],
['Adversarial Attack', 'System-Isolierung, Forensik'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,72 @@
/**
* Section 2: Verzeichnis der Verarbeitungstaetigkeiten (Art. 30 DSGVO)
* Subsections: 2.1 Verantwortlicher, 2.2 DSB, 2.3 Verarbeitungstaetigkeiten
*/
import { Table } from '../Table'
export function VerarbeitungsTaetigkeiten() {
return (
<section id="section-2" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
2. Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
</h2>
<div id="section-2-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.1 Verantwortlicher</h3>
<Table
headers={['Feld', 'Inhalt']}
rows={[
['Verantwortlicher', '[Schulname], [Schuladresse]'],
['Vertreter', 'Schulleitung: [Name]'],
['Kontakt', '[E-Mail], [Telefon]'],
]}
/>
</div>
<div id="section-2-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.2 Datenschutzbeauftragter</h3>
<Table
headers={['Feld', 'Inhalt']}
rows={[
['Name', '[Name DSB]'],
['Organisation', '[Behördlicher/Externer DSB]'],
['Kontakt', '[E-Mail], [Telefon]'],
]}
/>
</div>
<div id="section-2-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.3 Verarbeitungstätigkeiten</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">2.3.1 OCR-Verarbeitung von Klausuren</h4>
<Table
headers={['Attribut', 'Beschreibung']}
rows={[
['Zweck', 'Digitalisierung handschriftlicher Prüfungsantworten mittels KI-gestützter Texterkennung zur Unterstützung der Lehrkräfte bei der Korrektur'],
['Rechtsgrundlage', 'Art. 6 Abs. 1 lit. e DSGVO i.V.m. §31 NSchG (öffentliche Aufgabe der Leistungsbewertung)'],
['Betroffene Personen', 'Schülerinnen und Schüler (Prüfungsarbeiten)'],
['Datenkategorien', 'Handschriftproben, Prüfungsantworten, Schülerkennung (optional)'],
['Empfänger', 'Ausschließlich berechtigte Lehrkräfte der Schule'],
['Drittlandübermittlung', 'Keine'],
['Löschfrist', 'Gem. Aufbewahrungspflichten für Prüfungsunterlagen (i.d.R. 2-10 Jahre je nach Bundesland)'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">2.3.2 Labeling für Modell-Training</h4>
<Table
headers={['Attribut', 'Beschreibung']}
rows={[
['Zweck', 'Erstellung von Trainingsdaten für lokales Fine-Tuning der OCR-Modelle zur Verbesserung der Handschrifterkennung'],
['Rechtsgrundlage', 'Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) oder Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)'],
['Betroffene Personen', 'Schülerinnen und Schüler (anonymisierte Handschriftproben)'],
['Datenkategorien', 'Anonymisierte/pseudonymisierte Handschriftbilder, korrigierter Text'],
['Empfänger', 'Lokales ML-System, keine externen Empfänger'],
['Drittlandübermittlung', 'Keine'],
['Löschfrist', 'Trainingsdaten: Nach Abschluss des Trainings oder auf Widerruf'],
]}
/>
</div>
</section>
)
}

View File

@@ -0,0 +1,12 @@
export { ManagementSummary } from './ManagementSummary'
export { VerarbeitungsTaetigkeiten } from './VerarbeitungsTaetigkeiten'
export { Rechtsgrundlagen } from './Rechtsgrundlagen'
export { DatenschutzFolgen } from './DatenschutzFolgen'
export { InformationspflichtenAndArt22 } from './InformationspflichtenAndArt22'
export { PrivacyByDesignAndTOM } from './PrivacyByDesignAndTOM'
export { BSIAndEUAIAct } from './BSIAndEUAIAct'
export { MLTrainingAndRechte } from './MLTrainingAndRechte'
export { SchulungReviewVorfall } from './SchulungReviewVorfall'
export { KontakteAndVoice } from './KontakteAndVoice'
export { BQASScheduler } from './BQASScheduler'
export { Anhaenge } from './Anhaenge'

View File

@@ -0,0 +1,54 @@
/**
* Table of contents sections for the DSGVO Audit Documentation navigation.
*/
export interface Section {
id: string
title: string
level: number
}
export const SECTIONS: Section[] = [
{ id: '1', title: 'Management Summary', level: 2 },
{ id: '1-1', title: 'Systemuebersicht', level: 3 },
{ id: '1-2', title: 'Datenschutz-Garantien', level: 3 },
{ id: '1-3', title: 'Compliance-Status', level: 3 },
{ id: '2', title: 'Verzeichnis der Verarbeitungstaetigkeiten', level: 2 },
{ id: '2-1', title: 'Verantwortlicher', level: 3 },
{ id: '2-2', title: 'Datenschutzbeauftragter', level: 3 },
{ id: '2-3', title: 'Verarbeitungstaetigkeiten', level: 3 },
{ id: '3', title: 'Rechtsgrundlagen (Art. 6)', level: 2 },
{ id: '3-1', title: 'Primaere Rechtsgrundlagen', level: 3 },
{ id: '3-2', title: 'Landesrechtliche Grundlagen', level: 3 },
{ id: '3-3', title: 'Besondere Kategorien (Art. 9)', level: 3 },
{ id: '4', title: 'Datenschutz-Folgenabschaetzung', level: 2 },
{ id: '4-1', title: 'Schwellwertanalyse', level: 3 },
{ id: '4-2', title: 'Systematische Beschreibung', level: 3 },
{ id: '4-3', title: 'Notwendigkeit und Verhaeltnismaessigkeit', level: 3 },
{ id: '4-4', title: 'Risikobewertung', level: 3 },
{ id: '4-5', title: 'Massnahmen zur Risikominderung', level: 3 },
{ id: '5', title: 'Informationspflichten', level: 2 },
{ id: '6', title: 'Automatisierte Entscheidungsfindung', level: 2 },
{ id: '7', title: 'Privacy by Design', level: 2 },
{ id: '8', title: 'Technisch-Organisatorische Massnahmen', level: 2 },
{ id: '9', title: 'BSI-Anforderungen', level: 2 },
{ id: '10', title: 'EU AI Act Compliance', level: 2 },
{ id: '11', title: 'ML/AI Training Dokumentation', level: 2 },
{ id: '12', title: 'Betroffenenrechte', level: 2 },
{ id: '13', title: 'Schulung und Awareness', level: 2 },
{ id: '14', title: 'Review und Audit', level: 2 },
{ id: '15', title: 'Vorfallmanagement', level: 2 },
{ id: '16', title: 'Kontakte', level: 2 },
{ id: '17', title: 'Voice Service DSGVO', level: 2 },
{ id: '17-1', title: 'Architektur & Datenfluss', level: 3 },
{ id: '17-2', title: 'Datenklassifizierung', level: 3 },
{ id: '17-3', title: 'Verschluesselung', level: 3 },
{ id: '17-4', title: 'TTL & Auto-Loeschung', level: 3 },
{ id: '17-5', title: 'Audit-Logs', level: 3 },
{ id: '18', title: 'BQAS Lokaler Scheduler', level: 2 },
{ id: '18-1', title: 'GitHub Actions Alternative', level: 3 },
{ id: '18-2', title: 'Datenschutz-Vorteile', level: 3 },
{ id: '18-3', title: 'Komponenten', level: 3 },
{ id: '18-4', title: 'Datenverarbeitung', level: 3 },
{ id: '18-5', title: 'Benachrichtigungen', level: 3 },
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
/**
* Static data for Developer Documentation Page
*/
import type { ServiceNode, ArchitectureLayer, ServiceDefinition } from './types'
// Documentation paths for VS Code links
export const docPaths: Record<string, string> = {
'postgres': 'docs/architecture/data-model.md',
'backend': 'docs/backend/README.md',
'consent-service': 'docs/consent-service/README.md',
'billing-service': 'docs/billing/billing-service-api.md',
'edu-search-service': 'docs/api/edu-search-seeds-api.md',
'dsms-gateway': 'docs/dsms/README.md',
'pca-platform': 'docs/api/pca-platform-api.md',
'matrix-synapse': 'docs/matrix/README.md',
'jitsi': 'docs/jitsi/README.md',
'mailpit': 'docs/guides/email-and-auth-testing.md',
'llm-gateway': 'docs/llm-platform/README.md',
'website': 'docs/website/README.md',
'opensearch': 'docs/llm-platform/guides/ollama-setup.md',
'klausur': 'klausur-service/docs/RAG-Admin-Spec.md',
'qdrant': 'klausur-service/docs/RAG-Admin-Spec.md',
'minio': 'klausur-service/docs/RAG-Admin-Spec.md',
}
// Base path for project (used for VS Code links)
export const PROJECT_BASE_PATH = '/Users/benjaminadmin/Projekte/breakpilot-pwa'
// All services in the architecture (30+ services)
export const ARCHITECTURE_SERVICES: ServiceNode[] = [
// Frontends
{ id: 'website', name: 'Admin Frontend', type: 'frontend', port: '3000', technology: 'Next.js 15', description: 'Admin Dashboard, Edu-Search, DSMS, Consent', connections: ['backend', 'consent-service'] },
{ id: 'studio', name: 'Lehrer Studio', type: 'frontend', port: '8000', technology: 'FastAPI + JS', description: 'Klausur, School, Stundenplan Module', connections: ['backend'] },
{ id: 'creator', name: 'Creator Studio', type: 'frontend', port: '-', technology: 'Vue 3', description: 'Content Creation Interface', connections: ['backend'] },
{ id: 'policy-ui', name: 'Policy Vault UI', type: 'frontend', port: '4200', technology: 'Angular 17', description: 'Richtlinien-Verwaltung', connections: ['policy-api'] },
// Python Backend
{ id: 'backend', name: 'Main Backend', type: 'backend', port: '8000', technology: 'Python FastAPI', description: 'Haupt-API, DevSecOps, Studio UI', connections: ['postgres', 'vault', 'redis', 'qdrant', 'minio'] },
{ id: 'klausur', name: 'Klausur Service', type: 'backend', port: '8086', technology: 'Python FastAPI', description: 'BYOEH Abitur-Klausurkorrektur, RAG Admin, NiBiS Ingestion', connections: ['postgres', 'minio', 'qdrant'] },
// Go Microservices
{ id: 'consent-service', name: 'Consent Service', type: 'backend', port: '8081', technology: 'Go Gin', description: 'DSGVO Consent Management', connections: ['postgres'] },
{ id: 'school-service', name: 'School Service', type: 'backend', port: '8084', technology: 'Go Gin', description: 'Klausuren, Noten, Zeugnisse', connections: ['postgres'] },
{ id: 'billing-service', name: 'Billing Service', type: 'backend', port: '8083', technology: 'Go Gin', description: 'Stripe Integration', connections: ['postgres'] },
{ id: 'dsms-gateway', name: 'DSMS Gateway', type: 'backend', port: '8082', technology: 'Go', description: 'IPFS REST API', connections: ['ipfs'] },
// Node.js Services
{ id: 'h5p', name: 'H5P Service', type: 'backend', port: '8085', technology: 'Node.js', description: 'Interaktive Inhalte', connections: ['postgres', 'minio'] },
{ id: 'policy-api', name: 'Policy Vault API', type: 'backend', port: '3001', technology: 'NestJS', description: 'Richtlinien-Verwaltung API', connections: ['postgres'] },
// Databases
{ id: 'postgres', name: 'PostgreSQL', type: 'database', port: '5432', technology: 'PostgreSQL 16', description: 'Hauptdatenbank', connections: [] },
{ id: 'synapse-db', name: 'Synapse DB', type: 'database', port: '-', technology: 'PostgreSQL 16', description: 'Matrix Datenbank', connections: [] },
{ id: 'mariadb', name: 'MariaDB', type: 'database', port: '-', technology: 'MariaDB 10.6', description: 'ERPNext Datenbank', connections: [] },
{ id: 'mongodb', name: 'MongoDB', type: 'database', port: '27017', technology: 'MongoDB 7', description: 'LibreChat Datenbank', connections: [] },
// Cache & Queue
{ id: 'redis', name: 'Redis', type: 'cache', port: '6379', technology: 'Redis Alpine', description: 'Cache & Sessions', connections: [] },
// Search Engines
{ id: 'qdrant', name: 'Qdrant', type: 'search', port: '6333', technology: 'Qdrant 1.7', description: 'Vector DB - NiBiS EWH (7352 Chunks), BYOEH', connections: [] },
{ id: 'opensearch', name: 'OpenSearch', type: 'search', port: '9200', technology: 'OpenSearch 2.x', description: 'Volltext-Suche', connections: [] },
{ id: 'meilisearch', name: 'Meilisearch', type: 'search', port: '7700', technology: 'Meilisearch', description: 'Instant Search', connections: [] },
// Storage
{ id: 'minio', name: 'MinIO', type: 'storage', port: '9000/9001', technology: 'MinIO', description: 'S3-kompatibel - RAG Dokumente, Landes/Lehrer-Daten', connections: [] },
{ id: 'ipfs', name: 'IPFS (Kubo)', type: 'storage', port: '5001', technology: 'IPFS 0.24', description: 'Dezentral', connections: [] },
// Security
{ id: 'vault', name: 'Vault', type: 'security', port: '8200', technology: 'HashiCorp Vault', description: 'Secrets Management', connections: [] },
{ id: 'keycloak', name: 'Keycloak', type: 'security', port: '8180', technology: 'Keycloak 23', description: 'SSO/OIDC', connections: ['postgres'] },
// Communication
{ id: 'synapse', name: 'Matrix Synapse', type: 'communication', port: '8008', technology: 'Matrix', description: 'E2EE Messenger', connections: ['synapse-db'] },
{ id: 'jitsi', name: 'Jitsi Meet', type: 'communication', port: '8443', technology: 'Jitsi', description: 'Videokonferenz', connections: [] },
// AI/LLM
{ id: 'librechat', name: 'LibreChat', type: 'ai', port: '3080', technology: 'LibreChat', description: 'Multi-LLM Chat', connections: ['mongodb', 'qdrant'] },
{ id: 'ragflow', name: 'RAGFlow', type: 'ai', port: '9380', technology: 'RAGFlow', description: 'RAG Pipeline', connections: ['qdrant', 'opensearch'] },
// ERP
{ id: 'erpnext', name: 'ERPNext', type: 'erp', port: '8090', technology: 'ERPNext v15', description: 'Open Source ERP', connections: ['mariadb', 'redis'] },
]
// Architecture layers
export const LAYERS: ArchitectureLayer[] = [
{ id: 'presentation', name: 'Presentation Layer', description: 'User Interfaces & Frontends', types: ['frontend'] },
{ id: 'application', name: 'Application Layer', description: 'Business Logic & APIs', types: ['backend'] },
{ id: 'data', name: 'Data Layer', description: 'Databases, Cache & Search', types: ['database', 'cache', 'search'] },
{ id: 'infrastructure', name: 'Infrastructure Layer', description: 'Storage, Security & Communication', types: ['storage', 'security', 'communication', 'ai', 'erp'] },
]
// Service definitions with ports, technologies, and API info
export const services: ServiceDefinition[] = [
{
id: 'postgres',
name: 'PostgreSQL',
type: 'database',
port: 5432,
container: 'breakpilot-pwa-postgres',
description: 'Zentrale Datenbank für alle Services',
purpose: 'Persistente Datenspeicherung für Benutzer, Dokumente, Consents und alle Anwendungsdaten mit pgvector für Embedding-Suche.',
tech: ['PostgreSQL 15', 'pgvector'],
healthEndpoint: null,
endpoints: [],
envVars: ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DB'],
},
{
id: 'backend',
name: 'Python Backend',
type: 'backend',
port: 8000,
container: 'breakpilot-pwa-backend',
description: 'FastAPI Backend mit AI-Integration und GDPR-Export',
purpose: 'Zentrale API-Schicht für das Studio-Frontend mit AI-gestützter Arbeitsblatt-Generierung, Multi-LLM-Integration und DSGVO-konformem Datenexport.',
tech: ['Python 3.11', 'FastAPI', 'SQLAlchemy', 'Pydantic'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/chat', description: 'AI Chat Endpoint' },
{ method: 'GET', path: '/api/v1/gdpr/export', description: 'DSGVO Datenexport' },
{ method: 'POST', path: '/api/v1/seeds', description: 'Edu Search Seeds' },
],
envVars: ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
},
{
id: 'consent-service',
name: 'Consent Service',
type: 'backend',
port: 8081,
container: 'breakpilot-pwa-consent-service',
description: 'Go-basierter Consent-Management-Service',
purpose: 'DSGVO-konforme Einwilligungsverwaltung mit Versionierung, Audit-Trail und rechtssicherer Dokumentenspeicherung für Schulen.',
tech: ['Go 1.21', 'Gin', 'GORM', 'JWT'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'GET', path: '/api/v1/consent/check', description: 'Consent Status pruefen' },
{ method: 'POST', path: '/api/v1/consent/grant', description: 'Consent erteilen' },
{ method: 'GET', path: '/api/v1/documents', description: 'Rechtsdokumente abrufen' },
{ method: 'GET', path: '/api/v1/communication/status', description: 'Matrix/Jitsi Status' },
{ method: 'POST', path: '/api/v1/communication/rooms', description: 'Matrix Raum erstellen' },
{ method: 'POST', path: '/api/v1/communication/meetings', description: 'Jitsi Meeting erstellen' },
],
envVars: ['DATABASE_URL', 'JWT_SECRET', 'PORT', 'MATRIX_HOMESERVER_URL', 'JITSI_BASE_URL'],
},
{
id: 'billing-service',
name: 'Billing Service',
type: 'backend',
port: 8083,
container: 'breakpilot-pwa-billing-service',
description: 'Stripe-basiertes Billing mit Trial & Subscription',
purpose: 'Monetarisierung der Plattform mit 7-Tage-Trial, gestuften Abo-Modellen (Basic/Standard/Premium) und automatischer Nutzungslimitierung.',
tech: ['Go 1.21', 'Gin', 'Stripe API', 'pgx'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/billing/status', description: 'Subscription Status' },
{ method: 'POST', path: '/api/v1/billing/trial/start', description: 'Trial starten' },
{ method: 'POST', path: '/api/v1/billing/webhook', description: 'Stripe Webhooks' },
],
envVars: ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],
},
{
id: 'edu-search-service',
name: 'Edu Search Service',
type: 'backend',
port: 8086,
container: 'breakpilot-edu-search',
description: 'Bildungsquellen-Crawler mit OpenSearch-Integration',
purpose: 'Automatisches Crawlen und Indexieren von Bildungsressourcen (OER, Lehrpläne, Schulbücher) für RAG-gestützte Arbeitsblatterstellung.',
tech: ['Go 1.23', 'Gin', 'OpenSearch', 'Colly'],
healthEndpoint: '/v1/health',
endpoints: [
{ method: 'GET', path: '/v1/health', description: 'Health Check' },
{ method: 'GET', path: '/v1/search', description: 'Dokumentensuche' },
{ method: 'POST', path: '/v1/crawl/start', description: 'Crawler starten' },
{ method: 'GET', path: '/api/v1/staff/stats', description: 'Staff Statistiken' },
{ method: 'POST', path: '/api/v1/admin/crawl/staff', description: 'Staff Crawl starten' },
],
envVars: ['OPENSEARCH_URL', 'DB_HOST', 'DB_USER', 'DB_PASSWORD'],
},
{
id: 'dsms-gateway',
name: 'DSMS Gateway',
type: 'backend',
port: 8082,
container: 'breakpilot-pwa-dsms-gateway',
description: 'Datenschutz-Management Gateway',
purpose: 'Dezentrale Dokumentenspeicherung mit IPFS-Integration für manipulationssichere Audit-Logs und Rechtsdokumente.',
tech: ['Go 1.21', 'Gin', 'IPFS'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/documents', description: 'Dokument speichern' },
],
envVars: ['IPFS_URL', 'DATABASE_URL'],
},
{
id: 'pca-platform',
name: 'PCA Platform',
type: 'backend',
port: 8084,
container: 'breakpilot-pca-platform',
description: 'Payment Card Adapter für Taschengeld-Management',
purpose: 'Fintech-Integration für Schüler-Taschengeld mit virtuellen Karten, Spending-Limits und Echtzeit-Transaktionsverfolgung für Eltern.',
tech: ['Go 1.21', 'Gin', 'Stripe Issuing', 'pgx'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/cards/create', description: 'Virtuelle Karte erstellen' },
{ method: 'GET', path: '/api/v1/transactions', description: 'Transaktionen abrufen' },
{ method: 'POST', path: '/api/v1/wallet/topup', description: 'Wallet aufladen' },
],
envVars: ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],
},
{
id: 'matrix-synapse',
name: 'Matrix Synapse',
type: 'communication',
port: 8448,
container: 'breakpilot-synapse',
description: 'Ende-zu-Ende verschlüsselter Messenger',
purpose: 'Sichere Kommunikation zwischen Lehrern und Eltern mit E2EE, Raum-Management und DSGVO-konformer Nachrichtenspeicherung.',
tech: ['Matrix Protocol', 'Synapse', 'PostgreSQL'],
healthEndpoint: '/_matrix/client/versions',
endpoints: [
{ method: 'GET', path: '/_matrix/client/versions', description: 'Client Versions' },
{ method: 'POST', path: '/_matrix/client/v3/login', description: 'Matrix Login' },
{ method: 'POST', path: '/_matrix/client/v3/createRoom', description: 'Raum erstellen' },
],
envVars: ['SYNAPSE_SERVER_NAME', 'POSTGRES_HOST', 'SYNAPSE_REGISTRATION_SHARED_SECRET'],
},
{
id: 'jitsi',
name: 'Jitsi Meet',
type: 'communication',
port: 8443,
container: 'breakpilot-jitsi',
description: 'Videokonferenz-Plattform',
purpose: 'Virtuelle Elterngespräche und Klassenkonferenzen mit optionaler JWT-Authentifizierung und Embedded-Integration ins Studio.',
tech: ['Jitsi Meet', 'Prosody', 'JWT Auth'],
healthEndpoint: '/http-bind',
endpoints: [
{ method: 'GET', path: '/http-bind', description: 'BOSH Endpoint' },
{ method: 'GET', path: '/config.js', description: 'Jitsi Konfiguration' },
],
envVars: ['JITSI_APP_ID', 'JITSI_APP_SECRET', 'PUBLIC_URL'],
},
{
id: 'mailpit',
name: 'Mailpit (SMTP)',
type: 'infrastructure',
port: 1025,
container: 'breakpilot-pwa-mailpit',
description: 'E-Mail-Testing und Vorschau',
purpose: 'Lokaler SMTP-Server für E-Mail-Vorschau im Development mit Web-UI auf Port 8025 zur Überprüfung von Lifecycle-Emails.',
tech: ['Mailpit', 'SMTP', 'Web UI'],
healthEndpoint: null,
endpoints: [
{ method: 'GET', path: '/', description: 'Web UI (Port 8025)' },
],
envVars: ['MP_SMTP_AUTH', 'MP_SMTP_AUTH_ALLOW_INSECURE'],
},
{
id: 'llm-gateway',
name: 'LLM Gateway',
type: 'backend',
port: 8085,
container: 'breakpilot-llm-gateway',
description: 'Multi-Provider LLM Router',
purpose: 'Einheitliche API für verschiedene LLM-Anbieter (OpenAI, Anthropic, Ollama) mit Provider-Switching, Token-Tracking und Fallback-Logik.',
tech: ['Python 3.11', 'FastAPI', 'LiteLLM'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/health', description: 'Health Check' },
{ method: 'POST', path: '/v1/chat/completions', description: 'Chat Completion (OpenAI-kompatibel)' },
{ method: 'GET', path: '/v1/models', description: 'Verfügbare Modelle' },
],
envVars: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OLLAMA_BASE_URL'],
},
{
id: 'website',
name: 'Website (Next.js)',
type: 'frontend',
port: 3000,
container: 'breakpilot-pwa-website',
description: 'Next.js 14 Frontend mit App Router',
purpose: 'Admin-Dashboard, Landing-Page und API-Routing für das Next.js Frontend mit Server Components und Edge Functions.',
tech: ['Next.js 14', 'React 18', 'TypeScript', 'Tailwind CSS'],
healthEndpoint: null,
endpoints: [
{ method: 'GET', path: '/', description: 'Landing Page' },
{ method: 'GET', path: '/admin', description: 'Admin Dashboard' },
{ method: 'GET', path: '/app', description: 'Benutzer-App (redirect to :8000)' },
],
envVars: ['NEXT_PUBLIC_API_URL', 'NEXTAUTH_SECRET'],
},
{
id: 'opensearch',
name: 'OpenSearch',
type: 'database',
port: 9200,
container: 'breakpilot-opensearch',
description: 'Volltextsuche und Vektorsuche',
purpose: 'Hochperformante Suche in Bildungsressourcen mit k-NN für semantische Ähnlichkeitssuche und BM25 für Keyword-Matching.',
tech: ['OpenSearch 2.11', 'k-NN Plugin'],
healthEndpoint: '/',
endpoints: [
{ method: 'GET', path: '/_cluster/health', description: 'Cluster Health' },
{ method: 'POST', path: '/bp_documents_v1/_search', description: 'Dokumentensuche' },
],
envVars: ['OPENSEARCH_JAVA_OPTS'],
},
]
// ASCII architecture diagram
export const DATAFLOW_DIAGRAM = `
BreakPilot Platform - Datenfluss
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND LAYER │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Admin Frontend │ │ Lehrer Studio │ │ Policy Vault UI │ │
│ │ Next.js :3000 │ │ FastAPI :8000 │ │ Angular :4200 │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘ │
└─────────────┼───────────────────────────┼───────────────────────────┼────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ BACKEND LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Consent :8081 │ │ │ Billing :8083 │ │ │ School :8084 │ │ │ DSMS GW :8082 │ │
│ │ Go/Gin │ │ │ Go/Stripe │ │ │ Go/Gin │ │ │ Go/IPFS │ │
│ │ DSGVO Consent │ │ │ Subscriptions │ │ │ Noten/Zeugnis │ │ │ Audit Storage │ │
│ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │
│ │ │ │ │ │ │ │ │
│ ▼ │ ▼ │ ▼ │ ▼ │
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Klausur :8086 │ │ │ H5P :8085 │ │ │ Policy API │ │ │ LLM Services │ │
│ │ Python/BYOEH │ │ │ Node.js │ │ │ NestJS :3001 │ │ │ LibreChat/RAG │ │
│ │ Abiturkorrek. │ │ │ Interaktiv │ │ │ Richtlinien │ │ │ KI-Assistenz │ │
│ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │
└─────────┼─────────┴─────────┼─────────┴─────────┼─────────┴─────────┼───────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ PostgreSQL │◄┼►│ Redis │ │ │ Qdrant │ │ │ OpenSearch │ │
│ │ :5432 │ │ │ :6379 Cache │ │ │ :6333 Vector │ │ │ :9200 Search │ │
│ │ Hauptdaten │ │ │ Sessions │ │ │ RAG Embeddings│ │ │ Volltext │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │
└───────────────────┴───────────────────┴───────────────────┴─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Vault :8200 │ │ │ Keycloak :8180│ │ │ MinIO :9000 │ │ │ IPFS :5001 │ │
│ │ Secrets Mgmt │ │ │ SSO/OIDC Auth │ │ │ S3 Storage │ │ │ Dezentral │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ │
│ │ Matrix :8008 │ │ │ Jitsi :8443 │ │ │ ERPNext :8090 │ │ │
│ │ E2EE Chat │ │ │ Video Calls │ │ │ Open ERP │ │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ │
└───────────────────┴───────────────────┴───────────────────┴─────────────────────────────────┘
═══════════════════════════════════════════════════════════════════════════════════════════════
Legende: ──► Datenfluss ◄──► Bidirektional │ Layer-Grenze ┌─┐ Service-Box
═══════════════════════════════════════════════════════════════════════════════════════════════
`
// Tab definitions
export const TAB_DEFINITIONS = [
{ id: 'overview', label: 'Architektur' },
{ id: 'services', label: 'Services' },
{ id: 'api', label: 'API Reference' },
{ id: 'docker', label: 'Docker' },
{ id: 'testing', label: 'Testing' },
] as const

View File

@@ -0,0 +1,58 @@
/**
* Helper functions for Developer Documentation Page
*/
import type { ServiceNode } from './types'
export const getArchTypeColor = (type: ServiceNode['type']) => {
switch (type) {
case 'frontend': return { bg: 'bg-blue-500', border: 'border-blue-600', text: 'text-blue-800', light: 'bg-blue-50' }
case 'backend': return { bg: 'bg-green-500', border: 'border-green-600', text: 'text-green-800', light: 'bg-green-50' }
case 'database': return { bg: 'bg-purple-500', border: 'border-purple-600', text: 'text-purple-800', light: 'bg-purple-50' }
case 'cache': return { bg: 'bg-cyan-500', border: 'border-cyan-600', text: 'text-cyan-800', light: 'bg-cyan-50' }
case 'search': return { bg: 'bg-pink-500', border: 'border-pink-600', text: 'text-pink-800', light: 'bg-pink-50' }
case 'storage': return { bg: 'bg-orange-500', border: 'border-orange-600', text: 'text-orange-800', light: 'bg-orange-50' }
case 'security': return { bg: 'bg-red-500', border: 'border-red-600', text: 'text-red-800', light: 'bg-red-50' }
case 'communication': return { bg: 'bg-yellow-500', border: 'border-yellow-600', text: 'text-yellow-800', light: 'bg-yellow-50' }
case 'ai': return { bg: 'bg-violet-500', border: 'border-violet-600', text: 'text-violet-800', light: 'bg-violet-50' }
case 'erp': return { bg: 'bg-indigo-500', border: 'border-indigo-600', text: 'text-indigo-800', light: 'bg-indigo-50' }
default: return { bg: 'bg-gray-500', border: 'border-gray-600', text: 'text-gray-800', light: 'bg-gray-50' }
}
}
export const getArchTypeLabel = (type: ServiceNode['type']) => {
switch (type) {
case 'frontend': return 'Frontend'
case 'backend': return 'Backend'
case 'database': return 'Datenbank'
case 'cache': return 'Cache'
case 'search': return 'Suche'
case 'storage': return 'Speicher'
case 'security': return 'Sicherheit'
case 'communication': return 'Kommunikation'
case 'ai': return 'KI/LLM'
case 'erp': return 'ERP'
default: return type
}
}
export const getServiceTypeColor = (type: string) => {
switch (type) {
case 'frontend': return 'bg-blue-100 text-blue-800'
case 'backend': return 'bg-green-100 text-green-800'
case 'database': return 'bg-purple-100 text-purple-800'
case 'communication': return 'bg-orange-100 text-orange-800'
case 'infrastructure': return 'bg-slate-200 text-slate-700'
default: return 'bg-gray-100 text-gray-800'
}
}
export const getMethodColor = (method: string) => {
switch (method) {
case 'GET': return 'bg-emerald-100 text-emerald-700'
case 'POST': return 'bg-blue-100 text-blue-700'
case 'PUT': return 'bg-amber-100 text-amber-700'
case 'DELETE': return 'bg-red-100 text-red-700'
default: return 'bg-gray-100 text-gray-700'
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
/**
* Types for Developer Documentation Page
*/
export interface ServiceNode {
id: string
name: string
type: 'frontend' | 'backend' | 'database' | 'cache' | 'search' | 'storage' | 'security' | 'communication' | 'ai' | 'erp'
port?: string
technology: string
description: string
connections?: string[]
}
export interface ServiceEndpoint {
method: string
path: string
description: string
}
export interface ServiceDefinition {
id: string
name: string
type: string
port: number
container: string
description: string
purpose: string
tech: string[]
healthEndpoint: string | null
endpoints: ServiceEndpoint[]
envVars: string[]
}
export interface ArchitectureLayer {
id: string
name: string
description: string
types: string[]
}
export type TabType = 'overview' | 'services' | 'api' | 'docker' | 'testing'

View File

@@ -0,0 +1,38 @@
export function FailedTestsList({ testIds, onViewDetails }: { testIds: string[]; onViewDetails?: (id: string) => void }) {
if (testIds.length === 0) {
return (
<div className="text-center py-8 text-emerald-600">
<svg className="w-12 h-12 mx-auto mb-2" 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>
Alle Tests bestanden!
</div>
)
}
return (
<div className="space-y-2 max-h-64 overflow-y-auto">
{testIds.map((testId) => (
<div
key={testId}
className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-100"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="font-mono text-sm text-red-700">{testId}</span>
</div>
{onViewDetails && (
<button
onClick={() => onViewDetails(testId)}
className="text-xs text-red-600 hover:text-red-800"
>
Details
</button>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { BQASMetrics } from '../types'
import { IntentScoresChart } from './IntentScoresChart'
import { FailedTestsList } from './FailedTestsList'
export function GoldenTab({
goldenMetrics,
isRunningGolden,
runGoldenTests,
}: {
goldenMetrics: BQASMetrics | null
isRunningGolden: boolean
runGoldenTests: () => void
}) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Golden Test Suite</h3>
<p className="text-sm text-slate-500">Validierte Referenz-Tests gegen definierte Erwartungen</p>
</div>
<button
onClick={runGoldenTests}
disabled={isRunningGolden}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
>
{isRunningGolden ? 'Laeuft...' : 'Tests starten'}
</button>
</div>
{goldenMetrics && (
<>
{/* Metrics Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{goldenMetrics.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-lg">
<p className="text-2xl font-bold text-emerald-600">{goldenMetrics.passed_tests}</p>
<p className="text-xs text-slate-500">Bestanden</p>
</div>
<div className="text-center p-4 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-600">{goldenMetrics.failed_tests}</p>
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{goldenMetrics.avg_intent_accuracy.toFixed(0)}%</p>
<p className="text-xs text-slate-500">Intent Accuracy</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{goldenMetrics.avg_composite_score.toFixed(2)}</p>
<p className="text-xs text-slate-500">Composite Score</p>
</div>
</div>
{/* Intent Scores & Failed Tests */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-4">Scores nach Intent</h4>
<IntentScoresChart scores={goldenMetrics.scores_by_intent} />
</div>
<div>
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
<FailedTestsList testIds={goldenMetrics.failed_test_ids} />
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
export function IntentScoresChart({ scores }: { scores: Record<string, number> }) {
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1])
if (entries.length === 0) {
return (
<div className="text-center py-8 text-slate-400">
Keine Intent-Scores verfuegbar
</div>
)
}
return (
<div className="space-y-3">
{entries.map(([intent, score]) => (
<div key={intent}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-600 truncate max-w-[200px]">{intent.replace(/_/g, ' ')}</span>
<span
className={`font-medium ${
score >= 4 ? 'text-emerald-600' : score >= 3 ? 'text-amber-600' : 'text-red-600'
}`}
>
{score.toFixed(2)}
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
score >= 4 ? 'bg-emerald-500' : score >= 3 ? 'bg-amber-500' : 'bg-red-500'
}`}
style={{ width: `${(score / 5) * 100}%` }}
/>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,52 @@
export function MetricCard({
title,
value,
subtitle,
trend,
color = 'blue',
}: {
title: string
value: string | number
subtitle?: string
trend?: 'up' | 'down' | 'stable'
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
}) {
const colorClasses = {
blue: 'bg-blue-50 border-blue-200',
green: 'bg-emerald-50 border-emerald-200',
red: 'bg-red-50 border-red-200',
yellow: 'bg-amber-50 border-amber-200',
purple: 'bg-purple-50 border-purple-200',
}
const trendIcons = {
up: (
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
),
down: (
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
),
stable: (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
),
}
return (
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-slate-600">{title}</p>
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
</div>
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { BQASMetrics, TrendData, TestRun } from '../types'
import { MetricCard } from './MetricCard'
import { TrendChart } from './TrendChart'
import { TestSuiteCard } from './TestSuiteCard'
export function OverviewTab({
goldenMetrics,
ragMetrics,
syntheticMetrics,
trendData,
testRuns,
isRunningGolden,
isRunningRag,
isRunningSynthetic,
runGoldenTests,
runRagTests,
runSyntheticTests,
}: {
goldenMetrics: BQASMetrics | null
ragMetrics: BQASMetrics | null
syntheticMetrics: BQASMetrics | null
trendData: TrendData | null
testRuns: TestRun[]
isRunningGolden: boolean
isRunningRag: boolean
isRunningSynthetic: boolean
runGoldenTests: () => void
runRagTests: () => void
runSyntheticTests: () => void
}) {
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Golden Score"
value={goldenMetrics?.avg_composite_score.toFixed(2) || '-'}
subtitle="Durchschnitt aller Golden Tests"
trend={trendData?.trend === 'improving' ? 'up' : trendData?.trend === 'declining' ? 'down' : 'stable'}
color="blue"
/>
<MetricCard
title="Pass Rate"
value={goldenMetrics ? `${((goldenMetrics.passed_tests / goldenMetrics.total_tests) * 100).toFixed(0)}%` : '-'}
subtitle={goldenMetrics ? `${goldenMetrics.passed_tests}/${goldenMetrics.total_tests} bestanden` : undefined}
color="green"
/>
<MetricCard
title="RAG Qualitaet"
value={ragMetrics?.avg_composite_score.toFixed(2) || '-'}
subtitle="RAG Retrieval Score"
color="purple"
/>
<MetricCard
title="Test Runs"
value={testRuns.length}
subtitle="Letzte 30 Tage"
color="yellow"
/>
</div>
{/* Trend Chart */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Score-Trend (30 Tage)</h3>
<TrendChart data={trendData || { dates: [], scores: [], trend: 'insufficient_data' }} />
</div>
{/* Test Suites Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<TestSuiteCard
title="Golden Suite"
description="97 validierte Referenz-Tests fuer Intent-Erkennung"
metrics={goldenMetrics || undefined}
onRun={runGoldenTests}
isRunning={isRunningGolden}
/>
<TestSuiteCard
title="RAG/Korrektur Tests"
description="EH-Retrieval, Operatoren-Alignment, Citation Tests"
metrics={ragMetrics || undefined}
onRun={runRagTests}
isRunning={isRunningRag}
/>
<TestSuiteCard
title="Synthetic Tests"
description="LLM-generierte Variationen fuer Robustheit"
metrics={syntheticMetrics || undefined}
onRun={runSyntheticTests}
isRunning={isRunningSynthetic}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { BQASMetrics } from '../types'
import { IntentScoresChart } from './IntentScoresChart'
import { FailedTestsList } from './FailedTestsList'
export function RagTab({
ragMetrics,
isRunningRag,
runRagTests,
}: {
ragMetrics: BQASMetrics | null
isRunningRag: boolean
runRagTests: () => void
}) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">RAG/Korrektur Test Suite</h3>
<p className="text-sm text-slate-500">Erwartungshorizont-Retrieval, Operatoren-Alignment, Citations</p>
</div>
<button
onClick={runRagTests}
disabled={isRunningRag}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
>
{isRunningRag ? 'Laeuft...' : 'Tests starten'}
</button>
</div>
{ragMetrics ? (
<>
{/* RAG Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{ragMetrics.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{ragMetrics.avg_faithfulness.toFixed(2)}</p>
<p className="text-xs text-slate-500">Faithfulness</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{ragMetrics.avg_relevance.toFixed(2)}</p>
<p className="text-xs text-slate-500">Relevance</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-lg">
<p className="text-2xl font-bold text-emerald-600">{(ragMetrics.safety_pass_rate * 100).toFixed(0)}%</p>
<p className="text-xs text-slate-500">Safety Pass</p>
</div>
</div>
{/* RAG Categories */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-4">RAG Kategorien</h4>
<IntentScoresChart scores={ragMetrics.scores_by_intent} />
</div>
<div>
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
<FailedTestsList testIds={ragMetrics.failed_test_ids} />
</div>
</div>
</>
) : (
<div className="text-center py-12 text-slate-400">
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>Noch keine RAG-Test-Ergebnisse</p>
<p className="text-sm mt-2">Klicke &quot;Tests starten&quot; um die RAG-Suite auszufuehren</p>
</div>
)}
</div>
{/* RAG Test Categories Explanation */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: 'EH Retrieval', desc: 'Korrektes Abrufen von Erwartungshorizont-Passagen', color: 'blue' },
{ name: 'Operator Alignment', desc: 'Passende Operatoren fuer Abitur-Aufgaben', color: 'purple' },
{ name: 'Hallucination Control', desc: 'Keine erfundenen Fakten oder Inhalte', color: 'red' },
{ name: 'Citation Enforcement', desc: 'Quellenangaben bei EH-Bezuegen', color: 'green' },
{ name: 'Privacy Compliance', desc: 'Keine PII-Leaks, DSGVO-Konformitaet', color: 'amber' },
{ name: 'Namespace Isolation', desc: 'Strikte Trennung zwischen Lehrern', color: 'slate' },
].map((cat) => (
<div key={cat.name} className={`p-4 rounded-lg border bg-${cat.color}-50 border-${cat.color}-200`}>
<h4 className="font-medium text-slate-900">{cat.name}</h4>
<p className="text-sm text-slate-600 mt-1">{cat.desc}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
export function SchedulerStatusCard({
title,
status,
description,
icon,
}: {
title: string
status: 'active' | 'inactive' | 'warning' | 'unknown'
description: string
icon: React.ReactNode
}) {
const statusColors = {
active: 'bg-emerald-100 border-emerald-200 text-emerald-700',
inactive: 'bg-slate-100 border-slate-200 text-slate-700',
warning: 'bg-amber-100 border-amber-200 text-amber-700',
unknown: 'bg-slate-100 border-slate-200 text-slate-500',
}
const statusBadges = {
active: 'bg-emerald-500',
inactive: 'bg-slate-400',
warning: 'bg-amber-500',
unknown: 'bg-slate-300',
}
return (
<div className={`rounded-xl border p-5 ${statusColors[status]}`}>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">{icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold">{title}</h4>
<span className={`w-2 h-2 rounded-full ${statusBadges[status]}`} />
</div>
<p className="text-sm mt-1 opacity-80">{description}</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,266 @@
import { SchedulerStatusCard } from './SchedulerStatusCard'
import { SpinnerIcon } from './SpinnerIcon'
export function SchedulerTab({
isRunningGolden,
isRunningRag,
isRunningSynthetic,
runGoldenTests,
runRagTests,
runSyntheticTests,
}: {
isRunningGolden: boolean
isRunningRag: boolean
isRunningSynthetic: boolean
runGoldenTests: () => void
runRagTests: () => void
runSyntheticTests: () => void
}) {
return (
<div className="space-y-6">
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SchedulerStatusCard
title="launchd Job"
status="active"
description="Taeglich um 07:00 Uhr automatisch"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<SchedulerStatusCard
title="Git Hook"
status="active"
description="Quick Tests bei voice-service Aenderungen"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
}
/>
<SchedulerStatusCard
title="Benachrichtigungen"
status="active"
description="Desktop-Alerts bei Fehlern aktiviert"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
}
/>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quick Actions</h3>
<div className="flex flex-wrap gap-3">
<QuickActionButton
label="Golden Suite starten"
isRunning={isRunningGolden}
onClick={runGoldenTests}
colorClass="bg-blue-600 hover:bg-blue-700"
/>
<QuickActionButton
label="RAG Tests starten"
isRunning={isRunningRag}
onClick={runRagTests}
colorClass="bg-purple-600 hover:bg-purple-700"
/>
<QuickActionButton
label="Synthetic Tests"
isRunning={isRunningSynthetic}
onClick={runSyntheticTests}
colorClass="bg-emerald-600 hover:bg-emerald-700"
/>
</div>
</div>
{/* GitHub Actions vs Local - Comparison */}
<ComparisonTable />
{/* Configuration Details */}
<ConfigurationSection />
{/* Detailed Explanation */}
<ExplanationSection />
</div>
)
}
function QuickActionButton({
label,
isRunning,
onClick,
colorClass,
}: {
label: string
isRunning: boolean
onClick: () => void
colorClass: string
}) {
return (
<button
onClick={onClick}
disabled={isRunning}
className={`px-4 py-2 ${colorClass} text-white rounded-lg disabled:bg-slate-300 flex items-center gap-2`}
>
{isRunning ? (
<SpinnerIcon />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{label}
</button>
)
}
function ComparisonTable() {
const rows = [
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'emerald' },
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'emerald' },
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'emerald', local: 'Nicht moeglich', localColor: 'amber' },
{ feature: 'Regression-Check', gh: 'API-Call', local: 'Identischer API-Call', localColor: 'emerald' },
{ feature: 'Benachrichtigungen', gh: 'GitHub Issues', local: 'Desktop/Slack/Email', localColor: 'emerald' },
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'amber', local: '100% lokal', localColor: 'emerald' },
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'red', local: 'Ja', localColor: 'emerald' },
]
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">GitHub Actions Alternative</h3>
<p className="text-slate-600 mb-4">
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.feature} className="border-b border-slate-100">
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
<td className="py-3 px-4 text-center">
{row.ghColor ? (
<span className={`px-2 py-1 bg-${row.ghColor}-100 text-${row.ghColor}-700 rounded text-xs font-medium`}>
{row.gh}
</span>
) : (
<span className="text-slate-600">{row.gh}</span>
)}
</td>
<td className="py-3 px-4 text-center">
<span className={`px-2 py-1 bg-${row.localColor}-100 text-${row.localColor}-700 rounded text-xs font-medium`}>
{row.local}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function ConfigurationSection() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Konfiguration</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* launchd Configuration */}
<div>
<h4 className="font-medium text-slate-800 mb-3">launchd Job</h4>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
Label: com.breakpilot.bqas
Schedule: 07:00 taeglich
Script: /voice-service/scripts/run_bqas.sh
Logs: /var/log/bqas/`}</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-slate-800 mb-3">Umgebungsvariablen</h4>
<div className="space-y-2 text-sm">
{[
{ key: 'BQAS_SERVICE_URL', value: 'http://localhost:8091', color: 'text-slate-900' },
{ key: 'BQAS_REGRESSION_THRESHOLD', value: '0.1', color: 'text-slate-900' },
{ key: 'BQAS_NOTIFY_DESKTOP', value: 'true', color: 'text-emerald-600 font-medium' },
{ key: 'BQAS_NOTIFY_SLACK', value: 'false', color: 'text-slate-400' },
].map((env) => (
<div key={env.key} className="flex justify-between p-2 bg-slate-50 rounded">
<span className="font-mono text-slate-600">{env.key}</span>
<span className={env.color}>{env.value}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function ExplanationSection() {
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Detaillierte Erklaerung
</h3>
<div className="prose prose-sm max-w-none text-slate-700">
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
<p className="mb-4">
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
</p>
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
<ul className="list-disc list-inside space-y-2 mb-4">
<li>
<strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
</li>
<li>
<strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
</li>
<li>
<strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
</li>
<li>
<strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
</li>
</ul>
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
</div>
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
<ul className="list-disc list-inside space-y-1">
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
<li>Keine GitHub-Kosten fuer private Repositories</li>
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export function SpinnerIcon() {
return (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)
}

View File

@@ -0,0 +1,75 @@
import { BQASMetrics } from '../types'
import { IntentScoresChart } from './IntentScoresChart'
import { FailedTestsList } from './FailedTestsList'
export function SyntheticTab({
syntheticMetrics,
isRunningSynthetic,
runSyntheticTests,
}: {
syntheticMetrics: BQASMetrics | null
isRunningSynthetic: boolean
runSyntheticTests: () => void
}) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Synthetic Test Suite</h3>
<p className="text-sm text-slate-500">LLM-generierte Variationen fuer Robustheit-Tests</p>
</div>
<button
onClick={runSyntheticTests}
disabled={isRunningSynthetic}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
>
{isRunningSynthetic ? 'Laeuft...' : 'Tests starten'}
</button>
</div>
{syntheticMetrics ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{syntheticMetrics.total_tests}</p>
<p className="text-xs text-slate-500">Generierte Tests</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-lg">
<p className="text-2xl font-bold text-emerald-600">{syntheticMetrics.passed_tests}</p>
<p className="text-xs text-slate-500">Bestanden</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{syntheticMetrics.avg_composite_score.toFixed(2)}</p>
<p className="text-xs text-slate-500">Avg Score</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{syntheticMetrics.avg_coherence.toFixed(2)}</p>
<p className="text-xs text-slate-500">Coherence</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-4">Intent-Variationen</h4>
<IntentScoresChart scores={syntheticMetrics.scores_by_intent} />
</div>
<div>
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
<FailedTestsList testIds={syntheticMetrics.failed_test_ids} />
</div>
</div>
</>
) : (
<div className="text-center py-12 text-slate-400">
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
<p>Noch keine synthetischen Tests ausgefuehrt</p>
<p className="text-sm mt-2">Klicke &quot;Tests starten&quot; um Variationen zu generieren</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { TestRun } from '../types'
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
if (runs.length === 0) {
return (
<div className="text-center py-8 text-slate-400">
Keine Test-Laeufe vorhanden
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
<th className="text-left py-3 px-4 font-medium text-slate-600">Commit</th>
<th className="text-right py-3 px-4 font-medium text-slate-600">Golden Score</th>
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-slate-900">#{run.id}</td>
<td className="py-3 px-4 text-slate-600">
{new Date(run.timestamp).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 font-mono text-xs text-slate-500">
{run.git_commit?.slice(0, 7) || '-'}
</td>
<td className="py-3 px-4 text-right">
<span
className={`font-medium ${
run.golden_score >= 4 ? 'text-emerald-600' : run.golden_score >= 3 ? 'text-amber-600' : 'text-red-600'
}`}
>
{run.golden_score.toFixed(2)}
</span>
</td>
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
<td className="py-3 px-4 text-right">
<span className="text-emerald-600">{run.passed_tests}</span>
<span className="text-slate-400"> / </span>
<span className="text-red-600">{run.failed_tests}</span>
</td>
<td className="py-3 px-4 text-right text-slate-500">
{run.duration_seconds.toFixed(1)}s
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { BQASMetrics } from '../types'
export function TestSuiteCard({
title,
description,
metrics,
onRun,
isRunning,
lastRun,
}: {
title: string
description: string
metrics?: BQASMetrics
onRun: () => void
isRunning: boolean
lastRun?: string
}) {
const passRate = metrics ? (metrics.passed_tests / metrics.total_tests) * 100 : 0
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<button
onClick={onRun}
disabled={isRunning}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isRunning
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{isRunning ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Laeuft...
</span>
) : (
'Tests starten'
)}
</button>
</div>
{metrics && (
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Pass Rate</span>
<span className="font-medium text-slate-900">{passRate.toFixed(1)}%</span>
</div>
<div className="mt-2 h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
}`}
style={{ width: `${passRate}%` }}
/>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-slate-900">{metrics.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600">{metrics.passed_tests}</p>
<p className="text-xs text-slate-500">Bestanden</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{metrics.failed_tests}</p>
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<p className="text-xs text-slate-500">
Durchschnittlicher Score: <span className="font-medium">{metrics.avg_composite_score.toFixed(2)}</span>
</p>
</div>
</div>
)}
{lastRun && (
<p className="mt-4 text-xs text-slate-400">Letzter Lauf: {new Date(lastRun).toLocaleString('de-DE')}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { TrendData } from '../types'
export function TrendChart({ data }: { data: TrendData }) {
if (!data || data.dates.length === 0) {
return (
<div className="h-48 flex items-center justify-center text-slate-400">
Keine Trend-Daten verfuegbar
</div>
)
}
const maxScore = Math.max(...data.scores, 5)
const minScore = Math.min(...data.scores, 0)
const range = maxScore - minScore || 1
return (
<div className="h-48 relative">
{/* Y-Axis Labels */}
<div className="absolute left-0 top-0 bottom-4 w-8 flex flex-col justify-between text-xs text-slate-400">
<span>{maxScore.toFixed(1)}</span>
<span>{((maxScore + minScore) / 2).toFixed(1)}</span>
<span>{minScore.toFixed(1)}</span>
</div>
{/* Chart Area */}
<div className="ml-10 h-full pr-4">
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
{/* Grid Lines */}
<line x1="0" y1="0" x2="100" y2="0" stroke="#e2e8f0" strokeWidth="0.5" />
<line x1="0" y1="50" x2="100" y2="50" stroke="#e2e8f0" strokeWidth="0.5" />
<line x1="0" y1="100" x2="100" y2="100" stroke="#e2e8f0" strokeWidth="0.5" />
{/* Line Chart */}
<polyline
fill="none"
stroke="#0ea5e9"
strokeWidth="2"
points={data.scores
.map((score, i) => {
const x = (i / (data.scores.length - 1 || 1)) * 100
const y = 100 - ((score - minScore) / range) * 100
return `${x},${y}`
})
.join(' ')}
/>
{/* Data Points */}
{data.scores.map((score, i) => {
const x = (i / (data.scores.length - 1 || 1)) * 100
const y = 100 - ((score - minScore) / range) * 100
return <circle key={i} cx={x} cy={y} r="2" fill="#0ea5e9" />
})}
</svg>
</div>
{/* X-Axis Labels */}
<div className="ml-10 flex justify-between text-xs text-slate-400 mt-1">
{data.dates.slice(0, 5).map((date, i) => (
<span key={i}>{new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
))}
</div>
{/* Trend Indicator */}
<div className="absolute top-2 right-2">
<span
className={`px-2 py-1 rounded text-xs font-medium ${
data.trend === 'improving'
? 'bg-emerald-100 text-emerald-700'
: data.trend === 'declining'
? 'bg-red-100 text-red-700'
: 'bg-slate-100 text-slate-700'
}`}
>
{data.trend === 'improving' ? 'Verbessernd' : data.trend === 'declining' ? 'Verschlechternd' : 'Stabil'}
</span>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
/**
* Quality Dashboard - Types & Constants
*/
// Types
export interface TestResult {
test_id: string
test_name: string
passed: boolean
composite_score: number
intent_accuracy: number
faithfulness: number
relevance: number
coherence: number
safety: string
reasoning: string
expected_intent: string
detected_intent: string
}
export interface TestRun {
id: number
timestamp: string
git_commit: string
golden_score: number
synthetic_score: number
total_tests: number
passed_tests: number
failed_tests: number
duration_seconds: number
}
export interface BQASMetrics {
total_tests: number
passed_tests: number
failed_tests: number
avg_intent_accuracy: number
avg_faithfulness: number
avg_relevance: number
avg_coherence: number
safety_pass_rate: number
avg_composite_score: number
scores_by_intent: Record<string, number>
failed_test_ids: string[]
}
export interface TrendData {
dates: string[]
scores: number[]
trend: 'improving' | 'stable' | 'declining' | 'insufficient_data'
}
export type TabId = 'overview' | 'golden' | 'rag' | 'synthetic' | 'history' | 'scheduler'
// API Configuration
export const VOICE_SERVICE_URL = process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'

View File

@@ -0,0 +1,132 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { BQASMetrics, TestRun, TrendData, TabId, VOICE_SERVICE_URL } from './types'
export interface QualityDashboardState {
activeTab: TabId
setActiveTab: (tab: TabId) => void
isLoading: boolean
error: string | null
goldenMetrics: BQASMetrics | null
syntheticMetrics: BQASMetrics | null
ragMetrics: BQASMetrics | null
testRuns: TestRun[]
trendData: TrendData | null
isRunningGolden: boolean
isRunningSynthetic: boolean
isRunningRag: boolean
fetchData: () => Promise<void>
runGoldenTests: () => Promise<void>
runSyntheticTests: () => Promise<void>
runRagTests: () => Promise<void>
}
export function useQualityDashboard(): QualityDashboardState {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [goldenMetrics, setGoldenMetrics] = useState<BQASMetrics | null>(null)
const [syntheticMetrics, setSyntheticMetrics] = useState<BQASMetrics | null>(null)
const [ragMetrics, setRagMetrics] = useState<BQASMetrics | null>(null)
const [testRuns, setTestRuns] = useState<TestRun[]>([])
const [trendData, setTrendData] = useState<TrendData | null>(null)
const [isRunningGolden, setIsRunningGolden] = useState(false)
const [isRunningSynthetic, setIsRunningSynthetic] = useState(false)
const [isRunningRag, setIsRunningRag] = useState(false)
const fetchData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const runsResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/runs`)
if (runsResponse.ok) {
const runsData = await runsResponse.json()
setTestRuns(runsData.runs || [])
}
const trendResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/trend?days=30`)
if (trendResponse.ok) {
const trend = await trendResponse.json()
setTrendData(trend)
}
const metricsResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/latest-metrics`)
if (metricsResponse.ok) {
const metrics = await metricsResponse.json()
setGoldenMetrics(metrics.golden || null)
setSyntheticMetrics(metrics.synthetic || null)
setRagMetrics(metrics.rag || null)
}
} catch (err) {
console.error('Failed to fetch BQAS data:', err)
setError('Verbindung zum Voice-Service fehlgeschlagen')
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const runGoldenTests = async () => {
setIsRunningGolden(true)
try {
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/golden`, { method: 'POST' })
if (response.ok) {
const result = await response.json()
setGoldenMetrics(result.metrics)
await fetchData()
}
} catch (err) {
console.error('Failed to run golden tests:', err)
} finally {
setIsRunningGolden(false)
}
}
const runSyntheticTests = async () => {
setIsRunningSynthetic(true)
try {
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/synthetic`, { method: 'POST' })
if (response.ok) {
const result = await response.json()
setSyntheticMetrics(result.metrics)
await fetchData()
}
} catch (err) {
console.error('Failed to run synthetic tests:', err)
} finally {
setIsRunningSynthetic(false)
}
}
const runRagTests = async () => {
setIsRunningRag(true)
try {
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/rag`, { method: 'POST' })
if (response.ok) {
const result = await response.json()
setRagMetrics(result.metrics)
await fetchData()
}
} catch (err) {
console.error('Failed to run RAG tests:', err)
} finally {
setIsRunningRag(false)
}
}
return {
activeTab, setActiveTab,
isLoading, error,
goldenMetrics, syntheticMetrics, ragMetrics,
testRuns, trendData,
isRunningGolden, isRunningSynthetic, isRunningRag,
fetchData, runGoldenTests, runSyntheticTests, runRagTests,
}
}

View File

@@ -0,0 +1,123 @@
'use client'
import type { Collection } from './types'
function CollectionCard({ collection }: { collection: Collection }) {
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',
}
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>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
{statusLabels[collection.status]}
</span>
</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}</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>
)}
</div>
)
}
export function CollectionsTab({
collections,
loading,
onRefresh,
}: {
collections: Collection[]
loading: boolean
onRefresh: () => void
}) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Sammlungen</h2>
<p className="text-sm text-slate-500">Verwaltung der indexierten Dokumentensammlungen</p>
</div>
<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>
</div>
{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>
)}
{!loading && (
<div className="grid gap-4">
{collections.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Sammlungen vorhanden</h3>
<p className="text-slate-500 mb-4">Starten Sie die Ingestion oder laden Sie Dokumente hoch.</p>
</div>
) : (
collections.map((col) => <CollectionCard key={col.name} collection={col} />)
)}
<div className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-slate-400 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 (demnächst)</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import type { IngestionStatus } from './types'
import { API_BASE } from './types'
export function IngestionTab({
status,
onRefresh,
}: {
status: IngestionStatus | null
onRefresh: () => void
}) {
const [starting, setStarting] = useState(false)
const startIngestion = async () => {
setStarting(true)
try {
await fetch(`${API_BASE}/api/v1/admin/nibis/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ewh_only: true }),
})
onRefresh()
} catch (err) {
console.error('Failed to start ingestion:', err)
} finally {
setStarting(false)
}
}
return (
<div className="space-y-6">
<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>
</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"
>
{starting ? 'Startet...' : 'Ingestion starten'}
</button>
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center gap-4 mb-4">
{status?.running ? (
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
) : (
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
)}
<span className="text-lg font-medium text-slate-900">
{status?.running ? 'Indexierung läuft...' : '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">Letzte Ausführung</p>
<p className="text-lg font-semibold text-slate-900">{status.lastRun ? new Date(status.lastRun).toLocaleString('de-DE') : '-'}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Dokumente</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">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>
)}
{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>)}
</ul>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { useState, useEffect } from 'react'
import type { Collection } from './types'
import { API_BASE } from './types'
function MetricCard({ title, value, change, positive }: {
title: string
value: string
change?: string
positive?: boolean
}) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-4">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<div className="flex items-end gap-2">
<p className="text-2xl font-bold text-slate-900">{value}</p>
{change && (
<span className={`text-sm font-medium ${positive ? 'text-green-600' : 'text-red-600'}`}>{change}</span>
)}
</div>
</div>
)
}
function ScoreBar({ label, percent, color }: { label: string; percent: number; color: string }) {
return (
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600 w-16">{label}</span>
<div className="flex-1 h-4 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} transition-all`} style={{ width: `${percent}%` }} />
</div>
<span className="text-sm text-slate-500 w-12 text-right">{percent}%</span>
</div>
)
}
export function MetricsTab({ collections }: { collections: Collection[] }) {
const [metrics, setMetrics] = useState({
precision: 0,
recall: 0,
mrr: 0,
avgLatency: 0,
totalRatings: 0,
errorRate: 0,
scoreDistribution: { '0.9+': 0, '0.7-0.9': 0, '0.5-0.7': 0, '<0.5': 0 },
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchMetrics = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/metrics`)
if (res.ok) {
const data = await res.json()
setMetrics({
precision: data.precision_at_5 || 0,
recall: data.recall_at_10 || 0,
mrr: data.mrr || 0,
avgLatency: data.avg_latency_ms || 0,
totalRatings: data.total_ratings || 0,
errorRate: data.error_rate || 0,
scoreDistribution: data.score_distribution || {},
})
}
} catch (err) {
console.error('Failed to fetch metrics:', err)
} finally {
setLoading(false)
}
}
fetchMetrics()
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Qualitätsmetriken</h2>
<p className="text-sm text-slate-500">Übersicht über Retrieval-Performance und Nutzerbewertungen</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<MetricCard title="Precision@5" value={`${(metrics.precision * 100).toFixed(0)}%`} change="+5%" positive />
<MetricCard title="Recall@10" value={`${(metrics.recall * 100).toFixed(0)}%`} change="+3%" positive />
<MetricCard title="MRR" value={metrics.mrr.toFixed(2)} change="-2%" positive={false} />
<MetricCard title="Avg. Latenz" value={`${metrics.avgLatency}ms`} />
<MetricCard title="Bewertungen" value={metrics.totalRatings.toString()} />
<MetricCard title="Fehlerrate" value={`${metrics.errorRate}%`} />
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Score-Verteilung</h3>
{loading ? (
<div className="flex justify-center py-4"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div></div>
) : (
<div className="space-y-3">
<ScoreBar label="0.9+" percent={metrics.scoreDistribution['0.9+'] || 0} color="bg-green-500" />
<ScoreBar label="0.7-0.9" percent={metrics.scoreDistribution['0.7-0.9'] || 0} color="bg-green-400" />
<ScoreBar label="0.5-0.7" percent={metrics.scoreDistribution['0.5-0.7'] || 0} color="bg-yellow-400" />
<ScoreBar label="<0.5" percent={metrics.scoreDistribution['<0.5'] || 0} color="bg-red-400" />
</div>
)}
</div>
<div className="flex gap-4">
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Export CSV</button>
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Detailbericht</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useState } from 'react'
import type { Collection, SearchResult } from './types'
import { API_BASE } from './types'
export function SearchTab({ collections }: { collections: Collection[] }) {
const [query, setQuery] = useState('')
const [subject, setSubject] = useState<string>('')
const [year, setYear] = useState<string>('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [latency, setLatency] = useState<number | null>(null)
const [ratings, setRatings] = useState<Record<string, number>>({})
const submitRating = async (resultId: string, rating: number) => {
setRatings(prev => ({ ...prev, [resultId]: rating }))
try {
const formData = new FormData()
formData.append('result_id', resultId)
formData.append('rating', rating.toString())
await fetch(`${API_BASE}/api/v1/admin/rag/search/feedback`, { method: 'POST', body: formData })
} catch (err) {
console.error('Failed to submit rating:', err)
}
}
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
const startTime = Date.now()
try {
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.trim(), subject: subject || undefined, year: year ? parseInt(year) : undefined, limit: 10 }),
})
if (res.ok) {
const data = await res.json()
setResults(data)
setLatency(Date.now() - startTime)
}
} catch (err) {
console.error('Search failed:', err)
} finally {
setSearching(false)
}
}
const subjects = collections.flatMap(c => c.subjects).filter((v, i, a) => a.indexOf(v) === i).sort()
const years = collections.flatMap(c => c.years).filter((v, i, a) => a.indexOf(v) === i).sort()
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Suche & Qualitätstest</h2>
<p className="text-sm text-slate-500">Testen Sie die semantische Suche und bewerten Sie die Ergebnisqualität</p>
</div>
{/* Search Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Suchanfrage</label>
<input
type="text" value={query} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. Analyse eines Gedichts von Rilke"
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="flex flex-wrap gap-4">
<div className="w-48">
<label className="block text-sm font-medium text-slate-700 mb-2">Fach</label>
<select value={subject} onChange={(e) => setSubject(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="">Alle Fächer</option>
{subjects.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
</div>
<div className="w-32">
<label className="block text-sm font-medium text-slate-700 mb-2">Jahr</label>
<select value={year} onChange={(e) => setYear(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="">Alle Jahre</option>
{years.map((y) => (<option key={y} value={y}>{y}</option>))}
</select>
</div>
<div className="flex items-end">
<button onClick={handleSearch} disabled={searching || !query.trim()} className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
{searching ? (<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>) : (
<svg className="w-5 h-5" 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>
)}
Suchen
</button>
</div>
</div>
</div>
</div>
{/* Results */}
{results.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">{results.length} Ergebnisse</h3>
{latency && <span className="text-sm text-slate-500">Latenz: {latency}ms</span>}
</div>
{results.map((result, index) => (
<div key={result.id} className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-slate-400">#{index + 1}</span>
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-sm rounded font-mono">Score: {result.score.toFixed(3)}</span>
</div>
<div className="flex gap-2">
{result.year && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.year}</span>}
{result.subject && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.subject}</span>}
{result.niveau && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.niveau}</span>}
</div>
</div>
<p className="text-sm text-slate-700 leading-relaxed">{result.text}</p>
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-4">
<span className="text-sm text-slate-500">Relevanz:</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((starRating) => (
<button key={starRating} onClick={() => submitRating(result.id, starRating)} className={`p-1 transition-colors ${(ratings[result.id] || 0) >= starRating ? 'text-yellow-500' : 'text-slate-300 hover:text-yellow-400'}`} title={`${starRating} Sterne`}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
</button>
))}
</div>
{ratings[result.id] && <span className="text-xs text-green-600">Bewertet</span>}
</div>
</div>
))}
</div>
)}
{results.length === 0 && query && !searching && (
<div className="bg-slate-50 rounded-lg p-8 text-center"><p className="text-slate-500">Keine Ergebnisse gefunden</p></div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState, useCallback } from 'react'
import { API_BASE } from './types'
export function UploadTab({ onUploadComplete }: { onUploadComplete: () => void }) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
const validFiles = droppedFiles.filter(f => f.name.endsWith('.zip') || f.name.endsWith('.pdf'))
setFiles(prev => [...prev, ...validFiles])
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(prev => [...prev, ...Array.from(e.target.files!)])
}
}, [])
const removeFile = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
}, [])
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
formData.append('collection', 'bp_nibis_eh')
await fetch(`${API_BASE}/api/v1/admin/rag/upload`, { method: 'POST', body: formData })
}
setFiles([])
onUploadComplete()
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Dokumente hochladen</h2>
<p className="text-sm text-slate-500">ZIP-Archive oder einzelne PDFs hochladen. ZIPs werden automatisch entpackt.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Ziel-Sammlung</label>
<select className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="bp_nibis_eh">Niedersachsen - Klausurkorrektur</option>
<option value="bp_ni_zeugnis" disabled>Niedersachsen - Zeugnisgenerator (demnächst)</option>
</select>
</div>
{/* Drop Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
isDragging ? 'border-primary-500 bg-primary-50' : 'border-slate-300 hover:border-slate-400'
}`}
>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<p className="text-lg font-medium text-slate-700 mb-2">ZIP-Datei oder Ordner hierher ziehen</p>
<p className="text-sm text-slate-500 mb-4">oder</p>
<label className="cursor-pointer">
<span className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50">Dateien auswählen</span>
<input type="file" multiple accept=".zip,.pdf" onChange={handleFileSelect} className="hidden" />
</label>
<p className="text-xs text-slate-400 mt-4">Unterstützt: .zip, .pdf</p>
</div>
{/* File Queue */}
{files.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-slate-700">Upload-Queue ({files.length})</h3>
{files.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-slate-400" 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>
<div>
<p className="text-sm font-medium text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<button onClick={() => removeFile(index)} className="p-1 text-slate-400 hover:text-red-500">
<svg className="w-5 h-5" 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>
))}
<button
onClick={handleUpload}
disabled={uploading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{uploading ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Wird hochgeladen...</>
) : (
<>
<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-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Hochladen & Indexieren
</>
)}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { TabId } from './types'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'collections',
name: 'Sammlungen',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'upload',
name: 'Upload',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'ingestion',
name: 'Ingestion',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
},
{
id: 'search',
name: 'Suche & Test',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'metrics',
name: 'Metriken',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
]

View File

@@ -0,0 +1,35 @@
// API Base URL for klausur-service
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export interface Collection {
name: string
displayName: string
bundesland: string
useCase: string
documentCount: number
chunkCount: number
years: number[]
subjects: string[]
status: 'ready' | 'indexing' | 'empty'
}
export interface IngestionStatus {
running: boolean
lastRun: string | null
documentsIndexed: number | null
chunksCreated: number | null
errors: string[]
}
export interface SearchResult {
id: string
score: number
text: string
year: number | null
subject: string | null
niveau: string | null
taskNumber: number | null
}
// Tab definitions
export type TabId = 'collections' | 'upload' | 'ingestion' | 'search' | 'metrics'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
'use client'
import type { TrainingJob } from './types'
// Progress Ring Component
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
progress: number
size?: number
strokeWidth?: number
color?: string
}) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(progress)}%
</span>
</div>
</div>
)
}
// Mini Line Chart Component
export function MiniChart({ data, color = '#10B981', height = 60 }: {
data: number[]
color?: string
height?: number
}) {
if (!data.length) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const width = 200
const padding = 4
const points = data.map((value, i) => {
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} className="overflow-visible">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.length > 0 && (
<circle
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
r={4}
fill={color}
/>
)}
</svg>
)
}
// Status Badge
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
const styles = {
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
}
const labels = {
queued: 'In Warteschlange',
preparing: 'Vorbereitung',
training: 'Training läuft',
validating: 'Validierung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
paused: 'Pausiert',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
{status === 'training' && (
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
)}
{labels[status]}
</span>
)
}
// Metric Card
export function MetricCard({ label, value, unit, trend, color }: {
label: string
value: number | string
unit?: string
trend?: 'up' | 'down' | 'neutral'
color?: string
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
{typeof value === 'number' ? value.toFixed(3) : value}
</span>
{unit && <span className="text-sm text-gray-400">{unit}</span>}
{trend && (
<span className={`ml-2 text-sm ${
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
}`}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import type { DatasetStats } from './types'
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Datensatz-Übersicht
</h3>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{stats.total_documents.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.total_chunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
{stats.training_allowed.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Training erlaubt</p>
</div>
</div>
{/* Bundesland Distribution */}
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Verteilung nach Bundesland
</h4>
<div className="space-y-2">
{Object.entries(stats.by_bundesland)
.sort((a, b) => b[1] - a[1])
.map(([code, count]) => (
<div key={code} className="flex items-center gap-3">
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
{code}
</span>
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
style={{ width: `${(count / maxBundesland) * 100}%` }}
/>
</div>
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
{count}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import { useState } from 'react'
import type { TrainingConfig } from './types'
const BUNDESLAENDER = [
{ code: 'ni', name: 'Niedersachsen', allowed: true },
{ code: 'by', name: 'Bayern', allowed: true },
{ code: 'nw', name: 'NRW', allowed: true },
{ code: 'he', name: 'Hessen', allowed: true },
{ code: 'bw', name: 'Baden-Württemberg', allowed: true },
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
{ code: 'sn', name: 'Sachsen', allowed: true },
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
{ code: 'th', name: 'Thüringen', allowed: true },
{ code: 'be', name: 'Berlin', allowed: false },
{ code: 'bb', name: 'Brandenburg', allowed: false },
{ code: 'hb', name: 'Bremen', allowed: false },
{ code: 'hh', name: 'Hamburg', allowed: false },
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
{ code: 'sl', name: 'Saarland', allowed: false },
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
]
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
isOpen: boolean
onClose: () => void
onSubmit: (config: Partial<TrainingConfig>) => void
}) {
const [step, setStep] = useState(1)
const [config, setConfig] = useState<Partial<TrainingConfig>>({
batch_size: 16,
learning_rate: 0.00005,
epochs: 10,
warmup_steps: 500,
weight_decay: 0.01,
gradient_accumulation: 4,
mixed_precision: true,
bundeslaender: [],
})
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Neues Training starten
</h2>
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
<svg className="w-5 h-5" 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>
{/* Progress Steps */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-center gap-4">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s <= step ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}>
{s < step ? '✓' : s}
</div>
{s < 3 && (
<div className={`w-16 h-1 mx-2 rounded ${s < step ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`} />
)}
</div>
))}
</div>
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
<span>Daten</span>
<span>Parameter</span>
<span>Bestätigen</span>
</div>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[50vh]">
{step === 1 && (
<BundeslandStep config={config} setConfig={setConfig} />
)}
{step === 2 && (
<ParameterStep config={config} setConfig={setConfig} />
)}
{step === 3 && (
<ConfirmStep config={config} />
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{step > 1 ? 'Zurück' : 'Abbrechen'}
</button>
<button
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{step < 3 ? 'Weiter' : 'Training starten'}
</button>
</div>
</div>
</div>
)
}
function BundeslandStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (c: Partial<TrainingConfig>) => void
}) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Wählen Sie die Bundesländer für das Training
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Nur Bundesländer mit Training-Erlaubnis können ausgewählt werden.
</p>
<div className="grid grid-cols-2 gap-3">
{BUNDESLAENDER.map((bl) => (
<label
key={bl.code}
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
config.bundeslaender?.includes(bl.code)
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: bl.allowed
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
}`}
>
<input
type="checkbox"
disabled={!bl.allowed}
checked={config.bundeslaender?.includes(bl.code)}
onChange={(e) => {
if (e.target.checked) {
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
} else {
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
}
}}
className="sr-only"
/>
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
config.bundeslaender?.includes(bl.code)
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}>
{config.bundeslaender?.includes(bl.code) && '✓'}
</span>
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
{!bl.allowed && (
<span className="text-xs text-red-500">Kein Training</span>
)}
</label>
))}
</div>
</div>
)
}
function ParameterStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (c: Partial<TrainingConfig>) => void
}) {
return (
<div className="space-y-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Training-Parameter konfigurieren
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Batch Size</label>
<input type="number" value={config.batch_size} onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Learning Rate</label>
<input type="number" step="0.00001" value={config.learning_rate} onChange={(e) => setConfig({ ...config, learning_rate: parseFloat(e.target.value) })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Epochen</label>
<input type="number" value={config.epochs} onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Warmup Steps</label>
<input type="number" value={config.warmup_steps} onChange={(e) => setConfig({ ...config, warmup_steps: parseInt(e.target.value) })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700" />
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<input type="checkbox" id="mixedPrecision" checked={config.mixed_precision} onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })} className="w-4 h-4 text-blue-600 rounded" />
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
Mixed Precision Training (FP16) - schneller und speichereffizienter
</label>
</div>
</div>
)
}
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Training-Konfiguration bestätigen</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
<div className="flex justify-between"><span className="text-gray-600 dark:text-gray-400">Bundesländer</span><span className="font-medium text-gray-900 dark:text-white">{config.bundeslaender?.length || 0} ausgewählt</span></div>
<div className="flex justify-between"><span className="text-gray-600 dark:text-gray-400">Epochen</span><span className="font-medium text-gray-900 dark:text-white">{config.epochs}</span></div>
<div className="flex justify-between"><span className="text-gray-600 dark:text-gray-400">Batch Size</span><span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span></div>
<div className="flex justify-between"><span className="text-gray-600 dark:text-gray-400">Learning Rate</span><span className="font-medium text-gray-900 dark:text-white">{config.learning_rate}</span></div>
<div className="flex justify-between"><span className="text-gray-600 dark:text-gray-400">Mixed Precision</span><span className="font-medium text-gray-900 dark:text-white">{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}</span></div>
</div>
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Hinweis:</strong> Das Training kann je nach Datenmenge und Konfiguration
mehrere Stunden dauern. Sie können den Fortschritt jederzeit überwachen.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
export function QuickActions() {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Schnellaktionen
</h3>
<div className="space-y-3">
<button className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<span className="flex items-center gap-3">
<span className="text-xl">📊</span>
<span>
<span className="block font-medium text-gray-900 dark:text-white">
Modell-Vergleich
</span>
<span className="text-sm text-gray-500">
Versionen vergleichen
</span>
</span>
</span>
</button>
<button className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<span className="flex items-center gap-3">
<span className="text-xl">📥</span>
<span>
<span className="block font-medium text-gray-900 dark:text-white">
Modell exportieren
</span>
<span className="text-sm text-gray-500">
ONNX/TensorRT Format
</span>
</span>
</span>
</button>
<button className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<span className="flex items-center gap-3">
<span className="text-xl">🔄</span>
<span>
<span className="block font-medium text-gray-900 dark:text-white">
Daten aktualisieren
</span>
<span className="text-sm text-gray-500">
Crawler erneut ausführen
</span>
</span>
</span>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import type { TrainingJob } from './types'
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './ChartComponents'
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
job: TrainingJob
onPause: () => void
onResume: () => void
onStop: () => void
onViewDetails: () => void
}) {
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Modell: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
</p>
</div>
<StatusBadge status={job.status} />
</div>
{/* Progress Section */}
<div className="p-6">
<div className="flex items-center gap-8">
<ProgressRing
progress={job.progress}
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
/>
<div className="flex-1 space-y-4">
{/* Epoch Progress */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Epoche</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.current_epoch} / {job.total_epochs}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
/>
</div>
</div>
{/* Documents Progress */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
/>
</div>
</div>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-4 gap-3 mt-6">
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
</div>
{/* Loss Chart */}
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Loss-Verlauf
</span>
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1">
<span className="w-3 h-0.5 bg-blue-500 rounded" />
Training
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-0.5 bg-purple-500 rounded" />
Validation
</span>
</div>
</div>
<div className="flex gap-4">
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
</div>
</div>
{/* Time Info */}
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
<span>
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
</span>
<span>
Geschätzte Fertigstellung: {job.estimated_completion
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
: '-'
}
</span>
</div>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={onViewDetails}
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Details anzeigen
</button>
<div className="flex gap-2">
{isActive && (
<>
<button
onClick={job.status === 'paused' ? onResume : onPause}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
</button>
<button
onClick={onStop}
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
>
Abbrechen
</button>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import type { TrainingJob, TrainingConfig, DatasetStats } from './types'
// ============================================================================
// MOCK DATA (Replace with real API calls)
// ============================================================================
export const MOCK_JOBS: TrainingJob[] = [
{
id: 'job-1',
name: 'Zeugnis-RAG v2.1',
model_type: 'zeugnis',
status: 'training',
progress: 67,
current_epoch: 7,
total_epochs: 10,
loss: 0.234,
val_loss: 0.289,
learning_rate: 0.00002,
documents_processed: 423,
total_documents: 632,
started_at: new Date(Date.now() - 3600000).toISOString(),
estimated_completion: new Date(Date.now() + 1800000).toISOString(),
error_message: null,
metrics: {
precision: 0.89,
recall: 0.85,
f1_score: 0.87,
accuracy: 0.91,
loss_history: [0.8, 0.6, 0.45, 0.35, 0.28, 0.25, 0.234],
val_loss_history: [0.85, 0.65, 0.5, 0.4, 0.32, 0.3, 0.289],
},
config: {
batch_size: 16,
learning_rate: 0.00005,
epochs: 10,
warmup_steps: 500,
weight_decay: 0.01,
gradient_accumulation: 4,
mixed_precision: true,
bundeslaender: ['ni', 'by', 'nw', 'he', 'bw'],
},
},
]
export const MOCK_STATS: DatasetStats = {
total_documents: 632,
total_chunks: 8547,
training_allowed: 489,
by_bundesland: {
ni: 87, by: 92, nw: 78, he: 65, bw: 71, rp: 43, sn: 38, sh: 34, th: 29,
},
by_doc_type: {
verordnung: 312,
schulordnung: 156,
handreichung: 98,
erlass: 66,
},
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
export async function fetchJobs(): Promise<TrainingJob[]> {
try {
const response = await fetch('/api/admin/training?action=jobs')
if (!response.ok) throw new Error('Failed to fetch jobs')
return await response.json()
} catch (error) {
console.error('Error fetching jobs:', error)
return MOCK_JOBS
}
}
export async function fetchDatasetStats(): Promise<DatasetStats> {
try {
const response = await fetch('/api/admin/training?action=dataset-stats')
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch (error) {
console.error('Error fetching stats:', error)
return MOCK_STATS
}
}
export async function createTrainingJob(config: Partial<TrainingConfig>): Promise<{id: string, status: string}> {
const response = await fetch('/api/admin/training?action=create-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `Zeugnis-RAG ${new Date().toLocaleDateString('de-DE')}`,
model_type: 'zeugnis',
bundeslaender: config.bundeslaender || [],
batch_size: config.batch_size || 16,
learning_rate: config.learning_rate || 0.00005,
epochs: config.epochs || 10,
warmup_steps: config.warmup_steps || 500,
weight_decay: config.weight_decay || 0.01,
gradient_accumulation: config.gradient_accumulation || 4,
mixed_precision: config.mixed_precision ?? true,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create job')
}
return await response.json()
}
export async function pauseJob(jobId: string): Promise<void> {
const response = await fetch(`/api/admin/training?action=pause&job_id=${jobId}`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to pause job')
}
export async function resumeJob(jobId: string): Promise<void> {
const response = await fetch(`/api/admin/training?action=resume&job_id=${jobId}`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to resume job')
}
export async function cancelJob(jobId: string): Promise<void> {
const response = await fetch(`/api/admin/training?action=cancel&job_id=${jobId}`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to cancel job')
}

View File

@@ -0,0 +1,57 @@
export interface TrainingJob {
id: string
name: string
model_type: 'zeugnis' | 'klausur' | 'general'
status: 'queued' | 'preparing' | 'training' | 'validating' | 'completed' | 'failed' | 'paused'
progress: number
current_epoch: number
total_epochs: number
loss: number
val_loss: number
learning_rate: number
documents_processed: number
total_documents: number
started_at: string | null
estimated_completion: string | null
error_message: string | null
metrics: TrainingMetrics
config: TrainingConfig
}
export interface TrainingMetrics {
precision: number
recall: number
f1_score: number
accuracy: number
loss_history: number[]
val_loss_history: number[]
confusion_matrix?: number[][]
}
export interface TrainingConfig {
batch_size: number
learning_rate: number
epochs: number
warmup_steps: number
weight_decay: number
gradient_accumulation: number
mixed_precision: boolean
bundeslaender: string[]
}
export interface DatasetStats {
total_documents: number
total_chunks: number
training_allowed: number
by_bundesland: Record<string, number>
by_doc_type: Record<string, number>
}
export interface ModelVersion {
id: string
version: string
created_at: string
metrics: TrainingMetrics
is_active: boolean
size_mb: number
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
'use client'
import type { AnalyticsOverview } from './types'
import { StatCard } from './StatCard'
export function AnalyticsTab({
analyticsOverview,
isLoadingAnalytics,
onFetchAnalytics,
}: {
analyticsOverview: AnalyticsOverview | null
isLoadingAnalytics: boolean
onFetchAnalytics: () => void
}) {
return (
<div className="space-y-6">
{/* Analytics Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Learning Analytics</h2>
<button
onClick={onFetchAnalytics}
disabled={isLoadingAnalytics}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoadingAnalytics ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
{analyticsOverview ? (
<>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Sessions" value={analyticsOverview.total_sessions} color="blue" />
<StatCard title="Schüler" value={analyticsOverview.unique_students} color="green" />
<StatCard
title="Ø Abschluss"
value={`${Math.round(analyticsOverview.avg_completion_rate * 100)}%`}
color={analyticsOverview.avg_completion_rate > 0.7 ? 'green' : 'yellow'}
/>
<StatCard
title="Ø Learning Gain"
value={analyticsOverview.avg_learning_gain !== null
? `${Math.round(analyticsOverview.avg_learning_gain * 100)}%`
: '-'
}
color={analyticsOverview.avg_learning_gain && analyticsOverview.avg_learning_gain > 0.1 ? 'green' : 'gray'}
/>
</div>
{/* Most Played Units */}
{analyticsOverview.most_played_units.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold text-gray-900 mb-3">Meistgespielte Units</h3>
<div className="space-y-2">
{analyticsOverview.most_played_units.map((item, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-900">{item.unit_id}</span>
<span className="text-sm font-medium text-gray-600">{item.count} Sessions</span>
</div>
))}
</div>
</div>
)}
{/* Struggling Concepts */}
{analyticsOverview.struggling_concepts.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold text-gray-900 mb-3">Schwierige Konzepte</h3>
<div className="space-y-2">
{analyticsOverview.struggling_concepts.map((item, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-red-50 rounded">
<span className="text-sm text-gray-900">{item.concept}</span>
<span className="text-sm font-medium text-red-600">{item.count} Fehler</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-500">
{isLoadingAnalytics ? 'Lade Analytics...' : 'Keine Analytics-Daten verfügbar'}
</p>
<p className="text-sm text-gray-400 mt-2">
Analytics werden nach abgeschlossenen Sessions verfügbar.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useState } from 'react'
import type { LogEntry } from './types'
function LogEntryRow({ log }: { log: LogEntry }) {
const [expanded, setExpanded] = useState(false)
const typeColors = {
error: 'bg-red-100 text-red-800',
exception: 'bg-red-100 text-red-800',
warning: 'bg-yellow-100 text-yellow-800',
info: 'bg-blue-100 text-blue-800',
}
const typeColor = typeColors[log.type as keyof typeof typeColors] || 'bg-gray-100 text-gray-800'
return (
<div
className="border-b border-gray-100 py-2 px-3 hover:bg-gray-50 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-start gap-3">
<span className="text-xs text-gray-400 font-mono whitespace-nowrap">{log.time}</span>
<span className={`text-xs px-2 py-0.5 rounded ${typeColor} font-medium uppercase`}>
{log.type}
</span>
<span className="text-sm text-gray-700 flex-1 break-all">
{log.message.length > 150 && !expanded
? log.message.substring(0, 150) + '...'
: log.message}
</span>
</div>
{expanded && log.stack && (
<pre className="mt-2 text-xs bg-gray-900 text-gray-100 p-2 rounded overflow-x-auto">
{log.stack}
</pre>
)}
</div>
)
}
export function ConsoleLogPanel({
logs,
onRefresh,
onClear,
isLoading,
}: {
logs: LogEntry[]
onRefresh: () => void
onClear: () => void
isLoading: boolean
}) {
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Console Logs</h3>
<div className="flex gap-2">
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
>
Clear
</button>
<button
onClick={onRefresh}
disabled={isLoading}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoading ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{logs.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" 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>Keine Logs vorhanden</p>
<p className="text-sm mt-1">Logs erscheinen, wenn Unity Nachrichten generiert</p>
</div>
) : (
logs.map((log, index) => <LogEntryRow key={index} log={log} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import type { UnitDefinition, GeneratedContent } from './types'
export function ContentTab({
units,
selectedUnit,
generatedContent,
isGenerating,
onSelectUnit,
onFetchUnits,
onGenerateH5P,
onGenerateWorksheet,
onDownloadPdf,
onClearContent,
}: {
units: UnitDefinition[]
selectedUnit: UnitDefinition | null
generatedContent: GeneratedContent | null
isGenerating: boolean
onSelectUnit: (unit: UnitDefinition) => void
onFetchUnits: () => void
onGenerateH5P: (unitId: string) => void
onGenerateWorksheet: (unitId: string) => void
onDownloadPdf: (unitId: string) => void
onClearContent: () => void
}) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Content Generator</h2>
{/* Unit Selector */}
{units.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-medium text-gray-900 mb-3">Unit auswählen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{units.map((unit) => (
<button
key={unit.unit_id}
onClick={() => onSelectUnit(unit)}
className={`
p-3 text-left rounded-lg border transition-colors
${selectedUnit?.unit_id === unit.unit_id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
<span className="font-medium text-gray-900">{unit.unit_id}</span>
<span className="text-xs text-gray-500 ml-2">{unit.template}</span>
</button>
))}
</div>
</div>
) : (
<button
onClick={onFetchUnits}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Units laden
</button>
)}
{/* Generate Buttons */}
{selectedUnit && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-medium text-gray-900 mb-3">
Content generieren für: <span className="text-primary-600">{selectedUnit.unit_id}</span>
</h3>
<div className="flex flex-wrap gap-3">
<button
onClick={() => onGenerateH5P(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
H5P generieren
</button>
<button
onClick={() => onGenerateWorksheet(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
Arbeitsblatt HTML
</button>
<button
onClick={() => onDownloadPdf(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
PDF Download
</button>
</div>
</div>
)}
{/* Generated Content Preview */}
{generatedContent && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Generierter Content</h3>
<button
onClick={onClearContent}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" 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>
{generatedContent.html ? (
<div
className="prose prose-sm max-w-none p-4 bg-gray-50 rounded-lg border"
dangerouslySetInnerHTML={{ __html: generatedContent.html }}
/>
) : (
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto max-h-96">
{JSON.stringify(generatedContent, null, 2)}
</pre>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { DiagnosticEntry } from './types'
export function DiagnosticPanel({
diagnostics,
isLoading,
onRun,
}: {
diagnostics: DiagnosticEntry[]
isLoading: boolean
onRun: () => void
}) {
const severityColors = {
ok: 'text-green-600 bg-green-50',
warning: 'text-yellow-600 bg-yellow-50',
error: 'text-red-600 bg-red-50',
}
const severityIcons = {
ok: '✓',
warning: '⚠',
error: '✕',
}
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Diagnostik</h3>
<button
onClick={onRun}
disabled={isLoading}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoading ? 'Prüfe...' : 'Diagnose starten'}
</button>
</div>
<div className="p-4">
{diagnostics.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Klicke auf &quot;Diagnose starten&quot; um die Szene zu prüfen
</p>
) : (
<div className="space-y-2">
{diagnostics.map((d, index) => (
<div
key={index}
className={`flex items-center gap-3 p-2 rounded ${severityColors[d.severity]}`}
>
<span className="font-bold">{severityIcons[d.severity]}</span>
<span className="text-sm font-medium">{d.category}</span>
<span className="text-sm flex-1">{d.message}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import GameView from '@/components/admin/GameView'
import type { BridgeStatus, LogEntry, DiagnosticEntry } from './types'
import { StatCard } from './StatCard'
import { ConsoleLogPanel } from './ConsoleLogPanel'
import { DiagnosticPanel } from './DiagnosticPanel'
export function EditorTab({
status,
logs,
diagnostics,
isLoadingLogs,
isLoadingDiagnose,
error,
onSendCommand,
onFetchLogs,
onClearLogs,
onRunDiagnose,
}: {
status: BridgeStatus | null
logs: LogEntry[]
diagnostics: DiagnosticEntry[]
isLoadingLogs: boolean
isLoadingDiagnose: boolean
error: string | null
onSendCommand: (command: string) => void
onFetchLogs: () => void
onClearLogs: () => void
onRunDiagnose: () => void
}) {
return (
<>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard
title="Fehler"
value={status?.errors ?? '-'}
color={status?.errors && status.errors > 0 ? 'red' : 'green'}
/>
<StatCard
title="Warnungen"
value={status?.warnings ?? '-'}
color={status?.warnings && status.warnings > 0 ? 'yellow' : 'green'}
/>
<StatCard title="Szene" value={status?.scene ?? '-'} color="blue" />
<StatCard
title="Play Mode"
value={status?.is_playing ? 'Aktiv' : 'Inaktiv'}
color={status?.is_playing ? 'green' : 'gray'}
/>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<button
onClick={() => onSendCommand('play')}
disabled={!status || status.is_playing}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
Play
</button>
<button
onClick={() => onSendCommand('stop')}
disabled={!status || !status.is_playing}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" />
</svg>
Stop
</button>
<button
onClick={() => onSendCommand('quicksetup')}
disabled={!status || status.is_playing}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Quick Setup
</button>
</div>
</div>
{/* Game View */}
<div className="mb-6">
<GameView
isUnityOnline={!!status && !error}
isPlaying={status?.is_playing}
/>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ConsoleLogPanel
logs={logs}
onRefresh={onFetchLogs}
onClear={onClearLogs}
isLoading={isLoadingLogs}
/>
<DiagnosticPanel
diagnostics={diagnostics}
isLoading={isLoadingDiagnose}
onRun={onRunDiagnose}
/>
</div>
{/* API Info */}
<div className="mt-6 bg-slate-50 rounded-lg border border-slate-200 p-4">
<h3 className="font-semibold text-slate-900 mb-2">API Endpoints</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm font-mono">
<code className="bg-slate-100 px-2 py-1 rounded">GET /status</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /logs/errors</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /scene</code>
<code className="bg-slate-100 px-2 py-1 rounded">POST /diagnose</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /play</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /stop</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /screenshot</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /stream/start</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /stream/frame</code>
</div>
<p className="text-sm text-slate-600 mt-3">
Basis-URL: <code className="bg-slate-100 px-1 rounded">http://localhost:8090</code>
<span className="text-blue-600 ml-2">(Streaming-Endpoints blau markiert)</span>
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
export function StatCard({
title,
value,
color = 'gray',
icon,
}: {
title: string
value: string | number
color?: 'red' | 'yellow' | 'green' | 'blue' | 'gray'
icon?: React.ReactNode
}) {
const colorClasses = {
red: 'bg-red-50 border-red-200 text-red-700',
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
green: 'bg-green-50 border-green-200 text-green-700',
blue: 'bg-blue-50 border-blue-200 text-blue-700',
gray: 'bg-gray-50 border-gray-200 text-gray-700',
}
return (
<div className={`rounded-lg border p-4 ${colorClasses[color]}`}>
<div className="flex items-center justify-between">
<p className="text-sm font-medium opacity-80">{title}</p>
{icon}
</div>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import type { BridgeStatus } from './types'
export function StatusBadge({ status, error }: { status: BridgeStatus | null; error: string | null }) {
if (error) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-100 text-red-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
Offline
</div>
)
}
if (!status) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 text-gray-600 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse" />
Verbinde...
</div>
)
}
if (status.is_compiling) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
Kompiliert...
</div>
)
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-100 text-green-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full" />
Online - Port 8090
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import type { UnitDefinition } from './types'
export function UnitsTab({
units,
selectedUnit,
isLoadingUnits,
unitsError,
onFetchUnits,
onFetchUnitDetails,
onClearSelection,
}: {
units: UnitDefinition[]
selectedUnit: UnitDefinition | null
isLoadingUnits: boolean
unitsError: string | null
onFetchUnits: () => void
onFetchUnitDetails: (unitId: string) => void
onClearSelection: () => void
}) {
return (
<div className="space-y-6">
{/* Units Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Unit-Definitionen</h2>
<button
onClick={onFetchUnits}
disabled={isLoadingUnits}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoadingUnits ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
{/* Units Error */}
{unitsError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">{unitsError}</p>
<p className="text-sm text-red-600 mt-1">
Backend starten: <code className="bg-red-100 px-1 rounded">cd backend && python main.py</code>
</p>
</div>
)}
{/* Units Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{units.map((unit) => (
<div
key={unit.unit_id}
className={`
p-4 bg-white rounded-lg border-2 cursor-pointer transition-all
${selectedUnit?.unit_id === unit.unit_id
? 'border-primary-500 shadow-md'
: 'border-gray-200 hover:border-gray-300'
}
`}
onClick={() => onFetchUnitDetails(unit.unit_id)}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-gray-900">{unit.unit_id}</h3>
<span className={`
px-2 py-0.5 text-xs rounded-full
${unit.template === 'flight_path' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'}
`}>
{unit.template}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{unit.topic || unit.subject || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>{unit.duration_minutes} min</span>
<span>{unit.difficulty}</span>
<span>{unit.grade_band?.join(', ') || '-'}</span>
</div>
</div>
))}
</div>
{/* Empty State */}
{!isLoadingUnits && units.length === 0 && !unitsError && (
<div className="text-center py-12 text-gray-500">
<p>Keine Units gefunden</p>
<p className="text-sm mt-1">Units werden unter <code className="bg-gray-100 px-1 rounded">backend/data/units/</code> gespeichert</p>
</div>
)}
{/* Selected Unit Details */}
{selectedUnit && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">{selectedUnit.unit_id}</h3>
<button
onClick={onClearSelection}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" 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>
{/* Learning Objectives */}
{selectedUnit.learning_objectives && selectedUnit.learning_objectives.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Lernziele</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{selectedUnit.learning_objectives.map((obj, i) => (
<li key={i}>{obj}</li>
))}
</ul>
</div>
)}
{/* Stops */}
{selectedUnit.stops && selectedUnit.stops.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Stops ({selectedUnit.stops.length})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUnit.stops.map((stop) => (
<div key={stop.stop_id} className="flex items-center gap-2 p-2 bg-gray-50 rounded text-sm">
<span className="w-6 h-6 flex items-center justify-center bg-primary-100 text-primary-700 rounded-full text-xs font-medium">
{stop.order + 1}
</span>
<span className="text-gray-900">{stop.label?.['de-DE'] || stop.stop_id}</span>
{stop.interaction && (
<span className="text-xs text-gray-500">({stop.interaction.type})</span>
)}
</div>
))}
</div>
</div>
)}
{/* JSON Preview */}
<details className="mt-4">
<summary className="text-sm font-medium text-gray-700 cursor-pointer">JSON anzeigen</summary>
<pre className="mt-2 p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto max-h-96">
{JSON.stringify(selectedUnit, null, 2)}
</pre>
</details>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { Tab } from './types'
export const tabs: Tab[] = [
{
id: 'editor',
label: 'Editor',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: 'units',
label: 'Units',
icon: (
<svg className="w-4 h-4" 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>
),
},
{
id: 'sessions',
label: 'Sessions',
icon: (
<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>
),
},
{
id: 'analytics',
label: 'Analytics',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'content',
label: 'Content',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
]

View File

@@ -0,0 +1,92 @@
// Tab definitions
export type TabId = 'editor' | 'units' | 'sessions' | 'analytics' | 'content'
export interface Tab {
id: TabId
label: string
icon: React.ReactNode
}
// Type definitions
export interface BridgeStatus {
status: string
unity_version: string
project: string
scene: string
is_playing: boolean
is_compiling: boolean
errors: number
warnings: number
}
// Unit System types
export interface UnitDefinition {
unit_id: string
template: string
version: string
locale: string[]
grade_band: string[]
duration_minutes: number
difficulty: string
subject?: string
topic?: string
learning_objectives?: string[]
stops?: UnitStop[]
}
export interface UnitStop {
stop_id: string
order: number
label: { [key: string]: string }
interaction?: {
type: string
params?: Record<string, unknown>
}
}
export interface AnalyticsOverview {
time_range: string
total_sessions: number
unique_students: number
avg_completion_rate: number
avg_learning_gain: number | null
most_played_units: Array<{ unit_id: string; count: number }>
struggling_concepts: Array<{ concept: string; count: number }>
active_classes: number
}
export interface GeneratedContent {
unit_id: string
locale: string
generated_count?: number
html?: string
title?: string
}
export interface LogEntry {
time: string
type: string
message: string
frame: number
stack?: string
}
export interface LogsResponse {
count: number
total_errors: number
total_warnings: number
total_info: number
logs: LogEntry[]
}
export interface DiagnosticEntry {
category: string
severity: 'ok' | 'warning' | 'error'
message: string
}
export interface DiagnoseResponse {
diagnostics: DiagnosticEntry[]
errors: number
warnings: number
}

View File

@@ -0,0 +1,258 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type {
BridgeStatus,
LogEntry,
DiagnosticEntry,
UnitDefinition,
AnalyticsOverview,
GeneratedContent,
LogsResponse,
DiagnoseResponse,
TabId,
} from './types'
export function useUnityBridge() {
// Tab state
const [activeTab, setActiveTab] = useState<TabId>('editor')
// Editor tab state
const [status, setStatus] = useState<BridgeStatus | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [diagnostics, setDiagnostics] = useState<DiagnosticEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [isLoadingDiagnose, setIsLoadingDiagnose] = useState(false)
const [error, setError] = useState<string | null>(null)
// Units tab state
const [units, setUnits] = useState<UnitDefinition[]>([])
const [selectedUnit, setSelectedUnit] = useState<UnitDefinition | null>(null)
const [isLoadingUnits, setIsLoadingUnits] = useState(false)
const [unitsError, setUnitsError] = useState<string | null>(null)
// Analytics tab state
const [analyticsOverview, setAnalyticsOverview] = useState<AnalyticsOverview | null>(null)
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false)
// Content tab state
const [generatedContent, setGeneratedContent] = useState<GeneratedContent | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
// Fetch status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/unity-bridge?action=status')
if (res.ok) {
const data = await res.json()
if (!data.offline) {
setStatus(data)
setError(null)
} else {
setError(data.error)
setStatus(null)
}
} else {
setError('Bridge nicht erreichbar')
setStatus(null)
}
} catch {
setError('Bridge offline - Server in Unity starten')
setStatus(null)
}
setIsLoading(false)
}, [])
// Fetch logs
const fetchLogs = useCallback(async () => {
setIsLoadingLogs(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=logs&limit=50')
if (res.ok) {
const data: LogsResponse = await res.json()
if (data.logs) {
setLogs(data.logs)
}
}
} catch {
// Ignore errors for logs
}
setIsLoadingLogs(false)
}, [])
// Clear logs
const clearLogs = async () => {
try {
await fetch('/api/admin/unity-bridge?action=clear-logs', { method: 'POST' })
setLogs([])
} catch {
// Ignore errors
}
}
// Run diagnostics
const runDiagnose = async () => {
setIsLoadingDiagnose(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=diagnose', { method: 'POST' })
if (res.ok) {
const data: DiagnoseResponse = await res.json()
if (data.diagnostics) {
setDiagnostics(data.diagnostics)
}
}
} catch {
// Ignore errors
}
setIsLoadingDiagnose(false)
}
// Send command
const sendCommand = async (command: string) => {
try {
await fetch(`/api/admin/unity-bridge?action=${command}`)
setTimeout(fetchStatus, 500)
} catch {
// Ignore errors
}
}
// Units Tab Functions
const fetchUnits = useCallback(async () => {
setIsLoadingUnits(true)
setUnitsError(null)
try {
const res = await fetch('/api/admin/unity-bridge?action=units-list')
if (res.ok) {
const data = await res.json()
if (Array.isArray(data)) {
setUnits(data)
} else if (data.offline) {
setUnitsError(data.error)
}
} else {
setUnitsError('Fehler beim Laden der Units')
}
} catch {
setUnitsError('Backend nicht erreichbar')
}
setIsLoadingUnits(false)
}, [])
const fetchUnitDetails = async (unitId: string) => {
try {
const res = await fetch(`/api/admin/unity-bridge?action=units-get&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setSelectedUnit(data.definition || data)
}
} catch {
// Ignore errors
}
}
// Analytics Tab Functions
const fetchAnalytics = useCallback(async () => {
setIsLoadingAnalytics(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=analytics-overview')
if (res.ok) {
const data = await res.json()
if (!data.offline) {
setAnalyticsOverview(data)
}
}
} catch {
// Ignore errors
}
setIsLoadingAnalytics(false)
}, [])
// Content Tab Functions
const generateH5P = async (unitId: string) => {
setIsGenerating(true)
try {
const res = await fetch(`/api/admin/unity-bridge?action=content-h5p&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setGeneratedContent(data)
}
} catch {
// Ignore errors
}
setIsGenerating(false)
}
const generateWorksheet = async (unitId: string) => {
setIsGenerating(true)
try {
const res = await fetch(`/api/admin/unity-bridge?action=content-worksheet&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setGeneratedContent(data)
}
} catch {
// Ignore errors
}
setIsGenerating(false)
}
const downloadPdf = (unitId: string) => {
window.open(`/api/admin/unity-bridge?action=content-pdf&unit_id=${unitId}`, '_blank')
}
// Poll status every 5 seconds (Editor tab)
useEffect(() => {
fetchStatus()
fetchLogs()
const statusInterval = setInterval(fetchStatus, 5000)
const logsInterval = setInterval(fetchLogs, 10000)
return () => {
clearInterval(statusInterval)
clearInterval(logsInterval)
}
}, [fetchStatus, fetchLogs])
// Fetch data when tab changes
useEffect(() => {
if (activeTab === 'units' && units.length === 0) {
fetchUnits()
}
if (activeTab === 'analytics' && !analyticsOverview) {
fetchAnalytics()
}
}, [activeTab, units.length, analyticsOverview, fetchUnits, fetchAnalytics])
return {
activeTab,
setActiveTab,
status,
logs,
diagnostics,
isLoading,
isLoadingLogs,
isLoadingDiagnose,
error,
units,
selectedUnit,
setSelectedUnit,
isLoadingUnits,
unitsError,
analyticsOverview,
isLoadingAnalytics,
generatedContent,
setGeneratedContent,
isGenerating,
fetchLogs,
clearLogs,
runDiagnose,
sendCommand,
fetchUnits,
fetchUnitDetails,
fetchAnalytics,
generateH5P,
generateWorksheet,
downloadPdf,
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More