Files
awoooi/apps/web/src/components/ai/hitl-section.tsx
OG T 2e9ccf4a26 fix(lint): 清理所有 ESLint 警告 (61→0)
- 修復未使用變數 (prefix with _)
- 修復 type-only imports
- 修復 react-hooks/exhaustive-deps (useMemo + 依賴補齊)
- 修復 no-explicit-any (eslint-disable 標記)
- 移除未使用的 imports

涉及組件:
- demo/page, layout, page (主頁面)
- ai/* (OpenClaw, HITL, ThinkingStream)
- approval/* (ApprovalCard, LiveApprovalPanel)
- dashboard/* (HostCard, LiveDashboard, ConnectionStatus)
- incident/* (DualStateIncidentCard, ThinkingTerminal)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 17:06:58 +08:00

501 lines
18 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 } from '@/lib/constants/z-index'
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, setClawbotStatus] = 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
setClawbotStatus('intercepting')
await new Promise((r) => setTimeout(r, 800))
// Phase 2: Analyzing
setClawbotStatus('analyzing')
await new Promise((r) => setTimeout(r, 1200))
// Phase 3: Generating
setClawbotStatus('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
setClawbotStatus('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)
setClawbotStatus('patrolling')
} finally {
setIsSimulating(false)
setTimeout(() => {
setClawbotStatus('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, 'demo-user', currentUserName, 'Approved via demo UI')
// 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, 'demo-user', 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-mono 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-mono 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-mono">
[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-mono 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-mono 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-mono 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-mono text-[10px] text-nothing-gray-400">:</span>
<button
onClick={() => setCurrentUserRole('devops')}
className={cn(
'px-2 py-1 rounded text-[10px] font-mono 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-mono 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-mono 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-mono text-sm text-status-critical font-semibold uppercase">
{accessDeniedModal.riskLevel} RISK
</span>
</div>
{/* Message */}
<p className="text-nothing-gray-600 font-mono text-sm mb-2">
</p>
<p className="text-nothing-gray-500 font-mono 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-mono 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-mono 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-mono 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