refactor(admin): split dsfa, audit-llm, quality pages
Extract components and hooks from oversized page files (563/561/520 LOC) into colocated _components/ and _hooks/ subdirectories. All three page.tsx files are now thin orchestrators under 300 LOC each (dsfa: 216, audit-llm: 121, quality: 163). Zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
73
admin-compliance/app/sdk/quality/_components/MetricCard.tsx
Normal file
73
admin-compliance/app/sdk/quality/_components/MetricCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
export interface QualityMetric {
|
||||
id: string
|
||||
name: string
|
||||
category: 'accuracy' | 'fairness' | 'robustness' | 'explainability' | 'performance'
|
||||
score: number
|
||||
threshold: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
last_measured: string
|
||||
ai_system: string | null
|
||||
}
|
||||
|
||||
export function MetricCard({ metric, onEdit }: { metric: QualityMetric; onEdit: (m: QualityMetric) => void }) {
|
||||
const isAboveThreshold = metric.score >= metric.threshold
|
||||
const categoryColors = {
|
||||
accuracy: 'bg-blue-100 text-blue-700',
|
||||
fairness: 'bg-purple-100 text-purple-700',
|
||||
robustness: 'bg-green-100 text-green-700',
|
||||
explainability: 'bg-yellow-100 text-yellow-700',
|
||||
performance: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
accuracy: 'Genauigkeit',
|
||||
fairness: 'Fairness',
|
||||
robustness: 'Robustheit',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
performance: 'Performance',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${isAboveThreshold ? 'border-gray-200' : 'border-red-200'}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[metric.category]}`}>
|
||||
{categoryLabels[metric.category]}
|
||||
</span>
|
||||
<h4 className="font-semibold text-gray-900 mt-2">{metric.name}</h4>
|
||||
{metric.ai_system && <p className="text-xs text-gray-500">{metric.ai_system}</p>}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${
|
||||
metric.trend === 'up' ? 'text-green-600' :
|
||||
metric.trend === 'down' ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{metric.trend === 'up' && <svg className="w-4 h-4" 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>}
|
||||
{metric.trend === 'down' && <svg className="w-4 h-4" 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>}
|
||||
{metric.trend === 'stable' && <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" /></svg>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold ${isAboveThreshold ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
{metric.score}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Schwellenwert: {metric.threshold}%</div>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isAboveThreshold ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(metric.score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button onClick={() => onEdit(metric)} className="text-xs text-purple-600 hover:text-purple-700 hover:bg-purple-50 px-2 py-1 rounded">
|
||||
Score aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
admin-compliance/app/sdk/quality/_components/MetricModal.tsx
Normal file
74
admin-compliance/app/sdk/quality/_components/MetricModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { QualityMetric } from './MetricCard'
|
||||
|
||||
export function MetricModal({ metric, onClose, onSave }: {
|
||||
metric?: QualityMetric
|
||||
onClose: () => void
|
||||
onSave: (data: any) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: metric?.name || '',
|
||||
category: metric?.category || 'accuracy',
|
||||
score: metric?.score ?? 0,
|
||||
threshold: metric?.threshold ?? 80,
|
||||
trend: metric?.trend || 'stable',
|
||||
ai_system: metric?.ai_system || '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">{metric ? 'Metrik bearbeiten' : 'Messung hinzufuegen'}</h3>
|
||||
<button onClick={onClose} 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>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Accuracy Score" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="accuracy">Genauigkeit</option>
|
||||
<option value="fairness">Fairness</option>
|
||||
<option value="robustness">Robustheit</option>
|
||||
<option value="explainability">Erklaerbarkeit</option>
|
||||
<option value="performance">Performance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trend</label>
|
||||
<select value={form.trend} onChange={e => setForm(p => ({ ...p, trend: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="up">Steigend</option>
|
||||
<option value="stable">Stabil</option>
|
||||
<option value="down">Fallend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Score (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.score} onChange={e => setForm(p => ({ ...p, score: parseFloat(e.target.value) || 0 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schwellenwert (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.threshold} onChange={e => setForm(p => ({ ...p, threshold: parseFloat(e.target.value) || 80 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
admin-compliance/app/sdk/quality/_components/TestModal.tsx
Normal file
53
admin-compliance/app/sdk/quality/_components/TestModal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function TestModal({ onClose, onSave }: { onClose: () => void; onSave: (data: any) => void }) {
|
||||
const [form, setForm] = useState({ name: '', status: 'pending', duration: '', ai_system: '', details: '' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Test hinzufuegen</h3>
|
||||
<button onClick={onClose} 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>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bias Detection Test" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={form.status} onChange={e => setForm(p => ({ ...p, status: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="passed">Bestanden</option>
|
||||
<option value="failed">Fehlgeschlagen</option>
|
||||
<option value="warning">Warnung</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer</label>
|
||||
<input type="text" value={form.duration} onChange={e => setForm(p => ({ ...p, duration: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. 45min" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details</label>
|
||||
<input type="text" value={form.details} onChange={e => setForm(p => ({ ...p, details: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="Ergebnis-Zusammenfassung" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
admin-compliance/app/sdk/quality/_components/TestRow.tsx
Normal file
54
admin-compliance/app/sdk/quality/_components/TestRow.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
export interface QualityTest {
|
||||
id: string
|
||||
name: string
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending'
|
||||
last_run: string
|
||||
duration: string | null
|
||||
ai_system: string | null
|
||||
details: string | null
|
||||
}
|
||||
|
||||
export function TestRow({ test, onDelete }: { test: QualityTest; onDelete: (id: string) => void }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
pending: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
passed: 'Bestanden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
warning: 'Warnung',
|
||||
pending: 'Ausstehend',
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{test.name}</div>
|
||||
{test.ai_system && <div className="text-xs text-gray-500">{test.ai_system}</div>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[test.status]}`}>
|
||||
{statusLabels[test.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(test.last_run).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{test.duration || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">{test.details || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`"${test.name}" loeschen?`)) onDelete(test.id) }}
|
||||
className="text-sm text-red-400 hover:text-red-600"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
125
admin-compliance/app/sdk/quality/_hooks/useQualityData.ts
Normal file
125
admin-compliance/app/sdk/quality/_hooks/useQualityData.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { QualityMetric } from '../_components/MetricCard'
|
||||
import type { QualityTest } from '../_components/TestRow'
|
||||
|
||||
interface Stats {
|
||||
total_metrics: number
|
||||
avg_score: number
|
||||
metrics_above_threshold: number
|
||||
passed: number
|
||||
failed: number
|
||||
warning: number
|
||||
total_tests: number
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance/quality'
|
||||
|
||||
export function useQualityData() {
|
||||
const [metrics, setMetrics] = useState<QualityMetric[]>([])
|
||||
const [tests, setTests] = useState<QualityTest[]>([])
|
||||
const [apiStats, setApiStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metricsRes, testsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/metrics?limit=100`),
|
||||
fetch(`${API_BASE}/tests?limit=100`),
|
||||
fetch(`${API_BASE}/stats`),
|
||||
])
|
||||
if (metricsRes.ok) {
|
||||
const d = await metricsRes.json()
|
||||
setMetrics(Array.isArray(d.metrics) ? d.metrics : [])
|
||||
}
|
||||
if (testsRes.ok) {
|
||||
const d = await testsRes.json()
|
||||
setTests(Array.isArray(d.tests) ? d.tests : [])
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
setApiStats(await statsRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load quality data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCreateMetric = useCallback(async (form: any) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setMetrics(prev => [...prev, created])
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create metric:', err)
|
||||
}
|
||||
}, [loadAll])
|
||||
|
||||
const handleUpdateMetric = useCallback(async (editMetric: QualityMetric, form: any) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics/${editMetric.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setMetrics(prev => prev.map(m => m.id === updated.id ? updated : m))
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update metric:', err)
|
||||
}
|
||||
}, [loadAll])
|
||||
|
||||
const handleCreateTest = useCallback(async (form: any) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setTests(prev => [created, ...prev])
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create test:', err)
|
||||
}
|
||||
}, [loadAll])
|
||||
|
||||
const handleDeleteTest = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests/${id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
setTests(prev => prev.filter(t => t.id !== id))
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete test:', err)
|
||||
}
|
||||
}, [loadAll])
|
||||
|
||||
return {
|
||||
metrics,
|
||||
tests,
|
||||
apiStats,
|
||||
loading,
|
||||
loadAll,
|
||||
handleCreateMetric,
|
||||
handleUpdateMetric,
|
||||
handleCreateTest,
|
||||
handleDeleteTest,
|
||||
}
|
||||
}
|
||||
@@ -1,390 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface QualityMetric {
|
||||
id: string
|
||||
name: string
|
||||
category: 'accuracy' | 'fairness' | 'robustness' | 'explainability' | 'performance'
|
||||
score: number
|
||||
threshold: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
last_measured: string
|
||||
ai_system: string | null
|
||||
}
|
||||
|
||||
interface QualityTest {
|
||||
id: string
|
||||
name: string
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending'
|
||||
last_run: string
|
||||
duration: string | null
|
||||
ai_system: string | null
|
||||
details: string | null
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_metrics: number
|
||||
avg_score: number
|
||||
metrics_above_threshold: number
|
||||
passed: number
|
||||
failed: number
|
||||
warning: number
|
||||
total_tests: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function MetricCard({ metric, onEdit }: { metric: QualityMetric; onEdit: (m: QualityMetric) => void }) {
|
||||
const isAboveThreshold = metric.score >= metric.threshold
|
||||
const categoryColors = {
|
||||
accuracy: 'bg-blue-100 text-blue-700',
|
||||
fairness: 'bg-purple-100 text-purple-700',
|
||||
robustness: 'bg-green-100 text-green-700',
|
||||
explainability: 'bg-yellow-100 text-yellow-700',
|
||||
performance: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
accuracy: 'Genauigkeit',
|
||||
fairness: 'Fairness',
|
||||
robustness: 'Robustheit',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
performance: 'Performance',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${isAboveThreshold ? 'border-gray-200' : 'border-red-200'}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[metric.category]}`}>
|
||||
{categoryLabels[metric.category]}
|
||||
</span>
|
||||
<h4 className="font-semibold text-gray-900 mt-2">{metric.name}</h4>
|
||||
{metric.ai_system && <p className="text-xs text-gray-500">{metric.ai_system}</p>}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${
|
||||
metric.trend === 'up' ? 'text-green-600' :
|
||||
metric.trend === 'down' ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{metric.trend === 'up' && <svg className="w-4 h-4" 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>}
|
||||
{metric.trend === 'down' && <svg className="w-4 h-4" 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>}
|
||||
{metric.trend === 'stable' && <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" /></svg>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold ${isAboveThreshold ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
{metric.score}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Schwellenwert: {metric.threshold}%</div>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isAboveThreshold ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(metric.score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button onClick={() => onEdit(metric)} className="text-xs text-purple-600 hover:text-purple-700 hover:bg-purple-50 px-2 py-1 rounded">
|
||||
Score aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestRow({ test, onDelete }: { test: QualityTest; onDelete: (id: string) => void }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
pending: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
passed: 'Bestanden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
warning: 'Warnung',
|
||||
pending: 'Ausstehend',
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{test.name}</div>
|
||||
{test.ai_system && <div className="text-xs text-gray-500">{test.ai_system}</div>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[test.status]}`}>
|
||||
{statusLabels[test.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(test.last_run).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{test.duration || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">{test.details || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`"${test.name}" loeschen?`)) onDelete(test.id) }}
|
||||
className="text-sm text-red-400 hover:text-red-600"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODALS
|
||||
// =============================================================================
|
||||
|
||||
function MetricModal({ metric, onClose, onSave }: {
|
||||
metric?: QualityMetric
|
||||
onClose: () => void
|
||||
onSave: (data: any) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: metric?.name || '',
|
||||
category: metric?.category || 'accuracy',
|
||||
score: metric?.score ?? 0,
|
||||
threshold: metric?.threshold ?? 80,
|
||||
trend: metric?.trend || 'stable',
|
||||
ai_system: metric?.ai_system || '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">{metric ? 'Metrik bearbeiten' : 'Messung hinzufuegen'}</h3>
|
||||
<button onClick={onClose} 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>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Accuracy Score" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="accuracy">Genauigkeit</option>
|
||||
<option value="fairness">Fairness</option>
|
||||
<option value="robustness">Robustheit</option>
|
||||
<option value="explainability">Erklaerbarkeit</option>
|
||||
<option value="performance">Performance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trend</label>
|
||||
<select value={form.trend} onChange={e => setForm(p => ({ ...p, trend: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="up">Steigend</option>
|
||||
<option value="stable">Stabil</option>
|
||||
<option value="down">Fallend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Score (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.score} onChange={e => setForm(p => ({ ...p, score: parseFloat(e.target.value) || 0 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schwellenwert (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.threshold} onChange={e => setForm(p => ({ ...p, threshold: parseFloat(e.target.value) || 80 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestModal({ onClose, onSave }: { onClose: () => void; onSave: (data: any) => void }) {
|
||||
const [form, setForm] = useState({ name: '', status: 'pending', duration: '', ai_system: '', details: '' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Test hinzufuegen</h3>
|
||||
<button onClick={onClose} 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>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bias Detection Test" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={form.status} onChange={e => setForm(p => ({ ...p, status: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="passed">Bestanden</option>
|
||||
<option value="failed">Fehlgeschlagen</option>
|
||||
<option value="warning">Warnung</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer</label>
|
||||
<input type="text" value={form.duration} onChange={e => setForm(p => ({ ...p, duration: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. 45min" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details</label>
|
||||
<input type="text" value={form.details} onChange={e => setForm(p => ({ ...p, details: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="Ergebnis-Zusammenfassung" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance/quality'
|
||||
import { useQualityData } from './_hooks/useQualityData'
|
||||
import { MetricCard, type QualityMetric } from './_components/MetricCard'
|
||||
import { TestRow } from './_components/TestRow'
|
||||
import { MetricModal } from './_components/MetricModal'
|
||||
import { TestModal } from './_components/TestModal'
|
||||
|
||||
export default function QualityPage() {
|
||||
const { state } = useSDK()
|
||||
const [metrics, setMetrics] = useState<QualityMetric[]>([])
|
||||
const [tests, setTests] = useState<QualityTest[]>([])
|
||||
const [apiStats, setApiStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const {
|
||||
metrics,
|
||||
tests,
|
||||
apiStats,
|
||||
loading,
|
||||
loadAll,
|
||||
handleCreateMetric,
|
||||
handleUpdateMetric,
|
||||
handleCreateTest,
|
||||
handleDeleteTest,
|
||||
} = useQualityData()
|
||||
|
||||
const [showMetricModal, setShowMetricModal] = useState(false)
|
||||
const [showTestModal, setShowTestModal] = useState(false)
|
||||
const [editMetric, setEditMetric] = useState<QualityMetric | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [])
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metricsRes, testsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/metrics?limit=100`),
|
||||
fetch(`${API_BASE}/tests?limit=100`),
|
||||
fetch(`${API_BASE}/stats`),
|
||||
])
|
||||
if (metricsRes.ok) {
|
||||
const d = await metricsRes.json()
|
||||
setMetrics(Array.isArray(d.metrics) ? d.metrics : [])
|
||||
}
|
||||
if (testsRes.ok) {
|
||||
const d = await testsRes.json()
|
||||
setTests(Array.isArray(d.tests) ? d.tests : [])
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
setApiStats(await statsRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load quality data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateMetric(form: any) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setMetrics(prev => [...prev, created])
|
||||
setShowMetricModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create metric:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateMetric(form: any) {
|
||||
if (!editMetric) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics/${editMetric.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setMetrics(prev => prev.map(m => m.id === updated.id ? updated : m))
|
||||
setEditMetric(undefined)
|
||||
setShowMetricModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update metric:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTest(form: any) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setTests(prev => [created, ...prev])
|
||||
setShowTestModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create test:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTest(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests/${id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
setTests(prev => prev.filter(t => t.id !== id))
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete test:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Derived stats — prefer API stats, fallback to computed
|
||||
const avgScore = apiStats ? apiStats.avg_score : (metrics.length > 0 ? Math.round(metrics.reduce((s, m) => s + m.score, 0) / metrics.length) : 0)
|
||||
const metricsAboveThreshold = apiStats ? apiStats.metrics_above_threshold : metrics.filter(m => m.score >= m.threshold).length
|
||||
const passedTests = apiStats ? apiStats.passed : tests.filter(t => t.status === 'passed').length
|
||||
@@ -393,7 +36,6 @@ export default function QualityPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Quality Dashboard</h1>
|
||||
@@ -417,7 +59,6 @@ export default function QualityPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Durchschnittlicher Score</div>
|
||||
@@ -437,7 +78,6 @@ export default function QualityPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert for failed metrics */}
|
||||
{failingMetrics.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
@@ -450,7 +90,6 @@ export default function QualityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Qualitaetsmetriken</h3>
|
||||
{loading ? (
|
||||
@@ -472,7 +111,6 @@ export default function QualityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tests Table */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Testergebnisse</h3>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
@@ -504,16 +142,21 @@ export default function QualityPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showMetricModal && (
|
||||
<MetricModal
|
||||
metric={editMetric}
|
||||
onClose={() => { setShowMetricModal(false); setEditMetric(undefined) }}
|
||||
onSave={editMetric ? handleUpdateMetric : handleCreateMetric}
|
||||
onSave={editMetric
|
||||
? (form: any) => { handleUpdateMetric(editMetric, form); setShowMetricModal(false); setEditMetric(undefined) }
|
||||
: (form: any) => { handleCreateMetric(form); setShowMetricModal(false) }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showTestModal && (
|
||||
<TestModal onClose={() => setShowTestModal(false)} onSave={handleCreateTest} />
|
||||
<TestModal
|
||||
onClose={() => setShowTestModal(false)}
|
||||
onSave={(form: any) => { handleCreateTest(form); setShowTestModal(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user