[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:
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal file
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal file
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal 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`
|
||||
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal file
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal file
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal file
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal 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} "{rule.conditions[0]?.value}"
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
|
||||
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
|
||||
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
|
||||
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{rule.action_type}
|
||||
</span>
|
||||
<div
|
||||
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
|
||||
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all ${
|
||||
rule.is_active ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal file
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal file
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal 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>
|
||||
`,
|
||||
}
|
||||
68
website/app/admin/alerts/_components/types.ts
Normal file
68
website/app/admin/alerts/_components/types.ts
Normal 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'
|
||||
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal file
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal 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
133
website/app/admin/backlog/_components/BacklogItemCard.tsx
Normal file
133
website/app/admin/backlog/_components/BacklogItemCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
website/app/admin/backlog/_components/backlogData.ts
Normal file
205
website/app/admin/backlog/_components/backlogData.ts
Normal 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 }] },
|
||||
]
|
||||
114
website/app/admin/backlog/_components/categories.tsx
Normal file
114
website/app/admin/backlog/_components/categories.tsx
Normal 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',
|
||||
},
|
||||
]
|
||||
35
website/app/admin/backlog/_components/types.ts
Normal file
35
website/app/admin/backlog/_components/types.ts
Normal 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' },
|
||||
}
|
||||
110
website/app/admin/backlog/_components/useBacklog.ts
Normal file
110
website/app/admin/backlog/_components/useBacklog.ts
Normal 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
98
website/app/admin/compliance/_components/ArchitekturTab.tsx
Normal file
98
website/app/admin/compliance/_components/ArchitekturTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
website/app/admin/compliance/_components/AuditTab.tsx
Normal file
81
website/app/admin/compliance/_components/AuditTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
303
website/app/admin/compliance/_components/ExecutiveTab.tsx
Normal file
303
website/app/admin/compliance/_components/ExecutiveTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
website/app/admin/compliance/_components/RoadmapTab.tsx
Normal file
105
website/app/admin/compliance/_components/RoadmapTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
website/app/admin/compliance/_components/TechnischTab.tsx
Normal file
88
website/app/admin/compliance/_components/TechnischTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
website/app/admin/compliance/_components/UebersichtTab.tsx
Normal file
293
website/app/admin/compliance/_components/UebersichtTab.tsx
Normal 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
191
website/app/admin/compliance/types.ts
Normal file
191
website/app/admin/compliance/types.ts
Normal 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' },
|
||||
]
|
||||
67
website/app/admin/docs/_components/ApiReferenceTab.tsx
Normal file
67
website/app/admin/docs/_components/ApiReferenceTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
website/app/admin/docs/_components/DockerTab.tsx
Normal file
116
website/app/admin/docs/_components/DockerTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
website/app/admin/docs/_components/OverviewTab.tsx
Normal file
229
website/app/admin/docs/_components/OverviewTab.tsx
Normal 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 → Next.js/FastAPI Frontend</p>
|
||||
<p><strong>2. API:</strong> Frontend → 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>
|
||||
)
|
||||
}
|
||||
79
website/app/admin/docs/_components/ServiceDetailPanel.tsx
Normal file
79
website/app/admin/docs/_components/ServiceDetailPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
website/app/admin/docs/_components/ServicesTab.tsx
Normal file
78
website/app/admin/docs/_components/ServicesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
website/app/admin/docs/_components/TabNavigation.tsx
Normal file
31
website/app/admin/docs/_components/TabNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
website/app/admin/docs/_components/TestingTab.tsx
Normal file
171
website/app/admin/docs/_components/TestingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
website/app/admin/docs/audit/_components/AuditHeader.tsx
Normal file
37
website/app/admin/docs/audit/_components/AuditHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
website/app/admin/docs/audit/_components/AuditTitleBlock.tsx
Normal file
19
website/app/admin/docs/audit/_components/AuditTitleBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/docs/audit/_components/CodeBlock.tsx
Normal file
12
website/app/admin/docs/audit/_components/CodeBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
website/app/admin/docs/audit/_components/Table.tsx
Normal file
33
website/app/admin/docs/audit/_components/Table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
website/app/admin/docs/audit/_components/TableOfContents.tsx
Normal file
41
website/app/admin/docs/audit/_components/TableOfContents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/docs/audit/_components/sections/index.ts
Normal file
12
website/app/admin/docs/audit/_components/sections/index.ts
Normal 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'
|
||||
54
website/app/admin/docs/audit/constants.ts
Normal file
54
website/app/admin/docs/audit/constants.ts
Normal 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
386
website/app/admin/docs/data.ts
Normal file
386
website/app/admin/docs/data.ts
Normal 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
|
||||
58
website/app/admin/docs/helpers.ts
Normal file
58
website/app/admin/docs/helpers.ts
Normal 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
42
website/app/admin/docs/types.ts
Normal file
42
website/app/admin/docs/types.ts
Normal 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'
|
||||
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal file
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal file
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal file
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
website/app/admin/quality/_components/MetricCard.tsx
Normal file
52
website/app/admin/quality/_components/MetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal file
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/quality/_components/RagTab.tsx
Normal file
97
website/app/admin/quality/_components/RagTab.tsx
Normal 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 "Tests starten" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal file
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal file
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
website/app/admin/quality/_components/SyntheticTab.tsx
Normal file
75
website/app/admin/quality/_components/SyntheticTab.tsx
Normal 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 "Tests starten" um Variationen zu generieren</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
website/app/admin/quality/_components/TestRunsTable.tsx
Normal file
60
website/app/admin/quality/_components/TestRunsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/quality/_components/TestSuiteCard.tsx
Normal file
97
website/app/admin/quality/_components/TestSuiteCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
website/app/admin/quality/_components/TrendChart.tsx
Normal file
79
website/app/admin/quality/_components/TrendChart.tsx
Normal 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
56
website/app/admin/quality/types.ts
Normal file
56
website/app/admin/quality/types.ts
Normal 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'
|
||||
132
website/app/admin/quality/useQualityDashboard.ts
Normal file
132
website/app/admin/quality/useQualityDashboard.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
123
website/app/admin/rag/_components/CollectionsTab.tsx
Normal file
123
website/app/admin/rag/_components/CollectionsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
website/app/admin/rag/_components/IngestionTab.tsx
Normal file
98
website/app/admin/rag/_components/IngestionTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
website/app/admin/rag/_components/MetricsTab.tsx
Normal file
111
website/app/admin/rag/_components/MetricsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
website/app/admin/rag/_components/SearchTab.tsx
Normal file
141
website/app/admin/rag/_components/SearchTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
website/app/admin/rag/_components/UploadTab.tsx
Normal file
126
website/app/admin/rag/_components/UploadTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/rag/_components/tabs.tsx
Normal file
49
website/app/admin/rag/_components/tabs.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
]
|
||||
35
website/app/admin/rag/_components/types.ts
Normal file
35
website/app/admin/rag/_components/types.ts
Normal 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
150
website/app/admin/training/_components/ChartComponents.tsx
Normal file
150
website/app/admin/training/_components/ChartComponents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
website/app/admin/training/_components/DatasetOverview.tsx
Normal file
62
website/app/admin/training/_components/DatasetOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
website/app/admin/training/_components/NewTrainingModal.tsx
Normal file
230
website/app/admin/training/_components/NewTrainingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
website/app/admin/training/_components/QuickActions.tsx
Normal file
52
website/app/admin/training/_components/QuickActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
website/app/admin/training/_components/TrainingJobCard.tsx
Normal file
144
website/app/admin/training/_components/TrainingJobCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
website/app/admin/training/_components/api.ts
Normal file
123
website/app/admin/training/_components/api.ts
Normal 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')
|
||||
}
|
||||
57
website/app/admin/training/_components/types.ts
Normal file
57
website/app/admin/training/_components/types.ts
Normal 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
92
website/app/admin/unity-bridge/_components/AnalyticsTab.tsx
Normal file
92
website/app/admin/unity-bridge/_components/AnalyticsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
133
website/app/admin/unity-bridge/_components/ContentTab.tsx
Normal file
133
website/app/admin/unity-bridge/_components/ContentTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 "Diagnose starten" 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>
|
||||
)
|
||||
}
|
||||
132
website/app/admin/unity-bridge/_components/EditorTab.tsx
Normal file
132
website/app/admin/unity-bridge/_components/EditorTab.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
website/app/admin/unity-bridge/_components/StatCard.tsx
Normal file
31
website/app/admin/unity-bridge/_components/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
website/app/admin/unity-bridge/_components/StatusBadge.tsx
Normal file
39
website/app/admin/unity-bridge/_components/StatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
website/app/admin/unity-bridge/_components/UnitsTab.tsx
Normal file
145
website/app/admin/unity-bridge/_components/UnitsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/unity-bridge/_components/tabs.tsx
Normal file
49
website/app/admin/unity-bridge/_components/tabs.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
]
|
||||
92
website/app/admin/unity-bridge/_components/types.ts
Normal file
92
website/app/admin/unity-bridge/_components/types.ts
Normal 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
|
||||
}
|
||||
258
website/app/admin/unity-bridge/_components/useUnityBridge.ts
Normal file
258
website/app/admin/unity-bridge/_components/useUnityBridge.ts
Normal 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
Reference in New Issue
Block a user