267 lines
14 KiB
TypeScript
267 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
}
|