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:
128
admin-compliance/app/sdk/audit-llm/_components/ComplianceTab.tsx
Normal file
128
admin-compliance/app/sdk/audit-llm/_components/ComplianceTab.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { StatCard } from './StatCard'
|
||||
import { formatNumber, type ComplianceReport } from './types'
|
||||
|
||||
export function ComplianceTab({ complianceReport }: { complianceReport: ComplianceReport }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
admin-compliance/app/sdk/audit-llm/_components/LLMLogTab.tsx
Normal file
90
admin-compliance/app/sdk/audit-llm/_components/LLMLogTab.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { formatDate, formatNumber, formatDuration, type LLMLogEntry } from './types'
|
||||
|
||||
interface Props {
|
||||
logEntries: LLMLogEntry[]
|
||||
logFilter: { model: string; pii: string }
|
||||
onFilterChange: (filter: { model: string; pii: string }) => void
|
||||
}
|
||||
|
||||
export function LLMLogTab({ logEntries, logFilter, onFilterChange }: Props) {
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => onFilterChange({ ...logFilter, model: e.target.value })}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => onFilterChange({ ...logFilter, pii: e.target.value })}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
admin-compliance/app/sdk/audit-llm/_components/StatCard.tsx
Normal file
10
admin-compliance/app/sdk/audit-llm/_components/StatCard.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
export function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
admin-compliance/app/sdk/audit-llm/_components/UsageTab.tsx
Normal file
84
admin-compliance/app/sdk/audit-llm/_components/UsageTab.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { StatCard } from './StatCard'
|
||||
import { formatNumber, formatDuration, type UsageStats } from './types'
|
||||
|
||||
export function UsageTab({ usageStats }: { usageStats: UsageStats }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
admin-compliance/app/sdk/audit-llm/_components/types.ts
Normal file
75
admin-compliance/app/sdk/audit-llm/_components/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
export const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
94
admin-compliance/app/sdk/audit-llm/_hooks/useAuditData.ts
Normal file
94
admin-compliance/app/sdk/audit-llm/_hooks/useAuditData.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { API_BASE, getDateRange, type LLMLogEntry, type UsageStats, type ComplianceReport } from '../_components/types'
|
||||
|
||||
export function useAuditData(period: string, logFilter: { model: string; pii: string }) {
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const handleExport = useCallback(async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}, [period])
|
||||
|
||||
return {
|
||||
logEntries,
|
||||
usageStats,
|
||||
complianceReport,
|
||||
loading,
|
||||
error,
|
||||
loadLLMLog,
|
||||
loadUsage,
|
||||
loadCompliance,
|
||||
handleExport,
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useAuditData } from './_hooks/useAuditData'
|
||||
import { LLMLogTab } from './_components/LLMLogTab'
|
||||
import { UsageTab } from './_components/UsageTab'
|
||||
import { ComplianceTab } from './_components/ComplianceTab'
|
||||
import type { TabId } from './_components/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
export default function AuditLLMPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('llm-log')
|
||||
const [period, setPeriod] = useState('7d')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// LLM Log state
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [logFilter, setLogFilter] = useState({ model: '', pii: '' })
|
||||
|
||||
// Usage state
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
|
||||
// Compliance state
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
|
||||
// ─── Load Data ───────────────────────────────────────────────────────
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
const {
|
||||
logEntries,
|
||||
usageStats,
|
||||
complianceReport,
|
||||
loading,
|
||||
error,
|
||||
loadLLMLog,
|
||||
loadUsage,
|
||||
loadCompliance,
|
||||
handleExport,
|
||||
} = useAuditData(period, logFilter)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'llm-log') loadLLMLog()
|
||||
@@ -169,42 +38,13 @@ export default function AuditLLMPage() {
|
||||
else if (activeTab === 'compliance') loadCompliance()
|
||||
}, [activeTab, loadLLMLog, loadUsage, loadCompliance])
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleExport = async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">LLM Audit Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Monitoring und Compliance-Analyse der LLM-Operationen</p>
|
||||
</div>
|
||||
|
||||
{/* Period + Tabs */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{tabs.map(tab => (
|
||||
@@ -254,289 +94,22 @@ export default function AuditLLMPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LLM-Log Tab ── */}
|
||||
{!loading && activeTab === 'llm-log' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => setLogFilter(f => ({ ...f, model: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => setLogFilter(f => ({ ...f, pii: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
<LLMLogTab
|
||||
logEntries={logEntries}
|
||||
logFilter={logFilter}
|
||||
onFilterChange={setLogFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Nutzung Tab ── */}
|
||||
{!loading && activeTab === 'usage' && usageStats && (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UsageTab usageStats={usageStats} />
|
||||
)}
|
||||
|
||||
{/* ── Compliance Tab ── */}
|
||||
{!loading && activeTab === 'compliance' && complianceReport && (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ComplianceTab complianceReport={complianceReport} />
|
||||
)}
|
||||
|
||||
{/* Empty state for usage/compliance when no data */}
|
||||
{!loading && activeTab === 'usage' && !usageStats && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Keine Nutzungsdaten verfuegbar</div>
|
||||
)}
|
||||
@@ -546,16 +119,3 @@ export default function AuditLLMPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
133
admin-compliance/app/sdk/dsfa/_components/DSFACard.tsx
Normal file
133
admin-compliance/app/sdk/dsfa/_components/DSFACard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
export interface DSFA {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
approvedBy: string | null
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
processingActivity: string
|
||||
dataCategories: string[]
|
||||
recipients: string[]
|
||||
measures: string[]
|
||||
}
|
||||
|
||||
export function DSFACard({
|
||||
dsfa,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
}: {
|
||||
dsfa: DSFA
|
||||
onStatusChange: (id: string, status: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
'in-review': 'In Pruefung',
|
||||
approved: 'Genehmigt',
|
||||
'needs-update': 'Aktualisierung erforderlich',
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-100 text-green-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const createdDate = dsfa.createdAt
|
||||
? new Date(dsfa.createdAt).toLocaleDateString('de-DE')
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||
{statusLabels[dsfa.status]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
|
||||
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
|
||||
dsfa.riskLevel === 'medium' ? 'Mittel' :
|
||||
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{dsfa.dataCategories.map(cat => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsfa.measures.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Massnahmen:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{dsfa.measures.map(m => (
|
||||
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Erstellt: {createdDate}</span>
|
||||
{dsfa.approvedBy && (
|
||||
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{dsfa.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(dsfa.id, 'in-review')}
|
||||
className="px-3 py-1 text-yellow-600 hover:bg-yellow-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Zur Pruefung
|
||||
</button>
|
||||
)}
|
||||
{dsfa.status === 'in-review' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(dsfa.id, 'approved')}
|
||||
className="px-3 py-1 text-green-600 hover:bg-green-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(dsfa.id)}
|
||||
className="px-3 py-1 text-red-500 hover:bg-red-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
191
admin-compliance/app/sdk/dsfa/_components/GeneratorWizard.tsx
Normal file
191
admin-compliance/app/sdk/dsfa/_components/GeneratorWizard.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { DSFA } from './DSFACard'
|
||||
|
||||
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [processingActivity, setProcessingActivity] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
||||
|
||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
title,
|
||||
description,
|
||||
processingActivity,
|
||||
dataCategories: selectedCategories,
|
||||
riskLevel,
|
||||
measures: selectedMeasures,
|
||||
status: 'draft',
|
||||
})
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" 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="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
s === step ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{s < step ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-h-48">
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||
<input
|
||||
type="text"
|
||||
value={processingActivity}
|
||||
onChange={e => setProcessingActivity(e.target.value)}
|
||||
placeholder="z.B. Automatisierte Auswertung von Kundendaten"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={selectedCategories.includes(cat)}
|
||||
onChange={e => setSelectedCategories(prev =>
|
||||
e.target.checked ? [...prev, cat] : prev.filter(c => c !== cat)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
|
||||
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
|
||||
<div className="space-y-2">
|
||||
{(['Niedrig', 'Mittel', 'Hoch', 'Kritisch'] as const).map(level => (
|
||||
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="risk"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={riskLevel === riskMap[level]}
|
||||
onChange={() => setRiskLevel(riskMap[level])}
|
||||
/>
|
||||
<span className="text-sm font-medium">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
|
||||
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={selectedMeasures.includes(m)}
|
||||
onChange={e => setSelectedMeasures(prev =>
|
||||
e.target.checked ? [...prev, m] : prev.filter(x => x !== m)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{m}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
||||
disabled={saving || (step === 1 && !title.trim())}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface DSFA {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
approvedBy: string | null
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
processingActivity: string
|
||||
dataCategories: string[]
|
||||
recipients: string[]
|
||||
measures: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function DSFACard({
|
||||
dsfa,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
}: {
|
||||
dsfa: DSFA
|
||||
onStatusChange: (id: string, status: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
'in-review': 'In Pruefung',
|
||||
approved: 'Genehmigt',
|
||||
'needs-update': 'Aktualisierung erforderlich',
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-100 text-green-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const createdDate = dsfa.createdAt
|
||||
? new Date(dsfa.createdAt).toLocaleDateString('de-DE')
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||
{statusLabels[dsfa.status]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
|
||||
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
|
||||
dsfa.riskLevel === 'medium' ? 'Mittel' :
|
||||
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{dsfa.dataCategories.map(cat => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsfa.measures.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Massnahmen:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{dsfa.measures.map(m => (
|
||||
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Erstellt: {createdDate}</span>
|
||||
{dsfa.approvedBy && (
|
||||
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{dsfa.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(dsfa.id, 'in-review')}
|
||||
className="px-3 py-1 text-yellow-600 hover:bg-yellow-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Zur Pruefung
|
||||
</button>
|
||||
)}
|
||||
{dsfa.status === 'in-review' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(dsfa.id, 'approved')}
|
||||
className="px-3 py-1 text-green-600 hover:bg-green-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(dsfa.id)}
|
||||
className="px-3 py-1 text-red-500 hover:bg-red-50 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [processingActivity, setProcessingActivity] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
||||
|
||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
title,
|
||||
description,
|
||||
processingActivity,
|
||||
dataCategories: selectedCategories,
|
||||
riskLevel,
|
||||
measures: selectedMeasures,
|
||||
status: 'draft',
|
||||
})
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" 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="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
s === step ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{s < step ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-h-48">
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||
<input
|
||||
type="text"
|
||||
value={processingActivity}
|
||||
onChange={e => setProcessingActivity(e.target.value)}
|
||||
placeholder="z.B. Automatisierte Auswertung von Kundendaten"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={selectedCategories.includes(cat)}
|
||||
onChange={e => setSelectedCategories(prev =>
|
||||
e.target.checked ? [...prev, cat] : prev.filter(c => c !== cat)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
|
||||
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
|
||||
<div className="space-y-2">
|
||||
{(['Niedrig', 'Mittel', 'Hoch', 'Kritisch'] as const).map(level => (
|
||||
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="risk"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={riskLevel === riskMap[level]}
|
||||
onChange={() => setRiskLevel(riskMap[level])}
|
||||
/>
|
||||
<span className="text-sm font-medium">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
|
||||
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-purple-600"
|
||||
checked={selectedMeasures.includes(m)}
|
||||
onChange={e => setSelectedMeasures(prev =>
|
||||
e.target.checked ? [...prev, m] : prev.filter(x => x !== m)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{m}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
||||
disabled={saving || (step === 1 && !title.trim())}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { DSFACard, type DSFA } from './_components/DSFACard'
|
||||
import { GeneratorWizard } from './_components/GeneratorWizard'
|
||||
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
@@ -375,9 +46,7 @@ export default function DSFAPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDSFAs()
|
||||
}, [loadDSFAs])
|
||||
useEffect(() => { loadDSFAs() }, [loadDSFAs])
|
||||
|
||||
const handleCreateDSFA = useCallback(async (data: Partial<DSFA>) => {
|
||||
const res = await fetch('/api/sdk/v1/dsfa?tenant_id=default', {
|
||||
@@ -415,29 +84,22 @@ export default function DSFAPage() {
|
||||
await loadDSFAs()
|
||||
}, [loadDSFAs])
|
||||
|
||||
// Handle uploaded document
|
||||
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
|
||||
console.log('[DSFA Page] Document processed:', doc)
|
||||
}, [])
|
||||
|
||||
// Open document in workflow editor
|
||||
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
|
||||
router.push(`/sdk/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
|
||||
}, [router])
|
||||
|
||||
const filteredDSFAs = filter === 'all'
|
||||
? dsfas
|
||||
: dsfas.filter(d => d.status === filter)
|
||||
|
||||
const filteredDSFAs = filter === 'all' ? dsfas : dsfas.filter(d => d.status === filter)
|
||||
const draftCount = dsfas.filter(d => d.status === 'draft').length
|
||||
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
|
||||
const approvedCount = dsfas.filter(d => d.status === 'approved').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['dsfa']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="dsfa"
|
||||
title={stepInfo.title}
|
||||
@@ -458,7 +120,6 @@ export default function DSFAPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Generator */}
|
||||
{showGenerator && (
|
||||
<GeneratorWizard
|
||||
onClose={() => setShowGenerator(false)}
|
||||
@@ -466,14 +127,12 @@ export default function DSFAPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Upload Section */}
|
||||
<DocumentUploadSection
|
||||
documentType="dsfa"
|
||||
onDocumentProcessed={handleDocumentProcessed}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
/>
|
||||
|
||||
{/* 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">Gesamt</div>
|
||||
@@ -493,7 +152,6 @@ export default function DSFAPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-red-700 text-sm">
|
||||
Fehler beim Laden: {error}
|
||||
@@ -501,7 +159,6 @@ export default function DSFAPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
|
||||
@@ -509,9 +166,7 @@ export default function DSFAPage() {
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
@@ -522,12 +177,10 @@ export default function DSFAPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="text-center py-12 text-gray-500">Lade DSFAs...</div>
|
||||
)}
|
||||
|
||||
{/* DSFA List */}
|
||||
{!isLoading && (
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
|
||||
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