- Replace faster-whisper with wyoming-mlx-whisper (whisper-large-v3-turbo, MLX Metal GPU) STT latency: 8.4s → 400ms for short voice commands - Add Qwen3.5-35B-A3B (MoE, 3B active params, Q8_0) to Ollama — 26.7 tok/s vs 5.4 tok/s (70B) - Add model preload launchd service to pin voice model in VRAM permanently - Fix HA tool calling: set commands.native=true, symlink ha-ctl to PATH - Add pipeline benchmark script (STT/LLM/TTS latency profiling) - Add service restart buttons and STT endpoint to dashboard - Bind Vite dev server to 0.0.0.0 for LAN access Total estimated pipeline latency: ~27s → ~4s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
390 lines
16 KiB
JavaScript
390 lines
16 KiB
JavaScript
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': (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
|
</svg>
|
|
),
|
|
'Agent': (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z" />
|
|
</svg>
|
|
),
|
|
'Voice': (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
|
</svg>
|
|
),
|
|
'Smart Home': (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
|
</svg>
|
|
),
|
|
'Infrastructure': (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
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 (
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full shadow-lg ${colors[status] || colors.unknown}`} />
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-100">Service Status</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
{onlineCount}/{totalCount} services online
|
|
{lastRefresh && (
|
|
<span className="ml-3">
|
|
Last check: {lastRefresh.toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={refreshAll}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Summary bar */}
|
|
<div className="h-2 rounded-full bg-gray-800 overflow-hidden flex">
|
|
{allOnline ? (
|
|
<div
|
|
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 transition-all duration-500"
|
|
style={{ width: '100%' }}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 transition-all duration-500"
|
|
style={{ width: `${(onlineCount / totalCount) * 100}%` }}
|
|
/>
|
|
<div
|
|
className="h-full bg-gradient-to-r from-red-500 to-red-400 transition-all duration-500"
|
|
style={{ width: `${(offlineCount / totalCount) * 100}%` }}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Service grid by category */}
|
|
{categories.map(category => (
|
|
<div key={category}>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<span className="text-gray-400">{CATEGORY_ICONS[category]}</span>
|
|
<h2 className="text-lg font-semibold text-gray-300">{category}</h2>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{SERVICES.filter(s => s.category === category).map(service => {
|
|
const st = statuses[service.name] || { status: 'unknown' };
|
|
return (
|
|
<div
|
|
key={service.name}
|
|
className={`relative rounded-xl border p-4 transition-all duration-200 ${
|
|
st.status === 'online'
|
|
? 'bg-gray-800/50 border-gray-700 hover:border-emerald-500/50'
|
|
: st.status === 'offline'
|
|
? 'bg-gray-800/50 border-red-500/30 hover:border-red-500/50'
|
|
: 'bg-gray-800/50 border-gray-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<StatusDot status={st.status} />
|
|
<h3 className="font-medium text-gray-200">{service.name}</h3>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">{service.description}</p>
|
|
{st.responseTime !== null && (
|
|
<p className="text-xs text-gray-600 mt-0.5">{st.responseTime}ms</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{service.restart && st.status === 'offline' && (
|
|
<button
|
|
onClick={() => restartService(service)}
|
|
disabled={restarting[service.name]}
|
|
className="text-xs px-2.5 py-1 rounded-md bg-amber-600/80 hover:bg-amber-500 disabled:bg-gray-700 disabled:text-gray-500 text-white transition-colors flex items-center gap-1"
|
|
>
|
|
{restarting[service.name] ? (
|
|
<>
|
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Restarting
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 9v3m0 0v3m0-3h3m-3 0H9" />
|
|
</svg>
|
|
Restart
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
{service.uiUrl && (
|
|
<a
|
|
href={service.uiUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs px-2.5 py-1 rounded-md bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors flex items-center gap-1"
|
|
>
|
|
Open
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
</svg>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|