fix(web): Sprint F 前端打假行動 — 29處假數據全面清除 (首席架構師 98/100)

P0: Neural Command 三個子組件移除所有 MOCK 常數,接上真實 API props
- NeuralLiveCenter: 假歷史/假KPI/假雷達 → 從 stats/history/incidents 即時計算
- NeuralStats: MOCK_HISTORY/SCHEME_STATS/PLAYBOOK_RANKINGS → useMemo 聚合
- NeuralApprovalPanel: MOCK_PENDING → 真實 /api/v1/approvals 簽核操作

P1: 10+處假用戶身份 (demo-user/user-001/War Room User) → CURRENT_USER 常數統一
P2: 刪除 6 個 Demo 匯出 (GlobalPulseChartDemo/MOCK_APPROVAL/DEMO_DECISION_CHAIN)
P3: /demo 頁面加 NEXT_PUBLIC_ENABLE_DEMO 環境變數保護
i18n: 新增 22 個翻譯鍵 (zh-TW + en)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-07 12:53:52 +08:00
parent 561bcb638b
commit 246587a401
26 changed files with 813 additions and 501 deletions

View File

@@ -1048,6 +1048,26 @@
"approvalGranted": "Authorization Granted",
"approvalGrantedDesc": "NemoTron is executing ansible-playbook...",
"approvalRejected": "Authorization Rejected",
"approvalRejectedDesc": "Transferred to manual handling"
"approvalRejectedDesc": "Transferred to manual handling",
"noHistory": "No repair history yet",
"noActiveAlerts": "No active alerts",
"noPlaybooks": "No playbook records yet",
"noApprovals": "No pending approvals",
"noApprovalsDesc": "All authorization requests have been processed",
"chainAlert": "Alert Triggered",
"chainRAG": "🦞 OpenClaw RAG Diagnosis",
"chainDecide": "⚡ NemoTron Decision",
"chainExec": "Executor Routing",
"chainIdleSub": "Waiting for new alerts...",
"backToList": "Back to List",
"approvalError": "Operation failed",
"processing": "Processing...",
"blastRadius": "Blast Radius",
"affectedPods": "Affected Pods",
"estimatedDowntime": "Est. Downtime",
"relatedServices": "Related Services",
"dataImpact": "Data Impact",
"dryRunChecks": "Dry-Run Checks",
"approvalQueueCount": "{count} pending approvals"
}
}

View File

@@ -1049,6 +1049,26 @@
"approvalGranted": "授權已核准",
"approvalGrantedDesc": "NemoTron 正在執行 ansible-playbook...",
"approvalRejected": "授權已拒絕",
"approvalRejectedDesc": "已轉交人工處理"
"approvalRejectedDesc": "已轉交人工處理",
"noHistory": "尚無修復紀錄",
"noActiveAlerts": "目前無活躍告警",
"noPlaybooks": "尚無 Playbook 紀錄",
"noApprovals": "目前無待審核項目",
"noApprovalsDesc": "所有授權請求已處理完畢",
"chainAlert": "告警觸發",
"chainRAG": "🦞 OpenClaw RAG 診斷",
"chainDecide": "⚡ NemoTron 決策",
"chainExec": "Executor 路由",
"chainIdleSub": "等待新告警進入...",
"backToList": "返回列表",
"approvalError": "操作失敗",
"processing": "處理中...",
"blastRadius": "爆炸半徑",
"affectedPods": "影響 Pods",
"estimatedDowntime": "預估停機",
"relatedServices": "相關服務",
"dataImpact": "資料影響",
"dryRunChecks": "Dry-Run 檢查",
"approvalQueueCount": "共 {count} 個待審核項目"
}
}

View File

@@ -93,6 +93,15 @@ export default function DemoPage({ params }: { params: { locale: string } }) {
const tDryRun = useTranslations('dryRun')
const locale = params.locale
// 生產環境保護: 需要 NEXT_PUBLIC_ENABLE_DEMO=true 才能存取
if (process.env.NEXT_PUBLIC_ENABLE_DEMO !== 'true') {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-muted-foreground">Demo page is disabled in production.</p>
</div>
)
}
const [_isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)

View File

@@ -35,7 +35,7 @@ import { NeuralLiveCenter } from '@/components/neural-command/NeuralLiveCenter'
import { NeuralStats } from '@/components/neural-command/NeuralStats'
import { NeuralApprovalPanel } from '@/components/neural-command/NeuralApprovalPanel'
import type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab } from '@/components/neural-command/types'
import type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab, PendingApprovalItem, ActiveIncident } from '@/components/neural-command/types'
export type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab }
// =============================================================================
@@ -62,14 +62,18 @@ export default function NeuralCommandPage({ params }: { params: { locale: string
const [loading, setLoading] = useState(true)
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
const [pendingApprovals, setPendingApprovals] = useState(0)
const [pendingApprovalList, setPendingApprovalList] = useState<PendingApprovalItem[]>([])
const [activeIncidents, setActiveIncidents] = useState<ActiveIncident[]>([])
const fetchData = useCallback(async () => {
try {
const [statsRes, pbRes, histRes, approvalsRes] = await Promise.all([
const [statsRes, pbRes, histRes, approvalsRes, incidentsRes] = await Promise.all([
fetch('/api/v1/auto-repair/stats'),
fetch('/api/v1/playbooks/'),
fetch('/api/v1/auto-repair/history?limit=20'),
fetch('/api/v1/approvals/pending'),
fetch('/api/v1/incidents?status=firing&limit=10'),
])
if (statsRes.ok) {
@@ -87,6 +91,11 @@ export default function NeuralCommandPage({ params }: { params: { locale: string
if (approvalsRes.ok) {
const data = await approvalsRes.json()
setPendingApprovals(data.count ?? 0)
setPendingApprovalList(data.approvals ?? [])
}
if (incidentsRes.ok) {
const data = await incidentsRes.json()
setActiveIncidents(data.incidents ?? [])
}
setLastRefresh(new Date())
@@ -179,13 +188,13 @@ export default function NeuralCommandPage({ params }: { params: { locale: string
<NeuralPreFlight stats={stats} playbooks={approvedPlaybooks} />
)}
{activeTab === 'live' && (
<NeuralLiveCenter stats={stats} history={history} />
<NeuralLiveCenter stats={stats} history={history} pendingCount={pendingApprovals} activeIncidents={activeIncidents} />
)}
{activeTab === 'stats' && (
<NeuralStats stats={stats} playbooks={approvedPlaybooks} history={history} />
<NeuralStats stats={stats} playbooks={approvedPlaybooks} history={history} pendingCount={pendingApprovals} />
)}
{activeTab === 'approval' && (
<NeuralApprovalPanel />
<NeuralApprovalPanel approvals={pendingApprovalList} onRefresh={fetchData} />
)}
</div>
</div>

View File

@@ -25,6 +25,7 @@ import {
} from '@/stores/approval.store'
import { useApprovalSSE } from '@/hooks/useApprovalSSE'
import { ShieldCheck, Bell } from 'lucide-react'
import { CURRENT_USER } from '@/lib/constants'
// =============================================================================
// Props
@@ -51,13 +52,13 @@ export function AICommandPanel({ className }: AICommandPanelProps) {
// Handle approval
const handleApprove = async (id: string) => {
await signApproval(id, 'demo-user', 'War Room User', 'Approved via Command Center')
await signApproval(id, CURRENT_USER.id, CURRENT_USER.name, 'Approved via Command Center')
await fetchPending()
}
// Handle rejection
const handleReject = async (id: string) => {
await rejectApproval(id, 'demo-user', 'War Room User', 'Rejected via Command Center')
await rejectApproval(id, CURRENT_USER.id, CURRENT_USER.name, 'Rejected via Command Center')
await fetchPending()
}

View File

@@ -15,7 +15,7 @@
import { useState, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Z_INDEX } from '@/lib/constants/z-index'
import { Z_INDEX, CURRENT_USER } from '@/lib/constants'
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
import { ApprovalCard, type RiskLevel } from '@/components/approval/approval-card'
import {
@@ -235,7 +235,7 @@ export function HITLSection({ locale: _locale, className }: HITLSectionProps) {
approvalId: id,
})
const result = await signApproval(id, 'demo-user', currentUserName, 'Approved via demo UI')
const result = await signApproval(id, CURRENT_USER.id, currentUserName, 'Approved via HITL Panel')
// Check if approval is now complete - Log EXEC
if (result?.execution_triggered) {
@@ -255,7 +255,7 @@ export function HITLSection({ locale: _locale, className }: HITLSectionProps) {
// Handle rejection
const handleReject = useCallback(async (id: string) => {
await rejectApproval(id, 'demo-user', currentUserName, 'Rejected via HITL Panel')
await rejectApproval(id, CURRENT_USER.id, currentUserName, 'Rejected via HITL Panel')
await fetchPending()
}, [rejectApproval, fetchPending, currentUserName])

View File

@@ -25,6 +25,7 @@ import type { ThinkingStream as _ThinkingStream, DEFAULT_THINKING_MESSAGES as _D
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
import { ApprovalModal } from '@/components/ui/approval-modal'
import { RefreshCw, AlertCircle, CheckCircle2, AlertTriangle, ChevronRight } from 'lucide-react'
import { CURRENT_USER } from '@/lib/constants'
// =============================================================================
// Types
@@ -243,8 +244,8 @@ export function OpenClawStateMachine({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rejector_id: 'war-room-user',
rejector_name: 'War Room User',
rejector_id: CURRENT_USER.id,
rejector_name: CURRENT_USER.name,
reason: 'Rejected via AWOOOI Dashboard',
}),
})

View File

@@ -19,6 +19,7 @@
import { useState, useMemo, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { CURRENT_USER } from '@/lib/constants'
import {
useApprovalStore,
usePendingApprovals,
@@ -71,8 +72,8 @@ interface BulkApproveResponse {
export function BatchModeSelector({
className,
onBulkComplete,
signerId = 'user-001',
signerName = 'Demo User',
signerId = CURRENT_USER.id,
signerName = CURRENT_USER.name,
}: BatchModeSelectorProps) {
const t = useTranslations('approval')
const tBatch = useTranslations('approval.batch')

View File

@@ -23,7 +23,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Z_INDEX } from '@/lib/constants/z-index'
import { Z_INDEX, CURRENT_USER } from '@/lib/constants'
import {
useApprovalStore,
usePendingApprovals,
@@ -62,8 +62,8 @@ interface ConversationalViewProps {
export function ConversationalView({
className,
signerId = 'user-001',
signerName = 'Demo User',
signerId = CURRENT_USER.id,
signerName = CURRENT_USER.name,
}: ConversationalViewProps) {
const t = useTranslations('approval')
const tCommon = useTranslations('common')

View File

@@ -14,9 +14,7 @@ export {
type Signature,
} from './approval-card'
// Re-export ApprovalRequest as both type and value for mock data
import type { ApprovalRequest } from './approval-card'
export type { ApprovalRequest }
export type { ApprovalRequest } from './approval-card'
export { LiveApprovalPanel } from './live-approval-panel'
@@ -25,74 +23,3 @@ export { ConversationalView } from './conversational-view'
export { ApprovalThreadItem } from './approval-thread-item'
export { BatchModeSelector, type BatchMode } from './batch-mode-selector'
// =============================================================================
// Mock Data for Demo
// =============================================================================
export const MOCK_APPROVAL_HIGH: ApprovalRequest = {
id: 'apr-001',
action: 'Delete Pod: nginx-frontend-7d4b8c9f5-xk2m3',
description: 'Clean up unresponsive frontend Pod, ReplicaSet will auto-rebuild',
riskLevel: 'high',
blastRadius: {
affectedPods: 3,
estimatedDowntime: '~2 min',
relatedServices: ['nginx-ingress', 'frontend-svc', 'cdn-cache'],
dataImpact: 'NONE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'cluster-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Resource Exists', passed: true, message: 'Pod found' },
{ name: 'Replica Count > 1', passed: true, message: '3 replicas' },
],
requiredSignatures: 2,
currentSignatures: 1,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 14:32:05',
}
export const MOCK_APPROVAL_CRITICAL: ApprovalRequest = {
id: 'apr-002',
action: 'DROP TABLE: user_sessions',
description: 'Clear all user sessions, will force logout all users',
riskLevel: 'critical',
blastRadius: {
affectedPods: 0,
estimatedDowntime: '0',
relatedServices: ['auth-service', 'api-gateway', 'user-service'],
dataImpact: 'DESTRUCTIVE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'db-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Table Exists', passed: true },
{ name: 'Backup Available', passed: false, message: 'No recent backup!' },
],
requiredSignatures: 2,
currentSignatures: 2,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 14:45:12',
}
export const MOCK_APPROVAL_LOW: ApprovalRequest = {
id: 'apr-003',
action: 'Scale Deployment: api-backend',
description: 'Scale from 3 to 5 replicas for increased traffic',
riskLevel: 'low',
blastRadius: {
affectedPods: 5,
estimatedDowntime: '0',
relatedServices: ['api-backend'],
dataImpact: 'NONE',
},
dryRunChecks: [
{ name: 'RBAC Permission', passed: true, message: 'deployment-admin' },
{ name: 'Syntax Valid', passed: true },
{ name: 'Resource Quota', passed: true, message: '5/20 pods' },
],
requiredSignatures: 1,
currentSignatures: 1,
requestedBy: 'OpenClaw',
requestedAt: '2026-03-20 15:00:00',
}

View File

@@ -19,7 +19,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useApprovalStore, usePendingApprovals, toFrontendApproval } from '@/stores/approval.store'
import { useCSRF } from '@/hooks/useCSRF'
import { Z_INDEX } from '@/lib/constants/z-index'
import { Z_INDEX, CURRENT_USER } from '@/lib/constants'
import { useApprovalSSE } from '@/hooks/useApprovalSSE'
import { ApprovalCard, type ApprovalRequest, type RiskLevel } from './approval-card'
import {
@@ -98,8 +98,8 @@ function getRequiredRolesDisplay(riskLevel: RiskLevel): string {
export function LiveApprovalPanel({
className,
signerId = 'user-001',
signerName = 'Demo User',
signerId = CURRENT_USER.id,
signerName = CURRENT_USER.name,
signerRole = 'devops', // 模擬當前登入者角色
}: LiveApprovalPanelProps) {
const t = useTranslations('approval')

View File

@@ -229,45 +229,3 @@ export function AIProcessStepper({ steps, className }: AIProcessStepperProps) {
)
}
// =============================================================================
// Demo Component
// =============================================================================
export function AIProcessStepperDemo() {
const demoSteps: ProcessStep[] = [
{
id: 'gather',
label: 'Context Gathering',
description: 'Fetching logs, metrics, and K8s state',
status: 'complete',
duration: '1.2s',
},
{
id: 'analyze',
label: 'AI Analysis',
description: 'OpenClaw RCA engine processing',
status: 'active',
duration: '...',
},
{
id: 'approve',
label: 'HITL Approval',
description: 'Waiting for human confirmation',
status: 'pending',
},
{
id: 'execute',
label: 'Execute Action',
description: 'Apply kubectl commands',
status: 'pending',
},
{
id: 'verify',
label: 'Verification',
description: 'Confirm resolution',
status: 'pending',
},
]
return <AIProcessStepper steps={demoSteps} />
}

View File

@@ -183,41 +183,3 @@ export function GlobalPulseChart({ metrics, className }: GlobalPulseChartProps)
)
}
// =============================================================================
// Default Export with Demo Data
// =============================================================================
export function GlobalPulseChartDemo() {
const demoMetrics: PulseMetric[] = [
{
label: 'RPS',
value: 1247,
unit: 'req/s',
trend: [120, 135, 128, 142, 138, 145, 152, 148, 156, 160],
status: 'healthy',
},
{
label: 'Error Rate',
value: '0.12',
unit: '%',
trend: [0.1, 0.15, 0.08, 0.12, 0.09, 0.11, 0.14, 0.1, 0.12, 0.12],
status: 'healthy',
},
{
label: 'P99 Latency',
value: 245,
unit: 'ms',
trend: [220, 235, 248, 242, 255, 238, 252, 248, 245, 245],
status: 'warning',
},
{
label: 'AI Success',
value: '98.5',
unit: '%',
trend: [97, 98, 99, 98, 97, 99, 98, 99, 98, 98.5],
status: 'healthy',
},
]
return <GlobalPulseChart metrics={demoMetrics} />
}

View File

@@ -5,5 +5,5 @@
*/
export { TimeSeriesChart, type TimeSeriesChartProps, type TimeSeriesDataPoint } from './time-series-chart'
export { GlobalPulseChart, GlobalPulseChartDemo, type GlobalPulseChartProps, type PulseMetric } from './global-pulse-chart'
export { AIProcessStepper, AIProcessStepperDemo, type AIProcessStepperProps, type ProcessStep, type StepStatus } from './ai-process-stepper'
export { GlobalPulseChart, type GlobalPulseChartProps, type PulseMetric } from './global-pulse-chart'
export { AIProcessStepper, type AIProcessStepperProps, type ProcessStep, type StepStatus } from './ai-process-stepper'

View File

@@ -17,6 +17,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import type { IncidentResponse, DecisionInfo } from '@/lib/api-client'
import { apiClient } from '@/lib/api-client'
import { CURRENT_USER } from '@/lib/constants'
import { useCSRF } from '@/hooks/useCSRF'
import { FlowPipeline, type FlowStage } from './flow-pipeline'
@@ -172,7 +173,7 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
const approveAction = useCallback(async (): Promise<ButtonState | void> => {
if (!isDecisionReady) return
const approvalId = await resolveProposalId()
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken)
const result = await apiClient.signApproval(approvalId, CURRENT_USER.id, 'Authorized via AI Center', csrfToken)
const approvalStatus = result.approval?.status?.toLowerCase()
if (approvalStatus === 'approved') {
onApprovalChange?.(approvalId, 'approved')

View File

@@ -5,7 +5,6 @@
export { IncidentCard } from './incident-card'
export {
ThinkingTerminal,
DEMO_DECISION_CHAIN,
type DecisionChain,
type ReasoningStep,
type ThinkingTerminalProps,

View File

@@ -352,40 +352,6 @@ function ThinkingStepLine({ step, isLast }: ThinkingStepLineProps) {
)
}
// =============================================================================
// Demo Data (for testing - will be removed in production)
// =============================================================================
export const DEMO_DECISION_CHAIN: DecisionChain = {
analysis_type: 'blast_radius',
target_service: 'api-gateway',
reasoning_steps: [
{
step: 'SIGNAL_RECEIVED',
reasoning: 'Received PodCrashLoopBackOff alert for api-gateway in production namespace.',
timestamp: '2026-03-22T16:41:18Z',
},
{
step: 'SEVERITY_EVALUATION',
reasoning: 'Alert severity: CRITICAL (P0). Requires immediate attention.',
},
{
step: 'BLAST_RADIUS_ANALYSIS',
reasoning: 'Analyzing upstream dependencies...\n- payment-service: AFFECTED\n- order-service: AFFECTED\n- user-service: AFFECTED\nTotal impact: 3 services, ~2000 requests/min',
},
{
step: 'ROOT_CAUSE_HYPOTHESIS',
reasoning: 'Possible causes:\n1. OOMKilled (memory limit exceeded)\n2. Liveness probe failure\n3. Application panic/crash',
},
{
step: 'ACTION_RECOMMENDATION',
reasoning: 'Recommended action: Restart deployment api-gateway\nRisk level: CRITICAL (requires 2 signatures)\nEstimated downtime: 5-15 min',
},
],
conclusion: 'Generate proposal: Restart deployment api-gateway with multi-sig approval.',
confidence: 0.87,
}
// =============================================================================
// Exports
// =============================================================================

View File

@@ -3,69 +3,140 @@
/**
* NeuralApprovalPanel - 核鑰授權面板
* =====================================
* 顯示待審核的 SSH_COMMAND ansible:// 操作
* 整合 NuclearKeyButton 長按確認
* 顯示真實待審核的操作,整合 NuclearKeyButton 長按確認
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 移除 MOCK_PENDING
* 統帥鐵律: 接真實 /api/v1/approvals/pending無資料顯示 EmptyState
*/
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
import { AlertTriangle, CheckCircle2, XCircle, Inbox, Loader2 } from 'lucide-react'
import { NuclearKeyButton } from '@/components/genui/NuclearKeyButton'
import type { PendingApprovalItem } from './types'
import { CURRENT_USER } from '@/lib/constants'
// 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。此操作不可線上撤銷需統帥批准後執行。',
interface Props {
approvals: PendingApprovalItem[]
onRefresh: () => void
}
export function NeuralApprovalPanel() {
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
export function NeuralApprovalPanel({ approvals, onRefresh }: Props) {
const t = useTranslations('neuralCommand')
const [decision, setDecision] = useState<'approved' | 'rejected' | null>(null)
const [processingId, setProcessingId] = useState<string | null>(null)
const [result, setResult] = useState<{ id: string; action: 'approved' | 'rejected' } | null>(null)
const [error, setError] = useState<string | null>(null)
if (decision === 'approved') {
const handleApprove = useCallback(async (approvalId: string) => {
setProcessingId(approvalId)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: CURRENT_USER.id,
signer_name: CURRENT_USER.name,
comment: 'Approved via Neural Command Center',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setResult({ id: approvalId, action: 'approved' })
onRefresh()
} catch (err) {
setError(`${t('approvalError')}: ${err}`)
} finally {
setProcessingId(null)
}
}, [onRefresh, t])
const handleReject = useCallback(async (approvalId: string) => {
setProcessingId(approvalId)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: CURRENT_USER.id,
signer_name: CURRENT_USER.name,
reason: 'Rejected via Neural Command Center',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setResult({ id: approvalId, action: 'rejected' })
onRefresh()
} catch (err) {
setError(`${t('approvalError')}: ${err}`)
} finally {
setProcessingId(null)
}
}, [onRefresh, t])
// 剛處理完某個審批的結果回饋
if (result) {
const isApproved = result.action === '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>
{isApproved
? <CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
: <XCircle className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
}
<p className="text-base font-semibold">
{isApproved ? t('approvalGranted') : t('approvalRejected')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{isApproved ? t('approvalGrantedDesc') : t('approvalRejectedDesc')}
</p>
<button
onClick={() => setResult(null)}
className="mt-4 px-4 py-2 rounded-lg border border-border text-sm hover:bg-muted transition-colors"
>
{t('backToList')}
</button>
</div>
</div>
)
}
if (decision === 'rejected') {
// 無待審批
if (approvals.length === 0) {
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 className="text-center text-muted-foreground">
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-40" />
<p className="text-base font-semibold">{t('noApprovals')}</p>
<p className="text-sm mt-1">{t('noApprovalsDesc')}</p>
</div>
</div>
)
}
const pb = MOCK_PENDING
// 顯示第一個待審批 (最緊急的)
const approval = approvals[0]
const riskConfig: Record<string, { level: string; blocks: number; total: number }> = {
low: { level: 'LOW', blocks: 1, total: 4 },
medium: { level: 'MEDIUM', blocks: 2, total: 4 },
high: { level: 'HIGH', blocks: 3, total: 4 },
critical: { level: 'CRITICAL', blocks: 4, total: 4 },
}
const risk = riskConfig[approval.risk_level] ?? riskConfig['medium']
return (
<div className="p-6">
<div className="max-w-xl mx-auto space-y-5">
{/* 待審核數量指示 */}
{approvals.length > 1 && (
<div className="text-xs text-muted-foreground text-center">
{t('approvalQueueCount', { count: approvals.length })}
</div>
)}
{/* 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">
@@ -74,77 +145,96 @@ export function NeuralApprovalPanel() {
<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}
{approval.action} · {approval.id}
</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 className="rounded-xl border border-border bg-muted/30 p-4">
<p className="text-xs text-muted-foreground leading-relaxed">{approval.description}</p>
</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>
{/* Blast Radius */}
{approval.blast_radius && (
<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('blastRadius')}
</p>
{[
{ label: t('affectedPods'), value: String(approval.blast_radius.affected_pods) },
{ label: t('estimatedDowntime'), value: approval.blast_radius.estimated_downtime },
{ label: t('relatedServices'), value: approval.blast_radius.related_services.join(', ') },
{ label: t('dataImpact'), value: approval.blast_radius.data_impact },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-28 flex-shrink-0">{label}</span>
<span className="text-xs font-mono bg-background border border-border rounded px-2 py-0.5 flex-1 min-w-0">
{value}
</span>
</div>
))}
</div>
)}
{/* Dry-run checks */}
{approval.dry_run_checks && approval.dry_run_checks.length > 0 && (
<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('dryRunChecks')}
</p>
{approval.dry_run_checks.map((check, i) => (
<div key={i} className="flex items-center gap-2">
{check.passed
? <CheckCircle2 className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />
: <XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
}
<span className="text-xs">{check.name}</span>
{check.message && <span className="text-[10px] text-muted-foreground ml-auto">{check.message}</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) => (
{Array.from({ length: risk.total }).map((_, i) => (
<div key={i} className={cn(
'w-5 h-2 rounded-sm',
i < pb.riskBlocks ? 'bg-orange-500' : 'bg-muted',
i < risk.blocks ? '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>
<span className="text-sm font-semibold text-orange-500">{risk.level}</span>
</div>
{/* Error display */}
{error && (
<div className="p-3 bg-status-critical/10 border border-status-critical/30 rounded-lg">
<p className="text-sm text-status-critical font-body">{error}</p>
</div>
)}
{/* Nuclear confirm */}
<NuclearKeyButton
label={t('confirmExec')}
onConfirm={() => setDecision('approved')}
riskLevel="medium"
label={processingId ? t('processing') : t('confirmExec')}
onConfirm={() => handleApprove(approval.id)}
riskLevel={approval.risk_level === 'critical' ? 'critical' : '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"
onClick={() => handleReject(approval.id)}
disabled={!!processingId}
className="w-full py-3 rounded-xl border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{t('rejectApproval')}
{processingId
? <Loader2 className="w-4 h-4 animate-spin mx-auto" />
: t('rejectApproval')
}
</button>
</div>
</div>

View File

@@ -4,16 +4,21 @@
* NeuralLiveCenter - 即時指揮中心
* =================================
* 三欄: OpenClaw/NemoTron 狀態 | 神經傳導鏈路 | 執行串流
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 移除所有假數據,接上真實 props
* 統帥鐵律: 禁止假數據!無資料時顯示 '--' 或 EmptyState
*/
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'
import { CheckCircle2, Clock, XCircle, Loader2, Inbox } 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
@@ -30,31 +35,62 @@ const STATUS_CONFIG = {
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) {
// 從最新一筆 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`
}
// 嚴重度 → emoji 對應
function severityEmoji(severity: string): string {
switch (severity) {
case 'P0': return '🔴'
case 'P1': return '🟠'
case 'P2': return '🟡'
default: return '🟢'
}
}
export function NeuralLiveCenter({ stats, history, pendingCount, activeIncidents = [] }: 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 },
]
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">
@@ -73,9 +109,9 @@ export function NeuralLiveCenter({ stats, history }: Props) {
<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 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>
@@ -90,34 +126,36 @@ export function NeuralLiveCenter({ stats, history }: Props) {
<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 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 */}
{/* 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">
{[
{ 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>
{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="text-xs">{severityEmoji(inc.severity)}</span>
<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>
<span className="text-[10px] text-muted-foreground flex-shrink-0">{a.age}</span>
))
) : (
<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>
@@ -125,10 +163,10 @@ export function NeuralLiveCenter({ stats, history }: Props) {
{/* ── 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
{t('chainTitle')}{latestItem ? `${latestItem.incident_id} · ${latestItem.playbook_name}` : ''}
</p>
<div className="flex-1 flex flex-col items-center justify-center gap-0">
{CHAIN_NODES.map((node, i) => (
{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>
@@ -139,7 +177,7 @@ export function NeuralLiveCenter({ stats, history }: Props) {
{node.state === 'waiting'&& <span className="text-[10px] font-bold text-muted-foreground">{t('nodeWaiting')}</span>}
</div>
</div>
{i < CHAIN_NODES.length - 1 && (
{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>
@@ -166,41 +204,48 @@ export function NeuralLiveCenter({ stats, history }: Props) {
{/* ── 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)
{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')} />
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>
<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 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>

View File

@@ -4,60 +4,92 @@
* NeuralStats - 統計 & 歷史面板
* ================================
* 5 KPI + 執行路徑分佈 + Playbook 排名 + 時間軸
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 移除所有 MOCK 常數
* 統帥鐵律: 所有數據從 stats/playbooks/history props 計算,禁止寫死!
*/
import { useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { CheckCircle2, XCircle, Clock, TrendingUp, TrendingDown } from 'lucide-react'
import { Inbox } from 'lucide-react'
import type { AutoRepairStats, PlaybookItem, RepairHistoryItem } from './types'
interface Props {
stats: AutoRepairStats | null
playbooks: PlaybookItem[]
history: RepairHistoryItem[]
pendingCount: number
}
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 },
]
const SCHEME_ICON: Record<string, { icon: string; color: string; textColor: string; bg: string }> = {
'kubectl://': { icon: '☸️', color: 'bg-blue-500', textColor: 'text-blue-500', bg: 'bg-blue-500/10' },
'openclaw://': { icon: '🦞', color: 'bg-orange-500', textColor: 'text-orange-500', bg: 'bg-orange-500/10' },
'ansible://': { icon: '⚙️', color: 'bg-purple-500', textColor: 'text-purple-500', bg: 'bg-purple-500/10' },
}
export function NeuralStats({ stats, playbooks, history }: Props) {
export function NeuralStats({ stats, playbooks, history, pendingCount }: 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
// 從 history 聚合 scheme 分佈
const schemeStats = useMemo(() => {
const counts: Record<string, { count: number; successCount: number }> = {}
for (const item of history) {
const scheme = item.uri_scheme
if (!counts[scheme]) counts[scheme] = { count: 0, successCount: 0 }
counts[scheme].count++
if (item.status === 'success') counts[scheme].successCount++
}
const total = history.length || 1
return Object.entries(counts).map(([scheme, { count, successCount }]) => ({
scheme,
...(SCHEME_ICON[scheme] ?? SCHEME_ICON['kubectl://']),
count,
rate: count > 0 ? Math.round((successCount / count) * 100) : 0,
pct: Math.round((count / total) * 100),
})).sort((a, b) => b.count - a.count)
}, [history])
// 從 playbooks 排序 (按成功率,有執行次數的優先)
const playbookRankings = useMemo(() => {
return playbooks
.filter(p => p.success_count + p.failure_count > 0)
.map(p => {
const total = p.success_count + p.failure_count
return {
name: p.name,
type: p.tags.includes('ansible') ? 'ansible' : p.tags.includes('openclaw') ? 'openclaw' : 'kubectl',
rate: total > 0 ? Math.round((p.success_count / total) * 100) : 0,
count: total,
}
})
.sort((a, b) => b.count - a.count)
.slice(0, 8)
}, [playbooks])
// 從 history 計算平均時長
const avgDuration = useMemo(() => {
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`
}, [history])
const successRate = stats?.overall_success_rate ?? null
const totalExec = stats?.total_executions ?? null
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:// 類型' },
{ label: t('kpiSuccessRate'), value: successRate != null ? `${Math.round(successRate * 100)}%` : '--', color: 'text-green-500' },
{ label: t('kpiTotalExec'), value: totalExec ?? '--', color: 'text-blue-500' },
{ label: t('kpiPlaybooks'), value: stats?.approved_playbooks ?? '--', color: 'text-orange-500' },
{ label: t('kpiAvgDuration'), value: avgDuration, color: 'text-yellow-500' },
{ label: t('kpiPendingAppr'), value: pendingCount, color: 'text-purple-500' },
]
return (
@@ -65,11 +97,10 @@ export function NeuralStats({ stats, playbooks, history }: Props) {
{/* ── KPI strip ── */}
<div className="grid grid-cols-5 gap-3">
{KPIs.map(({ label, value, color, trend }) => (
{KPIs.map(({ label, value, color }) => (
<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>
@@ -79,118 +110,136 @@ export function NeuralStats({ stats, playbooks, history }: Props) {
{/* ── 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}%` }} />
{schemeStats.length > 0 ? (
<div className="space-y-3">
{schemeStats.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>
<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>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Inbox className="w-5 h-5 mb-1 opacity-40" />
<p className="text-[10px]">{t('noHistory')}</p>
</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>
{playbookRankings.length > 0 ? (
<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>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-border">
{playbookRankings.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] ?? TYPE_BADGE['kubectl'])}>
{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 className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Inbox className="w-5 h-5 mb-1 opacity-40" />
<p className="text-[10px]">{t('noPlaybooks')}</p>
</div>
)}
</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'
{history.length > 0 ? (
<div className="space-y-0 divide-y divide-border">
{history.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>
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>
<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>
)}
{/* 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}
</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>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Inbox className="w-6 h-6 mb-2 opacity-40" />
<p className="text-xs">{t('noHistory')}</p>
</div>
)}
</div>
</div>
)

View File

@@ -42,3 +42,30 @@ export interface RepairHistoryItem {
}
export type NeuralTab = 'preflight' | 'live' | 'stats' | 'approval'
export interface PendingApprovalItem {
id: string
action: string
description: string
status: string
risk_level: string
blast_radius: {
affected_pods: number
estimated_downtime: string
related_services: string[]
data_impact: string
}
dry_run_checks: Array<{ name: string; passed: boolean; message?: string }>
requested_by: string
created_at: string
incident_id?: string | null
}
export interface ActiveIncident {
incident_id: string
status: string
severity: string
signal_count: number
affected_services: string[]
created_at: string
}

View File

@@ -5,6 +5,8 @@
* 統帥鐵律: 禁止任何 Fallback IP環境變數缺失即噴錯
*/
import { CURRENT_USER } from '@/lib/constants/user'
// 絕對純化: 環境變數缺失時直接拋出致命錯誤,嚴禁任何 Fallback
const getApiBaseUrl = (): string => {
const url = process.env.NEXT_PUBLIC_API_URL
@@ -114,7 +116,7 @@ export const apiClient = {
}>(res)
},
async signApproval(approvalId: string, signer: string = 'commander', comment?: string, csrfToken?: string | null) {
async signApproval(approvalId: string, signer: string = CURRENT_USER.id, comment?: string, csrfToken?: string | null) {
// Phase 22 P0: 加入 CSRF token + credentials (2026-03-31 Claude Code)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
@@ -151,8 +153,8 @@ export const apiClient = {
headers,
credentials: 'include',
body: JSON.stringify({
rejector_id: 'commander',
rejector_name: 'Commander',
rejector_id: CURRENT_USER.id,
rejector_name: CURRENT_USER.name,
reason: reason || 'Rejected via WarRoom',
}),
})

View File

@@ -12,3 +12,4 @@ export * from './z-index'
export * from './shortcuts'
export * from './animations'
export * from './sse-states'
export * from './user'

View File

@@ -0,0 +1,13 @@
/**
* 當前用戶常數
* =====================================
* 統帥是唯一操作者,在多用戶認證系統實作前使用固定身份。
* 禁止使用 'demo-user'、'user-001'、'Demo User' 等假身份。
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 消除假用戶身份
*/
export const CURRENT_USER = {
id: 'commander',
name: '統帥',
} as const

View File

@@ -5,6 +5,30 @@
---
## 📍 當前狀態 (2026-04-07 Sprint F 打假行動完成 → Sprint 4 Phase A 待實作)
| 項目 | 狀態 | 說明 |
|------|------|------|
| Sprint F 計畫文件 | ✅ | `docs/superpowers/plans/2026-04-07-sprint-f-fake-data-purge.md` |
| 首席架構師審查 | ✅ 100% | Fully Approved (統帥指示: 立即啟動) |
| P0: Neural Command 假數據清除 | ✅ | NeuralLiveCenter + NeuralStats + NeuralApprovalPanel |
| P1: 假用戶身份清除 | ✅ | 7處 demo-user → commander/統帥 |
| P2: Demo 匯出清理 | ✅ | 6處 Demo 組件/Mock 常數刪除 |
| P3: /demo 頁面保護 | ✅ | NEXT_PUBLIC_ENABLE_DEMO 環境變數 |
| i18n 新增翻譯 | ✅ | 22 個新 key (zh-TW + en) |
| TypeScript 驗證 | ✅ | 零新增錯誤,假數據搜尋零殘留 |
| project_sprint_f_fake_data_purge.md | ✅ | Memory 更新 |
| project_current_status.md | ✅ | 狀態快照更新 |
| 首席架構師 Review | ✅ 98/100 | C1(CURRENT_USER統一) + C2(未使用import) 已修正 |
| C1 修正: CURRENT_USER 統一 | ✅ | 10+處 'commander'/'統帥' → CURRENT_USER 常數 |
| C2 修正: NeuralStats import 清理 | ✅ | 移除未使用的 lucide import |
| 漏網之魚修復 | ✅ | openclaw-state-machine.tsx War Room User → CURRENT_USER |
| 最終驗證 | ✅ | 'commander' 僅存於 user.ts 定義TypeScript 零新增錯誤 |
**下一步**: Sprint 4 Phase A 實作 (A1→A2→A3: Model + Redis 計數器)
---
## 📍 當前狀態 (2026-04-07 Sprint 4 規劃完成 → Phase A 待實作)
| 項目 | 狀態 | 說明 |

View File

@@ -0,0 +1,187 @@
# Sprint F: 前端打假行動 (Fake Data Purge)
> **建立**: 2026-04-07 (台北時區)
> **建立者**: Claude Code
> **首席架構師審查**: ✅ 100% Fully Approved (統帥原話: "請立即啟動實作程序")
> **優先級**: 🔴 P0 — 直接影響系統可信度 (Trust)
---
## 背景
前端審計發現 **29 處假數據/假頁面**,嚴重違反 `feedback_no_fake_data.md` 鐵律。
核心問題: Neural Command 頁面後端 API 已全部打通 (Sprint 2/3),但前端子組件殘留大量 Mock 常數。
## 與現有工作的衝突分析
| 現有工作 | 衝突? | 分析 |
|----------|-------|------|
| Sprint 3 SSH_COMMAND (已完成) | ❌ 無衝突 | Sprint F 修復的正是 Sprint 3 前端組件的假數據 |
| Sprint 4 告警處置統計 (待實作) | ⚠️ 低衝突 | Sprint 4 Phase E (前端頁面) 會新增組件Sprint F 修的是現有組件。建議 **Sprint F 先做**,確保新組件從一開始就用真實數據 |
| Phase 24 AI Router (進行中) | ❌ 無衝突 | 後端路由重構,不涉及前端 |
| Phase O 可觀測性 | ❌ 無衝突 | SigNoz/監控整合,不涉及 Neural Command UI |
**結論**: Sprint F 應在 Sprint 4 Phase A 之前執行,作為 Sprint 3.5 的技術債清理。
---
## 執行順序
```
Sprint 3 (✅ 完成) → Sprint F (打假行動) → Sprint 4 (告警處置統計)
^^^^^^^^^^^^^^^^
當前執行點
```
---
## Phase P0: Neural Command 假數據清除 (核心修復)
### P0-1: NeuralLiveCenter.tsx
**問題**: 6 處假數據 + 假歷史 + 假告警雷達
**後端 API**: 已有 `stats` + `history` props 從 page.tsx 傳入
| 修復項 | 現況 (假) | 修復方案 |
|--------|-----------|----------|
| L51-57: `displayHistory` fallback | 5 筆假 INC-1~5 | 無資料時顯示 `EmptyState` (`t('noHistory')`) |
| L77: todayMatches | 寫死 `23` | 從 `stats` 計算或顯示 `--` |
| L78: RAG Confidence | 寫死 `0.87` | 從 `stats` 取或顯示 `--` |
| L93: 執行成功率 | 寫死 `136/156` | `stats.total_executions * stats.overall_success_rate / stats.total_executions` |
| L94: 平均時長 | 寫死 `4.2s` | 從 `history` 計算平均 `duration_ms`,無資料顯示 `--` |
| L95: 待審批 | 寫死 `2` | 新增 `pendingCount` prop 從 page.tsx 傳入 |
| L103-108: Alert Radar | 4 筆假告警 | 從 `history` 最新 4 筆衍生,或從 `/api/v1/incidents` 取活躍告警 |
| L34-39: CHAIN_NODES | 假鏈路 | 無活躍修復時顯示 idle 狀態,有時從最新 history item 衍生 |
**新增 Props**:
```typescript
interface Props {
stats: AutoRepairStats | null
history: RepairHistoryItem[]
pendingCount: number // 新增: 從 page.tsx 傳入
activeIncidents?: any[] // 新增: 活躍告警 (可選)
}
```
### P0-2: NeuralStats.tsx
**問題**: SCHEME_STATS + PLAYBOOK_RANKINGS + MOCK_HISTORY + 假 KPI
**後端 API**: 已有 `stats` + `playbooks` + `history` props
| 修復項 | 現況 (假) | 修復方案 |
|--------|-----------|----------|
| L20-23: SCHEME_STATS | 寫死 116/34/6 | 從 `history` 聚合計算 scheme 分佈 |
| L26-31: PLAYBOOK_RANKINGS | 寫死 5 個排名 | 從 `playbooks` prop 排序 (success_count/total) |
| L40-46: MOCK_HISTORY | 5 筆假歷史 | 移除,直接用 `history` prop |
| L52: successRate fallback | `?? 0.87` | `?? 0` + 無資料時顯示 `--` |
| L53: totalExec fallback | `?? 156` | `?? 0` |
| L59: 平均時長 | 寫死 `4.2s` | 從 `history` 計算 |
| L60: 待審批 | 寫死 `2` | 新增 prop `pendingCount` |
### P0-3: NeuralApprovalPanel.tsx
**問題**: 整個面板用 MOCK_PENDING 假審批
**後端 API**: `/api/v1/approvals/pending` 已存在
| 修復項 | 現況 (假) | 修復方案 |
|--------|-----------|----------|
| L17-33: MOCK_PENDING | 假審批 APR-2026-0042 | 接收 `approvals` prop (PendingApprovalsResponse) |
| 無 pending 時 | 永遠顯示假審批 | 顯示 "目前無待審批項目" EmptyState |
| 審批動作 | 純前端 state | 呼叫真實 `/api/v1/approvals/{id}/sign``/reject` |
**新增 Props**:
```typescript
interface Props {
approvals: ApprovalItem[] // 從 page.tsx 傳入
onApprove: (id: string) => Promise<void>
onReject: (id: string) => Promise<void>
}
```
### P0-4: page.tsx (Neural Command) 修改
page.tsx 已在 fetch 四個 API但需要:
1.`pendingApprovals` count 和 approvals list 傳給子組件
2. 新增 incidents fetch 給 Alert Radar
---
## Phase P1: 假用戶身份清除
**影響**: 7 處使用 `demo-user` / `user-001` / `Demo User`
| 檔案 | 修復方案 |
|------|----------|
| live-approval-panel.tsx:101-102 | 改用 `AWOOOI_USER_ID` / `AWOOOI_USER_NAME` 環境變數或 constants |
| conversational-view.tsx:65-66 | 同上 |
| batch-mode-selector.tsx:74-75 | 同上 |
| hitl-section.tsx:238,258 | 同上 |
| ai-command-panel.tsx:54,60 | 同上 |
**方案**: 建立 `src/constants/user.ts`:
```typescript
// 統帥是唯一用戶,在多用戶系統實作前使用固定身份
export const CURRENT_USER = {
id: 'commander',
name: '統帥',
} as const
```
---
## Phase P2: Demo 組件清理
| 檔案 | 動作 |
|------|------|
| charts/global-pulse-chart.tsx:190-223 | 刪除 `GlobalPulseChartDemo` |
| charts/ai-process-stepper.tsx:236-273 | 刪除 `AIProcessStepperDemo` |
| charts/index.ts:8-9 | 移除 Demo 匯出 |
| approval/index.ts:32-98 | 刪除 `MOCK_APPROVAL_*` 三個常數 |
| incident/thinking-terminal.tsx:359-387 | 刪除 `DEMO_DECISION_CHAIN` |
| incident/index.ts:8 | 移除匯出 |
**前提確認**: 搜尋確認這些 export 沒有被任何生產頁面 import。
---
## Phase P3: Demo 頁面
| 動作 | 說明 |
|------|------|
| 保留但加保護 | `/demo` 路由加 `NEXT_PUBLIC_ENABLE_DEMO=true` 環境變數保護 |
**理由**: Demo 頁面用於內部測試 (LiveDashboard + HITLSection 都接真實 API),但不應在生產暴露。
---
## 實施順序 (無衝突)
```
P0-4 (page.tsx 增加 props 傳遞)
P0-1 (NeuralLiveCenter) ─── 可平行 ──→ P0-2 (NeuralStats)
P0-3 (NeuralApprovalPanel)
P1 (假用戶身份)
P2 (Demo 組件清理)
P3 (Demo 頁面保護)
驗證 + 文件更新
```
**P0-1 和 P0-2 可平行** (無依賴)P0-3 需要 P0-4 先完成 (approvals prop)。
---
## 驗證清單
- [ ] `npm run build` 通過 (無 TypeScript 錯誤)
- [ ] Neural Command 頁面四個 Tab 正常渲染
- [ ] 無資料時顯示 `--` 或 EmptyState (非假數據)
- [ ] 有真實資料時正確顯示
- [ ] 審批操作呼叫真實 API
- [ ] 全域搜尋 `MOCK_` / `DEMO_` / `demo-user` 零殘留
- [ ] `grep -r "136/156\|0\.87\|4\.2s" apps/web/src/` 零殘留