Files
awoooi/apps/web/src/components/neural-command/NeuralLiveCenter.tsx
Your Name 6bf98ed00e
All checks were successful
CD Pipeline / tests (push) Successful in 1m35s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 5m44s
CD Pipeline / post-deploy-checks (push) Successful in 2m36s
fix(web): standardize UI icon language
2026-06-03 00:18:23 +08:00

267 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* NeuralLiveCenter - 即時指揮中心
* =================================
* 三欄: OpenClaw/NemoTron 狀態 | 神經傳導鏈路 | 執行串流
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 移除所有假數據,接上真實 props
* 統帥鐵律: 禁止假數據!無資料時顯示 '--' 或 EmptyState
*/
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Bot, CheckCircle2, Clock, Cpu, Inbox, Loader2, Server, Settings, XCircle } from 'lucide-react'
import type { AutoRepairStats, RepairHistoryItem, UriScheme, RepairStatus, ActiveIncident } from './types'
interface Props {
stats: AutoRepairStats | null
history: RepairHistoryItem[]
pendingCount: number
activeIncidents?: ActiveIncident[]
}
// URI scheme display config
const SCHEME_CONFIG = {
'kubectl://': { label: 'kubectl://', color: 'text-blue-500', bg: 'bg-blue-500/10', border: 'border-blue-500/25' },
'openclaw://': { label: 'openclaw://', color: 'text-orange-500', bg: 'bg-orange-500/10', border: 'border-orange-500/25' },
'ansible://': { label: 'ansible://', color: 'text-purple-500', bg: 'bg-purple-500/10', border: 'border-purple-500/25' },
}
const STATUS_CONFIG = {
success: { Icon: CheckCircle2, color: 'text-green-500', label: '成功' },
failed: { Icon: XCircle, color: 'text-red-500', label: '失敗' },
pending_approval: { Icon: Clock, color: 'text-orange-500', label: '待授權' },
running: { Icon: Loader2, color: 'text-blue-500', label: '執行中' },
}
const NODE_STATE_STYLES = {
done: 'border-green-500/40 bg-green-500/5 opacity-70',
active: 'border-blue-500 bg-blue-500/5 shadow-[0_0_0_3px_rgba(59,130,246,0.1)]',
waiting: 'border-border opacity-40',
}
const SEVERITY_DOT_STYLES: Record<string, string> = {
P0: 'bg-red-500 ring-red-500/20',
P1: 'bg-orange-500 ring-orange-500/20',
P2: 'bg-yellow-500 ring-yellow-500/20',
P3: 'bg-green-500 ring-green-500/20',
}
// 從最新一筆 history 衍生鏈路狀態
function deriveChainNodes(latestItem: RepairHistoryItem | null, t: ReturnType<typeof useTranslations>) {
if (!latestItem) {
return [
{ id: 'alert', label: t('chainAlert'), sub: t('chainIdleSub'), state: 'waiting' as const },
{ id: 'rag', label: t('chainRAG'), sub: '--', state: 'waiting' as const },
{ id: 'decide', label: t('chainDecide'), sub: '--', state: 'waiting' as const },
{ id: 'exec', label: t('chainExec'), sub: '--', state: 'waiting' as const },
]
}
const isRunning = latestItem.status === 'running'
const isPending = latestItem.status === 'pending_approval'
const isDone = latestItem.status === 'success' || latestItem.status === 'failed'
return [
{ id: 'alert', label: t('chainAlert'), sub: `${latestItem.incident_id} · ${latestItem.playbook_name}`, state: 'done' as const },
{ id: 'rag', label: t('chainRAG'), sub: latestItem.rag_confidence ? `${t('ragConf')} ${latestItem.rag_confidence.toFixed(2)}` : '--', state: 'done' as const },
{ id: 'decide', label: t('chainDecide'), sub: isPending ? t('waitingApproval') : (isDone ? t('nodeDone') : t('nodeActive')), state: (isPending || isDone) ? 'done' as const : 'active' as const },
{ id: 'exec', label: t('chainExec'), sub: latestItem.command, state: isRunning ? 'active' as const : (isDone ? 'done' as const : 'waiting' as const) },
]
}
// 從 history 計算平均時長
function computeAvgDuration(history: RepairHistoryItem[]): string {
const durations = history.filter(h => h.duration_ms != null).map(h => h.duration_ms!)
if (durations.length === 0) return '--'
const avg = durations.reduce((a, b) => a + b, 0) / durations.length
return `${(avg / 1000).toFixed(1)}s`
}
function severityDotClass(severity: string): string {
return SEVERITY_DOT_STYLES[severity] ?? SEVERITY_DOT_STYLES.P3
}
export function NeuralLiveCenter({ stats, history, pendingCount, activeIncidents = [] }: Props) {
const t = useTranslations('neuralCommand')
const latestItem = history.length > 0 ? history[0] : null
const chainNodes = deriveChainNodes(latestItem, t)
// 從 stats 衍生 KPI無資料顯示 '--'
const successCount = stats ? Math.round(stats.total_executions * stats.overall_success_rate) : null
const totalExec = stats?.total_executions ?? null
return (
<div className="p-4 h-full">
<div className="grid grid-cols-[220px_1fr_260px] gap-3 h-full min-h-[520px]">
{/* ── Left: Agents + Alert radar ── */}
<div className="flex flex-col gap-3">
{/* OpenClaw */}
<div className="rounded-xl border border-orange-500/25 bg-orange-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-orange-500/15 flex items-center justify-center">
<Bot className="w-4 h-4 text-orange-500" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-bold text-orange-500">OpenClaw</p>
<p className="text-[10px] text-muted-foreground">{t('agentRoleOC')}</p>
</div>
<span className="ml-auto w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div>
<div className="space-y-1 text-[11px]">
<div className="flex justify-between"><span className="text-muted-foreground">Playbooks</span><span className="font-semibold">{stats?.approved_playbooks ?? '--'}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('todayMatches')}</span><span className="font-semibold">{history.length > 0 ? history.length : '--'}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('ragConf')}</span><span className="font-semibold">{latestItem?.rag_confidence?.toFixed(2) ?? '--'}</span></div>
</div>
</div>
{/* NemoTron */}
<div className="rounded-xl border border-blue-500/25 bg-blue-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/15 flex items-center justify-center">
<Cpu className="w-4 h-4 text-blue-500" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-bold text-blue-500">NemoTron</p>
<p className="text-[10px] text-muted-foreground">{t('agentRoleNemo')}</p>
</div>
<span className="ml-auto w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div>
<div className="space-y-1 text-[11px]">
<div className="flex justify-between"><span className="text-muted-foreground">{t('execSuccess')}</span><span className="font-semibold">{successCount != null && totalExec != null ? `${successCount}/${totalExec}` : '--'}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('avgDuration')}</span><span className="font-semibold">{computeAvgDuration(history)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('pendingApproval')}</span><span className="font-semibold text-orange-500">{pendingCount}</span></div>
</div>
</div>
{/* Alert radar — 從真實 incidents 或 history 衍生 */}
<div className="flex-1 rounded-xl border border-border bg-card p-3 overflow-hidden">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-2">{t('alertRadar')}</p>
<div className="space-y-1">
{activeIncidents.length > 0 ? (
activeIncidents.slice(0, 5).map((inc, i) => (
<div key={inc.incident_id} className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors',
i === 0 ? 'bg-orange-500/8 border border-orange-500/20' : 'hover:bg-muted',
)}>
<span
className={cn('h-2.5 w-2.5 rounded-full ring-4 flex-shrink-0', severityDotClass(inc.severity))}
aria-label={inc.severity}
title={inc.severity}
/>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-semibold truncate">{inc.incident_id}</p>
<p className="text-[10px] text-muted-foreground truncate">{inc.affected_services.join(', ') || inc.status}</p>
</div>
<span className="text-[10px] text-muted-foreground flex-shrink-0">{inc.severity}</span>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground">
<Inbox className="w-5 h-5 mb-1 opacity-40" />
<p className="text-[10px]">{t('noActiveAlerts')}</p>
</div>
)}
</div>
</div>
</div>
{/* ── Center: Chain visualization ── */}
<div className="rounded-xl border border-border bg-card p-4 flex flex-col">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-4">
{t('chainTitle')}{latestItem ? `${latestItem.incident_id} · ${latestItem.playbook_name}` : ''}
</p>
<div className="flex-1 flex flex-col items-center justify-center gap-0">
{chainNodes.map((node, i) => (
<div key={node.id} className="w-full max-w-sm flex flex-col items-center">
<div className={cn('w-full rounded-xl border p-3 relative', NODE_STATE_STYLES[node.state])}>
<p className="text-xs font-bold">{node.label}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{node.sub}</p>
<div className="absolute top-2.5 right-2.5">
{node.state === 'done' && <span className="text-[10px] font-bold text-green-500">{t('nodeDone')}</span>}
{node.state === 'active' && <span className="text-[10px] font-bold text-blue-500">{t('nodeActive')}</span>}
{node.state === 'waiting'&& <span className="text-[10px] font-bold text-muted-foreground">{t('nodeWaiting')}</span>}
</div>
</div>
{i < chainNodes.length - 1 && (
<div className="w-0.5 h-5 bg-gradient-to-b from-green-500/50 to-blue-500/50 rounded-full" />
)}
</div>
))}
{/* Branches */}
<div className="w-0.5 h-4 bg-muted-foreground/20 rounded-full" />
<div className="w-full max-w-sm grid grid-cols-3 gap-2">
{[
{ scheme: 'kubectl://', Icon: Server, label: 'kubectl', cls: 'border-blue-500/25 bg-blue-500/5', iconCls: 'text-blue-500' },
{ scheme: 'openclaw://', Icon: Bot, label: 'OpenClaw', cls: 'border-orange-500/25 bg-orange-500/5', iconCls: 'text-orange-500' },
{ scheme: 'ansible://', Icon: Settings, label: 'Ansible .188', cls: 'border-purple-500/25 bg-purple-500/5', iconCls: 'text-purple-500' },
].map(b => {
const BranchIcon = b.Icon
return (
<div key={b.scheme} className={cn('rounded-lg border p-2.5 text-center opacity-70', b.cls)}>
<BranchIcon className={cn('mx-auto h-4 w-4', b.iconCls)} aria-hidden="true" />
<p className="text-[10px] font-bold mt-1">{b.label}</p>
<p className="text-[9px] text-muted-foreground font-mono">{b.scheme}</p>
</div>
)
})}
</div>
</div>
</div>
{/* ── Right: Execution log stream ── */}
<div className="rounded-xl border border-border bg-card p-3 flex flex-col">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('execStream')}</p>
{history.length > 0 ? (
<div className="flex-1 overflow-y-auto space-y-0 divide-y divide-border">
{history.map(item => {
const scheme = SCHEME_CONFIG[item.uri_scheme as UriScheme] ?? SCHEME_CONFIG['kubectl://']
const statusCfg = STATUS_CONFIG[item.status as RepairStatus]
const StatusIcon = statusCfg.Icon
const elapsed = Math.round((Date.now() - new Date(item.executed_at).getTime()) / 60000)
return (
<div key={item.id} className="py-2.5">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-muted-foreground">
{elapsed}m
</span>
<span className={cn('text-[9px] font-bold px-1.5 py-0.5 rounded font-mono border', scheme.color, scheme.bg, scheme.border)}>
{scheme.label}
</span>
<StatusIcon className={cn('w-3 h-3 ml-auto', statusCfg.color, item.status === 'running' && 'animate-spin')} />
</div>
<p className="text-[11px] font-semibold leading-tight">{item.playbook_name}</p>
{item.duration_ms && (
<p className="text-[10px] text-green-500 mt-0.5">{(item.duration_ms / 1000).toFixed(1)}s</p>
)}
{item.error && (
<p className="text-[10px] text-red-400 mt-0.5 truncate">{item.error}</p>
)}
{item.status === 'pending_approval' && (
<p className="text-[10px] text-orange-500 mt-0.5">{t('waitingApproval')}</p>
)}
{item.rag_confidence && (
<p className="text-[10px] text-muted-foreground mt-0.5">RAG {item.rag_confidence.toFixed(2)}</p>
)}
</div>
)
})}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Inbox className="w-6 h-6 mb-2 opacity-40" />
<p className="text-xs">{t('noHistory')}</p>
</div>
)}
</div>
</div>
</div>
)
}