[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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user