import { useState, useEffect, useCallback } from 'react'; const SERVICES = [ { name: 'Ollama', url: 'http://localhost:11434', healthPath: '/api/tags', uiUrl: null, description: 'Local LLM runtime', category: 'AI & LLM', restart: { type: 'launchd', id: 'gui/501/com.homeai.ollama' }, }, { name: 'Open WebUI', url: 'http://localhost:3030', healthPath: '/', uiUrl: 'http://localhost:3030', description: 'Chat interface', category: 'AI & LLM', restart: { type: 'docker', id: 'homeai-open-webui' }, }, { name: 'OpenClaw Gateway', url: 'http://localhost:8080', healthPath: '/', uiUrl: null, description: 'Agent gateway', category: 'Agent', restart: { type: 'launchd', id: 'gui/501/com.homeai.openclaw' }, }, { name: 'OpenClaw Bridge', url: 'http://localhost:8081', healthPath: '/', uiUrl: null, description: 'HTTP-to-CLI bridge', category: 'Agent', restart: { type: 'launchd', id: 'gui/501/com.homeai.openclaw-bridge' }, }, { name: 'Wyoming STT', url: 'http://localhost:10300', healthPath: '/', uiUrl: null, description: 'Whisper speech-to-text', category: 'Voice', tcp: true, restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-stt' }, }, { name: 'Wyoming TTS', url: 'http://localhost:10301', healthPath: '/', uiUrl: null, description: 'Kokoro text-to-speech', category: 'Voice', tcp: true, restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-tts' }, }, { name: 'Wyoming Satellite', url: 'http://localhost:10700', healthPath: '/', uiUrl: null, description: 'Mac Mini mic/speaker satellite', category: 'Voice', tcp: true, restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-satellite' }, }, { name: 'Character Dashboard', url: 'http://localhost:5173', healthPath: '/', uiUrl: 'http://localhost:5173', description: 'Character manager & service status', category: 'Agent', restart: { type: 'launchd', id: 'gui/501/com.homeai.character-dashboard' }, }, { name: 'Home Assistant', url: 'https://10.0.0.199:8123', healthPath: '/api/', uiUrl: 'https://10.0.0.199:8123', description: 'Smart home platform', category: 'Smart Home', }, { name: 'Uptime Kuma', url: 'http://localhost:3001', healthPath: '/', uiUrl: 'http://localhost:3001', description: 'Service health monitoring', category: 'Infrastructure', restart: { type: 'docker', id: 'homeai-uptime-kuma' }, }, { name: 'n8n', url: 'http://localhost:5678', healthPath: '/', uiUrl: 'http://localhost:5678', description: 'Workflow automation', category: 'Infrastructure', restart: { type: 'docker', id: 'homeai-n8n' }, }, { name: 'code-server', url: 'http://localhost:8090', healthPath: '/', uiUrl: 'http://localhost:8090', description: 'Browser-based VS Code', category: 'Infrastructure', restart: { type: 'docker', id: 'homeai-code-server' }, }, { name: 'Portainer', url: 'https://10.0.0.199:9443', healthPath: '/', uiUrl: 'https://10.0.0.199:9443', description: 'Docker management', category: 'Infrastructure', }, { name: 'Gitea', url: 'http://10.0.0.199:3000', healthPath: '/', uiUrl: 'http://10.0.0.199:3000', description: 'Self-hosted Git', category: 'Infrastructure', }, ]; const CATEGORY_ICONS = { 'AI & LLM': ( ), 'Agent': ( ), 'Voice': ( ), 'Smart Home': ( ), 'Infrastructure': ( ), }; function StatusDot({ status }) { const colors = { online: 'bg-emerald-400 shadow-emerald-400/50', offline: 'bg-red-400 shadow-red-400/50', checking: 'bg-amber-400 shadow-amber-400/50 animate-pulse', unknown: 'bg-gray-500', }; return ( ); } export default function ServiceStatus() { const [statuses, setStatuses] = useState(() => Object.fromEntries(SERVICES.map(s => [s.name, { status: 'checking', lastCheck: null, responseTime: null }])) ); const [lastRefresh, setLastRefresh] = useState(null); const [restarting, setRestarting] = useState({}); const checkService = useCallback(async (service) => { try { // Route all checks through the server-side proxy to avoid CORS and // self-signed SSL cert issues in the browser. const target = encodeURIComponent(service.url + service.healthPath); const modeParam = service.tcp ? '&mode=tcp' : ''; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const res = await fetch(`/api/health?url=${target}${modeParam}`, { signal: controller.signal }); clearTimeout(timeout); const data = await res.json(); return { status: data.status, lastCheck: new Date(), responseTime: data.responseTime }; } catch { return { status: 'offline', lastCheck: new Date(), responseTime: null }; } }, []); const refreshAll = useCallback(async () => { // Mark all as checking setStatuses(prev => Object.fromEntries(Object.entries(prev).map(([k, v]) => [k, { ...v, status: 'checking' }])) ); const results = await Promise.allSettled( SERVICES.map(async (service) => { const result = await checkService(service); return { name: service.name, ...result }; }) ); const newStatuses = {}; for (const r of results) { if (r.status === 'fulfilled') { newStatuses[r.value.name] = { status: r.value.status, lastCheck: r.value.lastCheck, responseTime: r.value.responseTime, }; } } setStatuses(prev => ({ ...prev, ...newStatuses })); setLastRefresh(new Date()); }, [checkService]); useEffect(() => { refreshAll(); const interval = setInterval(refreshAll, 30000); return () => clearInterval(interval); }, [refreshAll]); const restartService = useCallback(async (service) => { if (!service.restart) return; setRestarting(prev => ({ ...prev, [service.name]: true })); try { const res = await fetch('/api/service/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(service.restart), }); const data = await res.json(); if (!data.ok) { console.error(`Restart failed for ${service.name}:`, data.error); } // Wait a moment for the service to come back, then re-check setTimeout(async () => { const result = await checkService(service); setStatuses(prev => ({ ...prev, [service.name]: result })); setRestarting(prev => ({ ...prev, [service.name]: false })); }, 3000); } catch (err) { console.error(`Restart failed for ${service.name}:`, err); setRestarting(prev => ({ ...prev, [service.name]: false })); } }, [checkService]); const categories = [...new Set(SERVICES.map(s => s.category))]; const onlineCount = Object.values(statuses).filter(s => s.status === 'online').length; const offlineCount = Object.values(statuses).filter(s => s.status === 'offline').length; const totalCount = SERVICES.length; const allOnline = onlineCount === totalCount; return (
{/* Header */}

Service Status

{onlineCount}/{totalCount} services online {lastRefresh && ( Last check: {lastRefresh.toLocaleTimeString()} )}

{/* Summary bar */}
{allOnline ? (
) : ( <>
)}
{/* Service grid by category */} {categories.map(category => (
{CATEGORY_ICONS[category]}

{category}

{SERVICES.filter(s => s.category === category).map(service => { const st = statuses[service.name] || { status: 'unknown' }; return (

{service.name}

{service.description}

{st.responseTime !== null && (

{st.responseTime}ms

)}
{service.restart && st.status === 'offline' && ( )} {service.uiUrl && ( Open )}
); })}
))}
); }