Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 18s
CI / test-python-dsms-gateway (push) Successful in 16s
- Remove 5 unused UCCA routes (wizard, stats, dsb-pool) from Go main.go - Delete 64 deprecated Go handlers (DSGVO, Vendors, Incidents, Drafting) - Delete legacy proxy routes (dsgvo, vendors) - Add LLM Audit Dashboard (3 tabs: Log, Nutzung, Compliance) with export - Add RBAC Admin UI (5 tabs: Mandanten, Namespaces, Rollen, Benutzer, LLM-Policies) - Add proxy routes for audit-llm and rbac to Go backend - Add Workshop, Portfolio, Roadmap proxy routes and frontends - Add LLM Audit + RBAC Admin to SDKSidebar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
562 lines
24 KiB
TypeScript
562 lines
24 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
|
|
// =============================================================================
|
|
// 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
|
|
// =============================================================================
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'llm-log') loadLLMLog()
|
|
else if (activeTab === 'usage') loadUsage()
|
|
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 => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === tab.id
|
|
? 'bg-white text-purple-700 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={period}
|
|
onChange={e => setPeriod(e.target.value)}
|
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
<option value="7d">Letzte 7 Tage</option>
|
|
<option value="30d">Letzte 30 Tage</option>
|
|
<option value="90d">Letzte 90 Tage</option>
|
|
</select>
|
|
<button
|
|
onClick={() => {
|
|
if (activeTab === 'llm-log') handleExport('llm', 'csv')
|
|
else if (activeTab === 'compliance') handleExport('compliance', 'json')
|
|
else handleExport('general', 'csv')
|
|
}}
|
|
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
|
>
|
|
Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="w-8 h-8 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin" />
|
|
</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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
{!loading && activeTab === 'compliance' && !complianceReport && !error && (
|
|
<div className="text-center py-12 text-gray-400">Kein Compliance-Report verfuegbar</div>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|