feat(health): expose ollama provider chain
All checks were successful
CD Pipeline / tests (push) Successful in 6m8s
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / build-and-deploy (push) Successful in 4m38s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s

This commit is contained in:
Your Name
2026-05-24 11:44:37 +08:00
parent 06dfdf7ead
commit 9bac5718da
6 changed files with 259 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
'use client'
/**
* AIModelStatus — AI 模型狀態 2×2 網格
* AIModelStatus — AI provider route health grid
* Sprint 5R S9: 設計稿 L531-545
* @created 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
@@ -13,33 +13,66 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface ModelInfo {
name: string
tag: string
healthy: boolean
role: 'primary' | 'backup' | 'local' | 'agent' | 'provider'
status: 'up' | 'down' | 'degraded' | 'unknown'
latencyMs?: number | null
}
interface HealthComponent {
status?: 'up' | 'down' | 'degraded'
latency_ms?: number | null
}
interface HealthResponse {
components?: Record<string, HealthComponent>
ollama_route_order?: string[]
}
const PROVIDER_LABELS: Record<string, string> = {
ollama_gcp_a: 'GCP-A',
ollama_gcp_b: 'GCP-B',
ollama_local: '111',
openclaw: 'OpenClaw',
}
const PROVIDER_ROLES: Record<string, ModelInfo['role']> = {
ollama_gcp_a: 'primary',
ollama_gcp_b: 'backup',
ollama_local: 'local',
openclaw: 'agent',
}
function statusColor(status: ModelInfo['status']) {
if (status === 'up') return '#22C55E'
if (status === 'degraded') return '#F59E0B'
if (status === 'down') return '#cc2200'
return '#87867f'
}
export function AIModelStatus() {
const t = useTranslations('dashboard')
const [models, setModels] = useState<ModelInfo[]>([
{ name: 'OpenClaw Nemo', tag: 'local', healthy: false },
{ name: 'Ollama gemma3', tag: 'local', healthy: false },
{ name: 'Gemini Pro', tag: 'cloud', healthy: false },
{ name: 'NVIDIA NIM', tag: 'cloud', healthy: false },
{ name: 'GCP-A', role: 'primary', status: 'unknown' },
{ name: 'GCP-B', role: 'backup', status: 'unknown' },
{ name: '111', role: 'local', status: 'unknown' },
{ name: 'OpenClaw', role: 'agent', status: 'unknown' },
])
useEffect(() => {
fetch(`${API_BASE}/api/v1/health`)
.then(r => r.ok ? r.json() : null)
.then(d => {
.then((d: HealthResponse | null) => {
if (!d?.components) return
setModels(prev => prev.map(m => {
if (m.name.includes('OpenClaw') && d.components.openclaw) return { ...m, healthy: d.components.openclaw.status === 'up' }
if (m.name.includes('Ollama') && d.components.ollama) return { ...m, healthy: d.components.ollama.status === 'up' }
// 2026-04-09 Claude Sonnet 4.6: 移除假數據 — /api/v1/health 無 gemini/nvidia component
// cloud 模型狀態未知,保持 false不顯示假綠燈
if (m.name.includes('Gemini') && d.components.gemini) return { ...m, healthy: d.components.gemini.status === 'up' }
if (m.name.includes('NVIDIA') && d.components.nvidia) return { ...m, healthy: d.components.nvidia.status === 'up' }
return m
}))
const routeOrder = d.ollama_route_order?.length
? d.ollama_route_order
: ['ollama_gcp_a', 'ollama_gcp_b', 'ollama_local']
const providerKeys = [...routeOrder, 'openclaw']
setModels(providerKeys.map(key => ({
name: PROVIDER_LABELS[key] ?? key,
role: PROVIDER_ROLES[key] ?? 'provider',
status: d.components?.[key]?.status ?? 'unknown',
latencyMs: d.components?.[key]?.latency_ms,
})))
})
.catch(() => {})
}, [])
@@ -62,9 +95,13 @@ export function AIModelStatus() {
border: '0.5px solid #e0ddd4', borderRadius: 6, padding: '6px 8px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: m.healthy ? '#22C55E' : '#cc2200', flexShrink: 0 }} />
<span style={{ width: 5, height: 5, borderRadius: '50%', background: statusColor(m.status), flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 500, color: '#141413' }}>{m.name}</span>
<span style={{ fontSize: 10, color: '#87867f', marginLeft: 'auto' }}>{m.tag}</span>
<span style={{ fontSize: 10, color: '#87867f', marginLeft: 'auto' }}>
{typeof m.latencyMs === 'number'
? `${Math.round(m.latencyMs)}ms`
: t(`aiModelRoles.${m.role}` as never)}
</span>
</div>
))}
</div>