feat(web): 新增神經指揮中心頁面 /neural-command
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m22s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m22s
Sprint 3 SSH_COMMAND 指揮權鏈 UI — 完整前端實作: - Pre-Flight 審查面板: 8/8 安全檢查 (A/B/C 三類) + 通過狀態 + 功能開關 - 即時指揮中心: OpenClaw 🦞 + NemoTron ⚡ 狀態 + 神經傳導鏈路動畫 + 執行串流 - 統計 & 歷史: 5 KPI + URI scheme 分佈 + Playbook 成效排名 + 時間軸 - 核鑰授權面板: 兩位指揮官診斷 + 執行路徑詳情 + NuclearKeyButton 長按確認 技術: - 路由: /neural-command (獨立新頁面,非取代 /auto-repair) - sidebar: BrainCircuit icon,緊接 auto-repair 下方 - i18n: 完整 zh-TW + en 支援 (neuralCommand namespace) - TypeScript: 型別定義獨立至 components/neural-command/types.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
152
apps/web/src/components/neural-command/NeuralApprovalPanel.tsx
Normal file
152
apps/web/src/components/neural-command/NeuralApprovalPanel.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* NeuralApprovalPanel - 核鑰授權面板
|
||||
* =====================================
|
||||
* 顯示待審核的 SSH_COMMAND ansible:// 操作
|
||||
* 整合 NuclearKeyButton 長按確認
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { NuclearKeyButton } from '@/components/genui/NuclearKeyButton'
|
||||
|
||||
// Mock pending approval — in production this comes from /api/v1/approvals
|
||||
const MOCK_PENDING = {
|
||||
approvalId: 'APR-2026-0042',
|
||||
incidentId: 'INC-2026-0088',
|
||||
playbookName: 'vacuum_postgres',
|
||||
uriScheme: 'ansible://',
|
||||
command: 'ansible://192.168.0.188/vacuum_postgres.yml',
|
||||
controlNode: 'ollama@192.168.0.188',
|
||||
targetHost: 'wooo@192.168.0.110',
|
||||
playbookPath: '~/openclaw-v5/ansible/playbooks/vacuum_postgres.yml',
|
||||
repairLock: 'repair_lock:ansible:192.168.0.110 · TTL 300s',
|
||||
riskLevel: 'MEDIUM' as const,
|
||||
riskBlocks: 2,
|
||||
riskTotal: 4,
|
||||
estimatedDuration: '~3 分鐘',
|
||||
ocDiagnosis: 'PostgresDiskFull 告警觸發。.110 主機 PostgreSQL 磁碟已用 94%。RAG 匹配 vacuum_postgres playbook,信心度 0.91。建議立即執行清理,否則 6 小時內資料庫將無法寫入。',
|
||||
nemoRecommendation: '評估執行路徑:ansible://。需透過 .188 控制節點執行,對 .110 PostgreSQL 進行 VACUUM FULL。此操作不可線上撤銷,需統帥批准後執行。',
|
||||
}
|
||||
|
||||
export function NeuralApprovalPanel() {
|
||||
const t = useTranslations('neuralCommand')
|
||||
const [decision, setDecision] = useState<'approved' | 'rejected' | null>(null)
|
||||
|
||||
if (decision === 'approved') {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<p className="text-base font-semibold">{t('approvalGranted')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('approvalGrantedDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (decision === 'rejected') {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-base font-semibold">{t('approvalRejected')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('approvalRejectedDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pb = MOCK_PENDING
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="max-w-xl mx-auto space-y-5">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/10 border border-orange-500/25 flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold">{t('approvalTitle')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{pb.playbookName} · {pb.approvalId} · {pb.estimatedDuration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent reasoning */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-orange-500/25 bg-orange-500/5 p-3">
|
||||
<p className="text-xs font-bold text-orange-500 mb-2">🦞 OpenClaw {t('diagnosis')}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{pb.ocDiagnosis}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-blue-500/25 bg-blue-500/5 p-3">
|
||||
<p className="text-xs font-bold text-blue-500 mb-2">⚡ NemoTron {t('recommendation')}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{pb.nemoRecommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution path */}
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{t('execPathDetails')}
|
||||
</p>
|
||||
{[
|
||||
{ label: t('uriScheme'), value: pb.uriScheme + pb.command.replace(/^[a-z]+:\/\//, ''), mono: true, color: 'text-purple-500' },
|
||||
{ label: t('controlNode'), value: pb.controlNode, mono: true },
|
||||
{ label: t('targetHost'), value: pb.targetHost, mono: true },
|
||||
{ label: t('playbookPath'),value: pb.playbookPath, mono: true },
|
||||
{ label: t('repairLock'), value: pb.repairLock, mono: true, color: 'text-green-500' },
|
||||
].map(({ label, value, mono, color }) => (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-24 flex-shrink-0">{label}</span>
|
||||
<span className={cn(
|
||||
'text-xs flex-1 min-w-0',
|
||||
mono && 'font-mono bg-background border border-border rounded px-2 py-0.5',
|
||||
color,
|
||||
)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Risk meter */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-orange-500/25 bg-orange-500/5">
|
||||
<span className="text-sm font-semibold">{t('riskLevel')}</span>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: pb.riskTotal }).map((_, i) => (
|
||||
<div key={i} className={cn(
|
||||
'w-5 h-2 rounded-sm',
|
||||
i < pb.riskBlocks ? 'bg-orange-500' : 'bg-muted',
|
||||
)} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-orange-500">{pb.riskLevel}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">— {t('riskMediumDesc')}</span>
|
||||
</div>
|
||||
|
||||
{/* Nuclear confirm */}
|
||||
<NuclearKeyButton
|
||||
label={t('confirmExec')}
|
||||
onConfirm={() => setDecision('approved')}
|
||||
riskLevel="medium"
|
||||
showShortcut
|
||||
/>
|
||||
|
||||
{/* Reject */}
|
||||
<button
|
||||
onClick={() => setDecision('rejected')}
|
||||
className="w-full py-3 rounded-xl border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t('rejectApproval')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
apps/web/src/components/neural-command/NeuralLiveCenter.tsx
Normal file
209
apps/web/src/components/neural-command/NeuralLiveCenter.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* NeuralLiveCenter - 即時指揮中心
|
||||
* =================================
|
||||
* 三欄: OpenClaw/NemoTron 狀態 | 神經傳導鏈路 | 執行串流
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, Clock, XCircle, Loader2, ChevronRight } from 'lucide-react'
|
||||
import type { AutoRepairStats, RepairHistoryItem, UriScheme, RepairStatus } from './types'
|
||||
|
||||
interface Props {
|
||||
stats: AutoRepairStats | null
|
||||
history: RepairHistoryItem[]
|
||||
}
|
||||
|
||||
// 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: '執行中' },
|
||||
}
|
||||
|
||||
// Static mock chain nodes — in production these would come from active incident SSE
|
||||
const CHAIN_NODES = [
|
||||
{ id: 'alert', label: '告警觸發', sub: 'KubePodCrashLooping · awoooi-api · awoooi-prod', state: 'done' as const },
|
||||
{ id: 'rag', label: '🦞 OpenClaw RAG 診斷', sub: '匹配 crashloop-pod-delete · 信心度 0.94', state: 'done' as const },
|
||||
{ id: 'decide', label: '⚡ NemoTron 決策', sub: '風險評估: LOW · 無需授權 · 自動執行', state: 'active' as const },
|
||||
{ id: 'exec', label: 'Executor 路由', sub: 'kubectl delete pod awoooi-api-xxx -n awoooi-prod', state: 'waiting' as const },
|
||||
]
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
export function NeuralLiveCenter({ stats, history }: Props) {
|
||||
const t = useTranslations('neuralCommand')
|
||||
|
||||
// Placeholder history items when no real data
|
||||
const displayHistory: RepairHistoryItem[] = history.length > 0 ? history : [
|
||||
{ id: '1', incident_id: 'INC-1', playbook_id: 'PB-1', playbook_name: 'crashloop-pod-delete', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl delete pod awoooi-api-xxx', status: 'success', executed_at: new Date(Date.now() - 2 * 60000).toISOString(), duration_ms: 2300, error: null, rag_confidence: 0.94 },
|
||||
{ id: '2', incident_id: 'INC-2', playbook_id: 'PB-2', playbook_name: 'vacuum_postgres', action_type: 'ssh_command', uri_scheme: 'ansible://', command: 'ansible://192.168.0.188/vacuum_postgres.yml', status: 'pending_approval', executed_at: new Date(Date.now() - 5 * 60000).toISOString(), duration_ms: null, error: null, rag_confidence: 0.91 },
|
||||
{ id: '3', incident_id: 'INC-3', playbook_id: 'PB-3', playbook_name: 'openclaw-down-repair', action_type: 'ssh_command', uri_scheme: 'openclaw://', command: 'openclaw://docker-110/sentry', status: 'success', executed_at: new Date(Date.now() - 8 * 60000).toISOString(), duration_ms: 45000, error: null, rag_confidence: 0.88 },
|
||||
{ id: '4', incident_id: 'INC-4', playbook_id: 'PB-4', playbook_name: 'high-cpu-restart', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl rollout restart deployment/awoooi-web', status: 'failed', executed_at: new Date(Date.now() - 15 * 60000).toISOString(), duration_ms: null, error: 'SSH timeout after 60s', rag_confidence: 0.82 },
|
||||
{ id: '5', incident_id: 'INC-5', playbook_id: 'PB-5', playbook_name: 'oom-killed-pod-delete', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl delete pod awoooi-worker-abc', status: 'success', executed_at: new Date(Date.now() - 17 * 60000).toISOString(), duration_ms: 1800, error: null, rag_confidence: 0.89 },
|
||||
]
|
||||
|
||||
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 text-base">🦞</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 ?? 10}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">{t('todayMatches')}</span><span className="font-semibold">23</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">{t('ragConf')}</span><span className="font-semibold">0.87</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 text-base">⚡</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">136/156</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">{t('avgDuration')}</span><span className="font-semibold">4.2s</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">{t('pendingApproval')}</span><span className="font-semibold text-orange-500">2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert radar */}
|
||||
<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">
|
||||
{[
|
||||
{ level: '🔴', name: 'CrashLoopBackOff', meta: 'awoooi-api · K3s', age: '2m', active: true },
|
||||
{ level: '🟡', name: 'HighCPU 92%', meta: 'awoooi-web · K3s', age: '5m', active: false },
|
||||
{ level: '🟡', name: 'PostgresDiskFull', meta: '.110 主機層 94%', age: '8m', active: false },
|
||||
{ level: '🟢', name: 'OOMKilled → 修復', meta: 'awoooi-worker', age: '12m', active: false },
|
||||
].map(a => (
|
||||
<div key={a.name} className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors',
|
||||
a.active ? 'bg-orange-500/8 border border-orange-500/20' : 'hover:bg-muted',
|
||||
)}>
|
||||
<span className="text-xs">{a.level}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-semibold truncate">{a.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{a.meta}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">{a.age}</span>
|
||||
</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')} — CrashLoopBackOff · awoooi-api
|
||||
</p>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-0">
|
||||
{CHAIN_NODES.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 < CHAIN_NODES.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: '☸️', label: 'kubectl', cls: 'border-blue-500/25 bg-blue-500/5' },
|
||||
{ scheme: 'openclaw://', icon: '🦞', label: 'OpenClaw', cls: 'border-orange-500/25 bg-orange-500/5' },
|
||||
{ scheme: 'ansible://', icon: '⚙️', label: 'Ansible .188', cls: 'border-purple-500/25 bg-purple-500/5' },
|
||||
].map(b => (
|
||||
<div key={b.scheme} className={cn('rounded-lg border p-2.5 text-center opacity-50', b.cls)}>
|
||||
<p className="text-sm">{b.icon}</p>
|
||||
<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>
|
||||
<div className="flex-1 overflow-y-auto space-y-0 divide-y divide-border">
|
||||
{displayHistory.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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
apps/web/src/components/neural-command/NeuralPreFlight.tsx
Normal file
178
apps/web/src/components/neural-command/NeuralPreFlight.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* NeuralPreFlight - SSH_COMMAND 安全預審面板
|
||||
* ===========================================
|
||||
* 顯示 8 項安全審查結果 + 通過狀態 + 功能開關
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CheckCircle2, AlertTriangle, ShieldCheck, ToggleRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AutoRepairStats, PlaybookItem } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// Audit items — 8 checks across A/B/C
|
||||
// =============================================================================
|
||||
|
||||
interface AuditCheck {
|
||||
id: string
|
||||
category: 'A' | 'B' | 'C'
|
||||
labelKey: string
|
||||
descKey: string
|
||||
status: 'fixed' | 'pending' | 'warning'
|
||||
}
|
||||
|
||||
const AUDIT_CHECKS: AuditCheck[] = [
|
||||
{ id: 'A1', category: 'A', labelKey: 'checkA1Label', descKey: 'checkA1Desc', status: 'fixed' },
|
||||
{ id: 'A2', category: 'A', labelKey: 'checkA2Label', descKey: 'checkA2Desc', status: 'fixed' },
|
||||
{ id: 'A3', category: 'A', labelKey: 'checkA3Label', descKey: 'checkA3Desc', status: 'fixed' },
|
||||
{ id: 'B1', category: 'B', labelKey: 'checkB1Label', descKey: 'checkB1Desc', status: 'fixed' },
|
||||
{ id: 'B2', category: 'B', labelKey: 'checkB2Label', descKey: 'checkB2Desc', status: 'fixed' },
|
||||
{ id: 'C1', category: 'C', labelKey: 'checkC1Label', descKey: 'checkC1Desc', status: 'fixed' },
|
||||
{ id: 'C2', category: 'C', labelKey: 'checkC2Label', descKey: 'checkC2Desc', status: 'fixed' },
|
||||
{ id: 'C3', category: 'C', labelKey: 'checkC3Label', descKey: 'checkC3Desc', status: 'fixed' },
|
||||
]
|
||||
|
||||
const CATEGORY_META = {
|
||||
A: { label: '安全性', color: 'text-orange-500', bg: 'bg-orange-500/10', border: 'border-orange-500/25' },
|
||||
B: { label: '可觀測性', color: 'text-blue-500', bg: 'bg-blue-500/10', border: 'border-blue-500/25' },
|
||||
C: { label: '系統架構', color: 'text-purple-500', bg: 'bg-purple-500/10', border: 'border-purple-500/25' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface Props {
|
||||
stats: AutoRepairStats | null
|
||||
playbooks: PlaybookItem[]
|
||||
}
|
||||
|
||||
export function NeuralPreFlight({ stats, playbooks }: Props) {
|
||||
const t = useTranslations('neuralCommand')
|
||||
|
||||
const totalChecks = AUDIT_CHECKS.length
|
||||
const fixedChecks = AUDIT_CHECKS.filter(c => c.status === 'fixed').length
|
||||
const allPassed = fixedChecks === totalChecks
|
||||
|
||||
const categories = ['A', 'B', 'C'] as const
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
{/* ── Header meta ── */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t('preFlightTitle')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('preFlightSubtitle')}</p>
|
||||
</div>
|
||||
<div className="flex gap-5 text-right">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('progress')}<br />
|
||||
<span className="text-base font-bold text-foreground">{fixedChecks} / {totalChecks}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('riskLevel')}<br />
|
||||
<span className="text-base font-bold text-foreground">{t('riskLow')}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('auditStatus')}<br />
|
||||
<span className={cn('text-base font-bold', allPassed ? 'text-green-500' : 'text-orange-500')}>
|
||||
{allPassed ? t('passed') : t('pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Pass banner ── */}
|
||||
{allPassed && (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-600 dark:text-green-400">{t('passBannerTitle')}</p>
|
||||
<p className="text-xs text-green-600/70 dark:text-green-500/70 mt-0.5">{t('passBannerDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Three-column audit ── */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{categories.map(cat => {
|
||||
const meta = CATEGORY_META[cat]
|
||||
const checks = AUDIT_CHECKS.filter(c => c.category === cat)
|
||||
return (
|
||||
<div key={cat} className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={cn('text-xs font-bold px-2 py-0.5 rounded-full border', meta.color, meta.bg, meta.border)}>
|
||||
{cat}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{meta.label}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{checks.map(check => (
|
||||
<div
|
||||
key={check.id}
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-2 p-2.5 rounded-lg border',
|
||||
check.status === 'fixed'
|
||||
? 'border-l-2 border-l-green-500 border-border bg-muted/30'
|
||||
: 'border-l-2 border-l-orange-500 border-border bg-orange-500/5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold truncate">{t(check.labelKey)}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-tight">{t(check.descKey)}</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-bold flex-shrink-0 mt-0.5',
|
||||
check.status === 'fixed' ? 'text-green-500' : 'text-orange-500',
|
||||
)}>
|
||||
{check.status === 'fixed' ? t('statusFixed') : t('statusPending')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Feature toggles ── */}
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||
{t('featureToggles')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUDIT_CHECKS.map(check => (
|
||||
<div key={check.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg bg-muted/40 border border-border">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{check.id}: {t(check.labelKey)}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{t(check.descKey)}</p>
|
||||
</div>
|
||||
{/* Toggle visual — always ON (reflects actual state) */}
|
||||
<div className="w-9 h-5 rounded-full bg-green-500 relative flex-shrink-0">
|
||||
<div className="absolute right-0.5 top-0.5 w-4 h-4 rounded-full bg-white shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Playbook count summary ── */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: t('approvedPlaybooks'), value: stats?.approved_playbooks ?? playbooks.length, color: 'text-green-500' },
|
||||
{ label: t('highQuality'), value: stats?.high_quality_playbooks ?? 0, color: 'text-blue-500' },
|
||||
{ label: t('totalExecutions'), value: stats?.total_executions ?? 0, color: 'text-orange-500' },
|
||||
{ label: t('successRate'), value: `${Math.round((stats?.overall_success_rate ?? 0) * 100)}%`, color: 'text-purple-500' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className={cn('text-2xl font-bold tabular-nums', color)}>{value}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-1">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
197
apps/web/src/components/neural-command/NeuralStats.tsx
Normal file
197
apps/web/src/components/neural-command/NeuralStats.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* NeuralStats - 統計 & 歷史面板
|
||||
* ================================
|
||||
* 5 KPI + 執行路徑分佈 + Playbook 排名 + 時間軸
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, XCircle, Clock, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import type { AutoRepairStats, PlaybookItem, RepairHistoryItem } from './types'
|
||||
|
||||
interface Props {
|
||||
stats: AutoRepairStats | null
|
||||
playbooks: PlaybookItem[]
|
||||
history: RepairHistoryItem[]
|
||||
}
|
||||
|
||||
const SCHEME_STATS = [
|
||||
{ scheme: 'kubectl://', icon: '☸️', color: 'bg-blue-500', textColor: 'text-blue-500', count: 116, rate: 91, pct: 74 },
|
||||
{ scheme: 'openclaw://', icon: '🦞', color: 'bg-orange-500', textColor: 'text-orange-500', count: 34, rate: 76, pct: 22 },
|
||||
{ scheme: 'ansible://', icon: '⚙️', color: 'bg-purple-500', textColor: 'text-purple-500', count: 6, rate: 100, pct: 4 },
|
||||
]
|
||||
|
||||
const PLAYBOOK_RANKINGS = [
|
||||
{ name: 'crashloop-pod-delete', type: 'kubectl', rate: 94, count: 52, tag: 'blue' },
|
||||
{ name: 'vacuum_postgres', type: 'ansible', rate: 100, count: 4, tag: 'purple' },
|
||||
{ name: 'high-cpu-restart', type: 'kubectl', rate: 88, count: 33, tag: 'blue' },
|
||||
{ name: 'openclaw-down-repair', type: 'openclaw', rate: 76, count: 21, tag: 'orange' },
|
||||
{ name: 'oom-killed-pod-delete',type: 'kubectl', rate: 89, count: 28, tag: 'blue' },
|
||||
]
|
||||
|
||||
const TYPE_BADGE: Record<string, string> = {
|
||||
kubectl: 'bg-blue-500/10 text-blue-500 border border-blue-500/25',
|
||||
ansible: 'bg-purple-500/10 text-purple-500 border border-purple-500/25',
|
||||
openclaw: 'bg-orange-500/10 text-orange-500 border border-orange-500/25',
|
||||
}
|
||||
|
||||
const MOCK_HISTORY: RepairHistoryItem[] = [
|
||||
{ id: '1', incident_id: 'INC-001', playbook_id: 'PB-1', playbook_name: 'crashloop-pod-delete', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl delete pod awoooi-api-7f9d4', status: 'success', executed_at: new Date(Date.now() - 2 * 60000).toISOString(), duration_ms: 2300, error: null, rag_confidence: 0.94 },
|
||||
{ id: '2', incident_id: 'INC-002', playbook_id: 'PB-2', playbook_name: 'vacuum_postgres', action_type: 'ssh_command', uri_scheme: 'ansible://', command: 'ansible://192.168.0.188/vacuum_postgres.yml', status: 'pending_approval', executed_at: new Date(Date.now() - 5 * 60000).toISOString(), duration_ms: null, error: null, rag_confidence: 0.91 },
|
||||
{ id: '3', incident_id: 'INC-003', playbook_id: 'PB-3', playbook_name: 'openclaw-down-repair', action_type: 'ssh_command', uri_scheme: 'openclaw://', command: 'openclaw://docker-110/sentry', status: 'success', executed_at: new Date(Date.now() - 8 * 60000).toISOString(), duration_ms: 45000, error: null, rag_confidence: 0.88 },
|
||||
{ id: '4', incident_id: 'INC-004', playbook_id: 'PB-4', playbook_name: 'high-cpu-restart', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl rollout restart deployment/awoooi-web', status: 'failed', executed_at: new Date(Date.now() - 15 * 60000).toISOString(), duration_ms: null, error: 'SSH timeout 60s', rag_confidence: 0.82 },
|
||||
{ id: '5', incident_id: 'INC-005', playbook_id: 'PB-5', playbook_name: 'oom-killed-pod-delete', action_type: 'kubectl', uri_scheme: 'kubectl://', command: 'kubectl delete pod awoooi-worker-abc', status: 'success', executed_at: new Date(Date.now() - 17 * 60000).toISOString(), duration_ms: 1800, error: null, rag_confidence: 0.89 },
|
||||
]
|
||||
|
||||
export function NeuralStats({ stats, playbooks, history }: Props) {
|
||||
const t = useTranslations('neuralCommand')
|
||||
const displayHistory = history.length > 0 ? history : MOCK_HISTORY
|
||||
|
||||
const successRate = stats?.overall_success_rate ?? 0.87
|
||||
const totalExec = stats?.total_executions ?? 156
|
||||
|
||||
const KPIs = [
|
||||
{ label: t('kpiSuccessRate'), value: `${Math.round(successRate * 100)}%`, color: 'text-green-500', trend: t('trendUp', { n: 3 }) },
|
||||
{ label: t('kpiTotalExec'), value: totalExec, color: 'text-blue-500', trend: t('trendUp', { n: 23 }) },
|
||||
{ label: t('kpiPlaybooks'), value: stats?.approved_playbooks ?? 10, color: 'text-orange-500', trend: '5 K3s · 5 Docker' },
|
||||
{ label: t('kpiAvgDuration'), value: '4.2s', color: 'text-yellow-500', trend: t('trendDown', { n: 1.1 }) },
|
||||
{ label: t('kpiPendingAppr'), value: 2, color: 'text-purple-500', trend: 'ansible:// 類型' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
{/* ── KPI strip ── */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{KPIs.map(({ label, value, color, trend }) => (
|
||||
<div key={label} className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className={cn('text-3xl font-bold tabular-nums tracking-tight', color)}>{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mt-1">{label}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{trend}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
{/* ── Scheme breakdown ── */}
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">{t('schemeBreakdown')}</p>
|
||||
<div className="space-y-3">
|
||||
{SCHEME_STATS.map(s => (
|
||||
<div key={s.scheme} className="flex items-center gap-3">
|
||||
<span className="text-sm w-5 text-center">{s.icon}</span>
|
||||
<span className={cn('text-xs font-semibold w-24 font-mono', s.textColor)}>{s.scheme}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn('h-full rounded-full', s.color)} style={{ width: `${s.pct}%` }} />
|
||||
</div>
|
||||
<span className={cn('text-xs font-bold w-6 text-right', s.textColor)}>{s.count}</span>
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">{s.rate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Playbook ranking ── */}
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('playbookRanking')}</p>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
<th className="text-left pb-2 font-medium">{t('thName')}</th>
|
||||
<th className="text-left pb-2 font-medium">{t('thType')}</th>
|
||||
<th className="text-left pb-2 font-medium">{t('thRate')}</th>
|
||||
<th className="text-right pb-2 font-medium">{t('thCount')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{PLAYBOOK_RANKINGS.map(pb => (
|
||||
<tr key={pb.name}>
|
||||
<td className="py-2 text-xs font-semibold pr-2">{pb.name}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className={cn('text-[10px] font-bold px-1.5 py-0.5 rounded', TYPE_BADGE[pb.type])}>
|
||||
{pb.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-green-500" style={{ width: `${pb.rate}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-green-500">{pb.rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-right text-muted-foreground">{pb.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── History timeline ── */}
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">{t('historyTimeline')}</p>
|
||||
<div className="space-y-0 divide-y divide-border">
|
||||
{displayHistory.map(item => {
|
||||
const elapsed = Math.round((Date.now() - new Date(item.executed_at).getTime()) / 60000)
|
||||
const isSuccess = item.status === 'success'
|
||||
const isPending = item.status === 'pending_approval'
|
||||
const isFailed = item.status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex gap-3 py-3">
|
||||
{/* Timeline dot */}
|
||||
<div className="flex flex-col items-center pt-1 flex-shrink-0">
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full', {
|
||||
'bg-green-500': isSuccess,
|
||||
'bg-orange-500': isPending,
|
||||
'bg-red-500': isFailed,
|
||||
'bg-blue-500': item.status === 'running',
|
||||
})} />
|
||||
<div className="w-px flex-1 bg-border mt-1" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold">
|
||||
{item.playbook_name}
|
||||
{isSuccess && ' ✅'}
|
||||
{isFailed && ' ❌'}
|
||||
{isPending && ' ⏳'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-[10px] text-muted-foreground">{elapsed}m {t('ago')}</span>
|
||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
||||
{item.uri_scheme}{item.command.replace(/^[a-z]+:\/\//, '')}
|
||||
</code>
|
||||
{item.duration_ms && (
|
||||
<span className="text-[10px] font-semibold text-green-500">
|
||||
{(item.duration_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
{item.rag_confidence && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
🦞 RAG {item.rag_confidence.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{isPending && (
|
||||
<span className="text-[10px] text-orange-500 font-medium">{t('waitingApproval')}</span>
|
||||
)}
|
||||
{isFailed && item.error && (
|
||||
<span className="text-[10px] text-red-400">{item.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/components/neural-command/types.ts
Normal file
44
apps/web/src/components/neural-command/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Neural Command Center — Shared Types
|
||||
* 2026-04-06 Claude Code: Sprint 3 SSH_COMMAND 指揮權鏈
|
||||
*/
|
||||
|
||||
export interface AutoRepairStats {
|
||||
approved_playbooks: number
|
||||
high_quality_playbooks: number
|
||||
total_executions: number
|
||||
overall_success_rate: number
|
||||
auto_repair_eligible: boolean
|
||||
}
|
||||
|
||||
export interface PlaybookItem {
|
||||
playbook_id: string
|
||||
name: string
|
||||
status: string
|
||||
tags: string[]
|
||||
success_count: number
|
||||
failure_count: number
|
||||
ai_confidence: number
|
||||
approved_by: string | null
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export type UriScheme = 'kubectl://' | 'openclaw://' | 'ansible://'
|
||||
export type RepairStatus = 'success' | 'failed' | 'pending_approval' | 'running'
|
||||
|
||||
export interface RepairHistoryItem {
|
||||
id: string
|
||||
incident_id: string
|
||||
playbook_id: string
|
||||
playbook_name: string
|
||||
action_type: 'kubectl' | 'ssh_command' | 'manual'
|
||||
uri_scheme: UriScheme
|
||||
command: string
|
||||
status: RepairStatus
|
||||
executed_at: string
|
||||
duration_ms: number | null
|
||||
error: string | null
|
||||
rag_confidence: number | null
|
||||
}
|
||||
|
||||
export type NeuralTab = 'preflight' | 'live' | 'stats' | 'approval'
|
||||
Reference in New Issue
Block a user