Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
7.5 KiB
TypeScript
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>
|
|
)
|
|
}
|