This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/common/ServiceStatus.tsx
BreakPilot Dev 660295e218 fix(admin-v2): Restore complete admin-v2 application
The admin-v2 application was incomplete in the repository. This commit
restores all missing components:

- Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education,
  infrastructure, communication, development, onboarding, rbac
- SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen,
  vendor-compliance, tom-generator, dsr, and more
- Developer portal (25 pages): API docs, SDK guides, frameworks
- All components, lib files, hooks, and types
- Updated package.json with all dependencies

The issue was caused by incomplete initial repository state - the full
admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2
but was never fully synced to the main admin-v2 directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 23:40:15 -08:00

204 lines
7.5 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback } from 'react'
interface ServiceHealth {
name: string
port: number
status: 'online' | 'offline' | 'checking' | 'degraded'
responseTime?: number
details?: string
category: 'core' | 'ai' | 'database' | 'storage'
}
// Initial services list for loading state
const INITIAL_SERVICES: Omit<ServiceHealth, 'status' | 'responseTime' | 'details'>[] = [
{ name: 'Backend API', port: 8000, category: 'core' },
{ name: 'Consent Service', port: 8081, category: 'core' },
{ name: 'Voice Service', port: 8091, category: 'core' },
{ name: 'Klausur Service', port: 8086, category: 'core' },
{ name: 'Mail Service (Mailpit)', port: 8025, category: 'core' },
{ name: 'Edu Search', port: 8088, category: 'core' },
{ name: 'H5P Service', port: 8092, category: 'core' },
{ name: 'Ollama/LLM', port: 11434, category: 'ai' },
{ name: 'Embedding Service', port: 8087, category: 'ai' },
{ name: 'PostgreSQL', port: 5432, category: 'database' },
{ name: 'Qdrant (Vector DB)', port: 6333, category: 'database' },
{ name: 'Valkey (Cache)', port: 6379, category: 'database' },
{ name: 'MinIO (S3)', port: 9000, category: 'storage' },
]
export function ServiceStatus() {
const [services, setServices] = useState<ServiceHealth[]>(
INITIAL_SERVICES.map(s => ({ ...s, status: 'checking' as const }))
)
const [lastChecked, setLastChecked] = useState<Date | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const checkServices = useCallback(async () => {
setIsRefreshing(true)
try {
// Use server-side API route to avoid mixed-content issues
const response = await fetch('/api/admin/health', {
method: 'GET',
signal: AbortSignal.timeout(15000),
})
if (response.ok) {
const data = await response.json()
setServices(data.services.map((s: ServiceHealth) => ({
...s,
status: s.status as 'online' | 'offline' | 'degraded'
})))
} else {
// If API fails, mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: 'Health-Check API nicht erreichbar'
})))
}
} catch (error) {
// Network error - mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: error instanceof Error ? error.message : 'Verbindungsfehler'
})))
}
setLastChecked(new Date())
setIsRefreshing(false)
}, [])
useEffect(() => {
checkServices()
// Auto-refresh every 30 seconds
const interval = setInterval(checkServices, 30000)
return () => clearInterval(interval)
}, [checkServices])
const getStatusColor = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'bg-green-500'
case 'offline': return 'bg-red-500'
case 'degraded': return 'bg-yellow-500'
case 'checking': return 'bg-slate-300 animate-pulse'
}
}
const getStatusText = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'Online'
case 'offline': return 'Offline'
case 'degraded': return 'Eingeschränkt'
case 'checking': return 'Prüfe...'
}
}
const getCategoryIcon = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return '⚙️'
case 'ai': return '🤖'
case 'database': return '🗄️'
case 'storage': return '📦'
}
}
const getCategoryLabel = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return 'Core Services'
case 'ai': return 'AI / LLM'
case 'database': return 'Datenbanken'
case 'storage': return 'Storage'
}
}
const groupedServices = services.reduce((acc, service) => {
if (!acc[service.category]) {
acc[service.category] = []
}
acc[service.category].push(service)
return acc
}, {} as Record<string, ServiceHealth[]>)
const onlineCount = services.filter(s => s.status === 'online').length
const totalCount = services.length
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">System Status</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${
onlineCount === totalCount
? 'bg-green-100 text-green-700'
: onlineCount > totalCount / 2
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{onlineCount}/{totalCount} online
</span>
</div>
<button
onClick={checkServices}
disabled={isRefreshing}
className="text-sm text-primary-600 hover:text-primary-700 disabled:opacity-50 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Aktualisieren
</button>
</div>
<div className="p-4 space-y-4">
{(['ai', 'core', 'database', 'storage'] as const).map(category => (
<div key={category}>
<div className="flex items-center gap-2 mb-2">
<span>{getCategoryIcon(category)}</span>
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
{getCategoryLabel(category)}
</span>
</div>
<div className="space-y-2">
{groupedServices[category]?.map((service) => (
<div key={service.name} className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getStatusColor(service.status)}`}></span>
<span className="text-sm text-slate-700">{service.name}</span>
<span className="text-xs text-slate-400">:{service.port}</span>
</div>
<div className="flex items-center gap-2">
{service.details && (
<span className="text-xs text-slate-500">{service.details}</span>
)}
{service.responseTime !== undefined && service.status === 'online' && (
<span className="text-xs text-slate-400">{service.responseTime}ms</span>
)}
<span className={`text-xs ${
service.status === 'online' ? 'text-green-600' :
service.status === 'offline' ? 'text-red-600' :
service.status === 'degraded' ? 'text-yellow-600' :
'text-slate-400'
}`}>
{getStatusText(service.status)}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
{lastChecked && (
<div className="px-4 py-2 border-t border-slate-100 text-xs text-slate-400">
Zuletzt geprüft: {lastChecked.toLocaleTimeString('de-DE')}
</div>
)}
</div>
)
}