All checks were successful
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) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
513 lines
19 KiB
TypeScript
513 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
|
|
interface MonitoringEvent {
|
|
id: string
|
|
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
|
|
title: string
|
|
description: string
|
|
severity: 'low' | 'medium' | 'high' | 'critical'
|
|
status: 'open' | 'investigating' | 'resolved' | 'closed'
|
|
created_at: string
|
|
resolved_at: string | null
|
|
resolved_by: string | null
|
|
resolution_notes: string | null
|
|
}
|
|
|
|
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
|
|
incident: {
|
|
label: 'Vorfall',
|
|
color: 'text-red-700',
|
|
bgColor: 'bg-red-100',
|
|
icon: '🚨',
|
|
},
|
|
update: {
|
|
label: 'Update',
|
|
color: 'text-blue-700',
|
|
bgColor: 'bg-blue-100',
|
|
icon: '🔄',
|
|
},
|
|
drift_alert: {
|
|
label: 'Drift-Warnung',
|
|
color: 'text-orange-700',
|
|
bgColor: 'bg-orange-100',
|
|
icon: '📉',
|
|
},
|
|
regulation_change: {
|
|
label: 'Regulierungsaenderung',
|
|
color: 'text-purple-700',
|
|
bgColor: 'bg-purple-100',
|
|
icon: '📜',
|
|
},
|
|
}
|
|
|
|
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
|
|
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
|
|
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
|
|
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
|
|
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
|
|
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
|
|
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
|
|
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
|
|
}
|
|
|
|
function EventTypeBadge({ type }: { type: string }) {
|
|
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
|
{config.icon} {config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SeverityBadge({ severity }: { severity: string }) {
|
|
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
interface EventFormData {
|
|
event_type: string
|
|
title: string
|
|
description: string
|
|
severity: string
|
|
}
|
|
|
|
function EventForm({
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
onSubmit: (data: EventFormData) => void
|
|
onCancel: () => void
|
|
}) {
|
|
const [formData, setFormData] = useState<EventFormData>({
|
|
event_type: 'incident',
|
|
title: '',
|
|
description: '',
|
|
severity: 'medium',
|
|
})
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Monitoring-Ereignis</h3>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
placeholder="z.B. KI-Modell Drift erkannt"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
|
<select
|
|
value={formData.event_type}
|
|
onChange={(e) => setFormData({ ...formData, event_type: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="incident">Vorfall</option>
|
|
<option value="update">Update</option>
|
|
<option value="drift_alert">Drift-Warnung</option>
|
|
<option value="regulation_change">Regulierungsaenderung</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
|
|
<select
|
|
value={formData.severity}
|
|
onChange={(e) => setFormData({ ...formData, severity: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="low">Niedrig</option>
|
|
<option value="medium">Mittel</option>
|
|
<option value="high">Hoch</option>
|
|
<option value="critical">Kritisch</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
rows={3}
|
|
placeholder="Beschreiben Sie das Ereignis..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
onClick={() => onSubmit(formData)}
|
|
disabled={!formData.title}
|
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
formData.title
|
|
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
Ereignis erfassen
|
|
</button>
|
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResolveModal({
|
|
event,
|
|
onSubmit,
|
|
onClose,
|
|
}: {
|
|
event: MonitoringEvent
|
|
onSubmit: (id: string, notes: string) => void
|
|
onClose: () => void
|
|
}) {
|
|
const [notes, setNotes] = useState('')
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Ereignis loesen: {event.title}
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Loesung / Massnahmen
|
|
</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={4}
|
|
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 flex items-center gap-3">
|
|
<button
|
|
onClick={() => onSubmit(event.id, notes)}
|
|
disabled={!notes}
|
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
notes
|
|
? 'bg-green-600 text-white hover:bg-green-700'
|
|
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
Als geloest markieren
|
|
</button>
|
|
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TimelineEvent({
|
|
event,
|
|
onResolve,
|
|
}: {
|
|
event: MonitoringEvent
|
|
onResolve: (event: MonitoringEvent) => void
|
|
}) {
|
|
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
|
|
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
|
|
|
|
return (
|
|
<div className="relative flex gap-4 pb-8 last:pb-0">
|
|
{/* Timeline line */}
|
|
<div className="flex flex-col items-center">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
|
|
{typeConfig.icon}
|
|
</div>
|
|
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<EventTypeBadge type={event.event_type} />
|
|
<SeverityBadge severity={event.severity} />
|
|
<StatusBadge status={event.status} />
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
|
{new Date(event.created_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
{event.description && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
|
|
)}
|
|
|
|
{event.resolution_notes && (
|
|
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
|
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
|
|
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
|
|
{event.resolved_at && (
|
|
<div className="text-xs text-green-600 mt-1">
|
|
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(event.status === 'open' || event.status === 'investigating') && (
|
|
<div className="mt-3">
|
|
<button
|
|
onClick={() => onResolve(event)}
|
|
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
Loesen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function MonitoringPage() {
|
|
const params = useParams()
|
|
const projectId = params.projectId as string
|
|
const [events, setEvents] = useState<MonitoringEvent[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
|
|
const [filterType, setFilterType] = useState('')
|
|
const [filterStatus, setFilterStatus] = useState('')
|
|
|
|
useEffect(() => {
|
|
fetchEvents()
|
|
}, [projectId])
|
|
|
|
async function fetchEvents() {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setEvents(json.events || json || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch monitoring events:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(data: EventFormData) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
})
|
|
if (res.ok) {
|
|
setShowForm(false)
|
|
await fetchEvents()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to add event:', err)
|
|
}
|
|
}
|
|
|
|
async function handleResolve(id: string, notes: string) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ resolution_notes: notes }),
|
|
})
|
|
if (res.ok) {
|
|
setResolvingEvent(null)
|
|
await fetchEvents()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to resolve event:', err)
|
|
}
|
|
}
|
|
|
|
const filteredEvents = events.filter((e) => {
|
|
const matchType = !filterType || e.event_type === filterType
|
|
const matchStatus = !filterStatus || e.status === filterStatus
|
|
return matchType && matchStatus
|
|
})
|
|
|
|
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
|
|
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Monitoring</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Post-Market Surveillance -- Ueberwachung von Vorfaellen, Updates, Drift und Regulierungsaenderungen.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowForm(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Ereignis erfassen
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
{events.length > 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">{events.length}</div>
|
|
<div className="text-xs text-gray-500">Gesamt</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
|
<div className="text-2xl font-bold text-red-600">{openCount}</div>
|
|
<div className="text-xs text-red-600">Offen</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
|
<div className="text-2xl font-bold text-green-600">{resolvedCount}</div>
|
|
<div className="text-xs text-green-600">Geloest</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
|
|
<div className="text-2xl font-bold text-orange-600">
|
|
{events.filter((e) => e.severity === 'critical' || e.severity === 'high').length}
|
|
</div>
|
|
<div className="text-xs text-orange-600">Hoch/Kritisch</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
{events.length > 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={filterType}
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="">Alle Typen</option>
|
|
<option value="incident">Vorfaelle</option>
|
|
<option value="update">Updates</option>
|
|
<option value="drift_alert">Drift-Warnungen</option>
|
|
<option value="regulation_change">Regulierungsaenderungen</option>
|
|
</select>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="">Alle Status</option>
|
|
<option value="open">Offen</option>
|
|
<option value="investigating">In Untersuchung</option>
|
|
<option value="resolved">Geloest</option>
|
|
<option value="closed">Geschlossen</option>
|
|
</select>
|
|
<span className="text-sm text-gray-500">
|
|
{filteredEvents.length} Ereignisse
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form */}
|
|
{showForm && (
|
|
<EventForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
|
)}
|
|
|
|
{/* Resolve Modal */}
|
|
{resolvingEvent && (
|
|
<ResolveModal
|
|
event={resolvingEvent}
|
|
onSubmit={handleResolve}
|
|
onClose={() => setResolvingEvent(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Timeline */}
|
|
{filteredEvents.length > 0 ? (
|
|
<div className="pl-1">
|
|
{filteredEvents
|
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
.map((event) => (
|
|
<TimelineEvent
|
|
key={event.id}
|
|
event={event}
|
|
onResolve={() => setResolvingEvent(event)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
!showForm && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Monitoring-Ereignisse</h3>
|
|
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
|
Erfassen Sie Vorfaelle, Software-Updates, KI-Drift-Warnungen und Regulierungsaenderungen
|
|
im Rahmen der Post-Market Surveillance.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowForm(true)}
|
|
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
Erstes Ereignis erfassen
|
|
</button>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
)
|
|
}
|