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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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} 個待審核項目"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
export { IncidentCard } from './incident-card'
|
||||
export {
|
||||
ThinkingTerminal,
|
||||
DEMO_DECISION_CHAIN,
|
||||
type DecisionChain,
|
||||
type ReasoningStep,
|
||||
type ThinkingTerminalProps,
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from './z-index'
|
||||
export * from './shortcuts'
|
||||
export * from './animations'
|
||||
export * from './sse-states'
|
||||
export * from './user'
|
||||
|
||||
13
apps/web/src/lib/constants/user.ts
Normal file
13
apps/web/src/lib/constants/user.ts
Normal 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
|
||||
@@ -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 待實作)
|
||||
|
||||
| 項目 | 狀態 | 說明 |
|
||||
|
||||
187
docs/superpowers/plans/2026-04-07-sprint-f-fake-data-purge.md
Normal file
187
docs/superpowers/plans/2026-04-07-sprint-f-fake-data-purge.md
Normal 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/` 零殘留
|
||||
Reference in New Issue
Block a user