Files
awoooi/apps/web/src/components/ai/hitl-section.tsx
OG T 246587a401 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>
2026-04-07 12:53:52 +08:00

501 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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