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>
501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* HITLSection - 人機協作審批區域
|
||
* ================================
|
||
* Phase 1: AI 思考 → 動態卡片對接
|
||
*
|
||
* Features:
|
||
* - OpenClaw AI 思考流視覺化
|
||
* - 動態 Approval Card 渲染 (100% 後端資料)
|
||
* - Slide Up 動畫過渡
|
||
* - **Phase 15: SSE 即時更新** (取代輪詢)
|
||
*/
|
||
|
||
import { useState, useCallback } from 'react'
|
||
import { useTranslations } from 'next-intl'
|
||
import { cn } from '@/lib/utils'
|
||
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 {
|
||
useApprovalStore,
|
||
usePendingApprovals,
|
||
toFrontendApproval,
|
||
} from '@/stores/approval.store'
|
||
import { useApprovalSSE } from '@/hooks/useApprovalSSE'
|
||
import { useTimelineStore } from '@/stores/timeline.store'
|
||
import { ActionTimeline } from '@/components/timeline'
|
||
import { GlassCard, GlassCardTitle } from '@/components/ui/glass-card'
|
||
import { ShieldCheck, Plus, Loader2, Lock, ShieldX, AlertTriangle } from 'lucide-react'
|
||
|
||
// =============================================================================
|
||
// API Configuration (統帥鐵律: 禁止任何 Fallback IP)
|
||
// =============================================================================
|
||
|
||
const getApiBaseUrl = (): string => {
|
||
if (typeof window === 'undefined') return ''
|
||
const url = process.env.NEXT_PUBLIC_API_URL
|
||
if (!url) {
|
||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||
return ''
|
||
}
|
||
return url
|
||
}
|
||
|
||
const API_BASE_URL = getApiBaseUrl()
|
||
|
||
// =============================================================================
|
||
// Phase 3: RBAC Permission System (企業護城河)
|
||
// =============================================================================
|
||
|
||
type UserRole = 'viewer' | 'developer' | 'devops' | 'admin' | 'cto' | 'ciso' | 'ceo'
|
||
|
||
const REQUIRED_ROLE_FOR_RISK: Record<RiskLevel, UserRole[]> = {
|
||
low: ['developer', 'devops', 'admin', 'cto', 'ciso', 'ceo'],
|
||
medium: ['devops', 'admin', 'cto', 'ciso', 'ceo'],
|
||
high: ['admin', 'cto', 'ciso', 'ceo'],
|
||
critical: ['cto', 'ciso', 'ceo'], // Critical 需要 CTO/CISO 等級
|
||
}
|
||
|
||
function canSignApproval(userRole: UserRole, riskLevel: RiskLevel): boolean {
|
||
const allowedRoles = REQUIRED_ROLE_FOR_RISK[riskLevel]
|
||
return allowedRoles.includes(userRole)
|
||
}
|
||
|
||
function getRequiredRolesDisplay(riskLevel: RiskLevel): string {
|
||
const roles = REQUIRED_ROLE_FOR_RISK[riskLevel]
|
||
return roles.map((r) => r.toUpperCase()).join(' / ')
|
||
}
|
||
|
||
// =============================================================================
|
||
// Types
|
||
// =============================================================================
|
||
|
||
interface HITLSectionProps {
|
||
locale: string
|
||
className?: string
|
||
}
|
||
|
||
// =============================================================================
|
||
// Component
|
||
// =============================================================================
|
||
|
||
export function HITLSection({ locale: _locale, className }: HITLSectionProps) {
|
||
const t = useTranslations('demo')
|
||
const tApproval = useTranslations('approval')
|
||
|
||
// Store
|
||
const { fetchPending, signApproval, rejectApproval } = useApprovalStore()
|
||
const pendingApprovals = usePendingApprovals()
|
||
const addTimelineEvent = useTimelineStore((state) => state.addEvent)
|
||
|
||
// Phase 15: SSE 即時更新 (取代輪詢)
|
||
useApprovalSSE({ autoConnect: true })
|
||
|
||
// AI thinking state
|
||
const [openclawStatus, setOpenClawStatus] = useState<OpenClawStatus>('patrolling')
|
||
const [currentAlertType, setCurrentAlertType] = useState<string | undefined>()
|
||
const [showCards, setShowCards] = useState(true)
|
||
const [isSimulating, setIsSimulating] = useState(false)
|
||
|
||
// Phase 3: 模擬當前登入者角色 (可切換測試權限)
|
||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('devops')
|
||
const currentUserName = currentUserRole === 'cto' ? 'CTO Admin' : 'Demo DevOps'
|
||
|
||
// Phase 3: Access Denied 模態框狀態
|
||
const [accessDeniedModal, setAccessDeniedModal] = useState<{
|
||
show: boolean
|
||
riskLevel: RiskLevel
|
||
requiredRoles: string
|
||
} | null>(null)
|
||
|
||
// Simulate webhook alert (triggers AI thinking flow)
|
||
const simulateAlert = useCallback(async (alertType: string, severity: 'critical' | 'warning') => {
|
||
setIsSimulating(true)
|
||
setShowCards(false)
|
||
setCurrentAlertType(alertType)
|
||
|
||
// Phase 1: Intercepting
|
||
setOpenClawStatus('intercepting')
|
||
await new Promise((r) => setTimeout(r, 800))
|
||
|
||
// Phase 2: Analyzing
|
||
setOpenClawStatus('analyzing')
|
||
await new Promise((r) => setTimeout(r, 1200))
|
||
|
||
// Phase 3: Generating
|
||
setOpenClawStatus('generating')
|
||
|
||
// Call actual webhook API
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/api/v1/webhooks/alerts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
alert_type: alertType,
|
||
severity,
|
||
source: 'demo-ui',
|
||
target_resource: alertType === 'k8s_pod_crash' ? 'harbor-core-7d4b8c9f5-xk2m3' : 'nginx-ingress-abc123',
|
||
namespace: 'default',
|
||
message: `Demo alert: ${alertType}`,
|
||
metrics: { cpu_percent: 95, sigma_deviation: 3.5 },
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`)
|
||
}
|
||
|
||
const result = await response.json()
|
||
console.log('[HITL] Webhook response:', result)
|
||
|
||
// Phase 4: Log SYSTEM alert received
|
||
addTimelineEvent({
|
||
type: 'system',
|
||
status: 'warning',
|
||
title: `接收到 ${alertType.replace('_', ' ')} 告警`,
|
||
riskLevel: severity === 'critical' ? 'critical' : 'medium',
|
||
})
|
||
|
||
// Phase 4: Log AGENT analysis complete
|
||
addTimelineEvent({
|
||
type: 'agent',
|
||
status: 'info',
|
||
title: `OpenClaw 分析完成,提出 ${severity.toUpperCase()} 提案`,
|
||
actor: 'OpenClaw',
|
||
actorRole: 'ai',
|
||
riskLevel: severity === 'critical' ? 'critical' : 'medium',
|
||
approvalId: result.approval_id,
|
||
})
|
||
|
||
// Phase 4: Complete
|
||
setOpenClawStatus('complete')
|
||
await new Promise((r) => setTimeout(r, 800))
|
||
|
||
// Refresh approvals list
|
||
await fetchPending()
|
||
|
||
// Show cards with animation
|
||
setShowCards(true)
|
||
|
||
} catch (error) {
|
||
console.error('[HITL] Webhook failed:', error)
|
||
setOpenClawStatus('patrolling')
|
||
} finally {
|
||
setIsSimulating(false)
|
||
setTimeout(() => {
|
||
setOpenClawStatus('patrolling')
|
||
setCurrentAlertType(undefined)
|
||
}, 2000)
|
||
}
|
||
}, [fetchPending, addTimelineEvent])
|
||
|
||
// Handle approval sign with RBAC check (Phase 3) + Timeline Logging (Phase 4)
|
||
const handleApprove = useCallback(async (id: string, riskLevel?: RiskLevel) => {
|
||
// Phase 3: 權限檢查
|
||
const level = riskLevel || 'medium'
|
||
if (!canSignApproval(currentUserRole, level)) {
|
||
// 顯示 Access Denied 模態框
|
||
setAccessDeniedModal({
|
||
show: true,
|
||
riskLevel: level,
|
||
requiredRoles: getRequiredRolesDisplay(level),
|
||
})
|
||
|
||
// Phase 4: Log SECURITY Access Denied (紅色微光)
|
||
addTimelineEvent({
|
||
type: 'security',
|
||
status: 'error',
|
||
title: `${currentUserRole.toUpperCase()} 嘗試簽核,觸發 Access Denied`,
|
||
description: `需要 ${getRequiredRolesDisplay(level)} 角色`,
|
||
actor: currentUserName,
|
||
actorRole: currentUserRole,
|
||
riskLevel: level,
|
||
approvalId: id,
|
||
})
|
||
|
||
console.warn('[HITL] Access Denied:', {
|
||
user: currentUserName,
|
||
role: currentUserRole,
|
||
riskLevel: level,
|
||
requiredRoles: getRequiredRolesDisplay(level),
|
||
})
|
||
return
|
||
}
|
||
|
||
// Phase 4: Log HUMAN signature (綠色微光)
|
||
addTimelineEvent({
|
||
type: 'human',
|
||
status: 'success',
|
||
title: `${currentUserRole.toUpperCase()} 成功簽核批准`,
|
||
actor: currentUserName,
|
||
actorRole: currentUserRole,
|
||
riskLevel: level,
|
||
approvalId: id,
|
||
})
|
||
|
||
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) {
|
||
addTimelineEvent({
|
||
type: 'exec',
|
||
status: 'success',
|
||
title: '多重簽章完成,指令已執行',
|
||
actor: 'OpenClaw',
|
||
actorRole: 'ai',
|
||
riskLevel: level,
|
||
approvalId: id,
|
||
})
|
||
}
|
||
|
||
await fetchPending()
|
||
}, [signApproval, fetchPending, currentUserRole, currentUserName, addTimelineEvent])
|
||
|
||
// Handle rejection
|
||
const handleReject = useCallback(async (id: string) => {
|
||
await rejectApproval(id, CURRENT_USER.id, currentUserName, 'Rejected via HITL Panel')
|
||
await fetchPending()
|
||
}, [rejectApproval, fetchPending, currentUserName])
|
||
|
||
return (
|
||
<section className={cn('space-y-6', className)}>
|
||
{/* Header - Lab-White Style */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<ShieldCheck className="w-6 h-6 text-claw-blue" />
|
||
<h2 className="font-dot-matrix text-2xl text-nothing-gray-800 tracking-wide">
|
||
{t('hitlRealApi')}
|
||
</h2>
|
||
<span className="px-2 py-0.5 rounded text-xs font-dot-matrix bg-claw-blue/10 text-claw-blue border border-claw-blue/20">
|
||
Multi-Sig
|
||
</span>
|
||
</div>
|
||
|
||
{/* Trigger Buttons */}
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => simulateAlert('k8s_pod_crash', 'critical')}
|
||
disabled={isSimulating}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||
'font-body text-xs font-medium',
|
||
'bg-status-critical/10 text-status-critical border border-status-critical/20',
|
||
'hover:bg-status-critical/20 transition-colors',
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
>
|
||
{isSimulating ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<Plus className="w-3 h-3" />
|
||
)}
|
||
{t('addCritical')}
|
||
</button>
|
||
<button
|
||
onClick={() => simulateAlert('high_cpu', 'warning')}
|
||
disabled={isSimulating}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||
'font-body text-xs font-medium',
|
||
'bg-status-warning/10 text-status-warning border border-status-warning/20',
|
||
'hover:bg-status-warning/20 transition-colors',
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
>
|
||
{isSimulating ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<Plus className="w-3 h-3" />
|
||
)}
|
||
{t('addMedium')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content Grid */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||
{/* OpenClaw Panel (Left) */}
|
||
<div className="lg:col-span-1">
|
||
<OpenClawPanel
|
||
status={openclawStatus}
|
||
alertType={currentAlertType}
|
||
/>
|
||
</div>
|
||
|
||
{/* Approval Cards (Center) */}
|
||
<div className="lg:col-span-3">
|
||
{pendingApprovals.length === 0 ? (
|
||
<GlassCard className="flex flex-col items-center justify-center py-12">
|
||
<ShieldCheck className="w-12 h-12 text-nothing-gray-300 mb-4" />
|
||
<GlassCardTitle className="text-nothing-gray-500">
|
||
{tApproval('noApprovals')}
|
||
</GlassCardTitle>
|
||
<p className="text-sm text-nothing-gray-400 mt-2 font-body">
|
||
[SYS] All clear. Awaiting alerts...
|
||
</p>
|
||
</GlassCard>
|
||
) : (
|
||
<div className={cn(
|
||
'space-y-4',
|
||
showCards ? 'animate-slide-in-up' : 'opacity-0'
|
||
)}>
|
||
{pendingApprovals.map((approval) => {
|
||
const frontendApproval = toFrontendApproval(approval)
|
||
const hasPermission = canSignApproval(currentUserRole, frontendApproval.riskLevel)
|
||
|
||
return (
|
||
<div key={approval.id} className="relative">
|
||
<ApprovalCard
|
||
request={frontendApproval}
|
||
onApprove={(id) => handleApprove(id, frontendApproval.riskLevel)}
|
||
onReject={handleReject}
|
||
holdDuration={approval.risk_level === 'critical' ? 2000 : 1000}
|
||
/>
|
||
|
||
{/* Phase 3: Permission Warning Badge */}
|
||
{!hasPermission && (
|
||
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-1 bg-status-critical/10 border border-status-critical/30 rounded text-[10px] font-body text-status-critical z-10">
|
||
<Lock className="w-3 h-3" />
|
||
需要 {getRequiredRolesDisplay(frontendApproval.riskLevel)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Action Timeline (Right) - Phase 4 */}
|
||
<div className="lg:col-span-1">
|
||
<GlassCard className="h-full" padding="md">
|
||
<ActionTimeline maxDisplay={8} />
|
||
</GlassCard>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phase 3 + 4: Current User Role Display with Switcher */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
{/* Current User */}
|
||
<div className="flex items-center gap-2 px-3 py-1.5 bg-nothing-gray-100 rounded-lg">
|
||
<Lock className="w-3.5 h-3.5 text-nothing-gray-500" />
|
||
<span className="font-body text-xs text-nothing-gray-600">
|
||
登入身份: <span className="font-bold text-nothing-gray-800">{currentUserName}</span>
|
||
</span>
|
||
<span className={cn(
|
||
'px-2 py-0.5 rounded text-[10px] font-body font-bold uppercase',
|
||
currentUserRole === 'cto' || currentUserRole === 'ciso' || currentUserRole === 'ceo'
|
||
? 'bg-status-healthy/10 text-status-healthy border border-status-healthy/30'
|
||
: currentUserRole === 'admin'
|
||
? 'bg-claw-blue/10 text-claw-blue border border-claw-blue/30'
|
||
: 'bg-status-warning/10 text-status-warning border border-status-warning/30'
|
||
)}>
|
||
{currentUserRole}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Role Switcher (Demo Only) */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-body text-[10px] text-nothing-gray-400">切換:</span>
|
||
<button
|
||
onClick={() => setCurrentUserRole('devops')}
|
||
className={cn(
|
||
'px-2 py-1 rounded text-[10px] font-body font-medium transition-colors',
|
||
currentUserRole === 'devops'
|
||
? 'bg-status-warning/20 text-status-warning'
|
||
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||
)}
|
||
>
|
||
DevOps
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentUserRole('cto')}
|
||
className={cn(
|
||
'px-2 py-1 rounded text-[10px] font-body font-medium transition-colors',
|
||
currentUserRole === 'cto'
|
||
? 'bg-status-healthy/20 text-status-healthy'
|
||
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||
)}
|
||
>
|
||
CTO
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Debug Info */}
|
||
<div className="text-[10px] font-body text-nothing-gray-400">
|
||
Pending: {pendingApprovals.length} | Status: {openclawStatus}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phase 3: Access Denied Modal (Nothing.tech Style) */}
|
||
{accessDeniedModal?.show && (
|
||
<div
|
||
className="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||
style={{ zIndex: Z_INDEX.DIALOG }}
|
||
>
|
||
<GlassCard className="w-full max-w-md mx-4" variant="elevated" padding="lg">
|
||
<div className="text-center py-6">
|
||
{/* Icon */}
|
||
<div className="mx-auto w-16 h-16 rounded-full bg-status-critical/10 flex items-center justify-center mb-4">
|
||
<ShieldX className="w-8 h-8 text-status-critical" />
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<h3 className="font-dot-matrix text-2xl text-status-critical mb-2">
|
||
ACCESS DENIED
|
||
</h3>
|
||
|
||
{/* Risk Level Badge */}
|
||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-status-critical/10 border border-status-critical/30 mb-4">
|
||
<AlertTriangle className="w-4 h-4 text-status-critical" />
|
||
<span className="font-body text-sm text-status-critical font-semibold uppercase">
|
||
{accessDeniedModal.riskLevel} RISK
|
||
</span>
|
||
</div>
|
||
|
||
{/* Message */}
|
||
<p className="text-nothing-gray-600 font-body text-sm mb-2">
|
||
此操作需要更高權限簽核
|
||
</p>
|
||
<p className="text-nothing-gray-500 font-body text-xs mb-6">
|
||
您的角色: <span className="text-status-warning font-bold">{currentUserRole.toUpperCase()}</span>
|
||
</p>
|
||
|
||
{/* Required Roles */}
|
||
<div className="bg-nothing-gray-50 rounded-lg p-4 mb-6">
|
||
<p className="text-[10px] text-nothing-gray-500 font-body uppercase tracking-wider mb-2">
|
||
需要以下角色之一
|
||
</p>
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
{accessDeniedModal.requiredRoles.split(' / ').map((role) => (
|
||
<span
|
||
key={role}
|
||
className="px-2 py-1 rounded bg-status-healthy/10 text-status-healthy font-body text-xs font-bold border border-status-healthy/30"
|
||
>
|
||
{role}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Button */}
|
||
<button
|
||
onClick={() => setAccessDeniedModal(null)}
|
||
className="w-full px-6 py-3 rounded-xl font-body text-sm font-semibold bg-nothing-gray-100 text-nothing-gray-700 hover:bg-nothing-gray-200 transition-colors"
|
||
>
|
||
了解,返回
|
||
</button>
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default HITLSection
|