Files
awoooi/apps/web/src/components/genui/ApprovalCard.tsx
OG T e5ded3b3f2 feat(phase19): OmniTerminal + GenUI + Hybrid SSE 架構實作 (Wave 0-2)
Phase 19 OmniTerminal MVP 完成:
- Wave 0: Backend (Hybrid SSE POST→GET 架構)
- Wave 1: Frontend (OmniTerminal 狀態機 + GenUI Registry)
- Wave 2: UI 組件 (8 個 GenUI 動態卡片)

ADR 文檔:
- ADR-031: OmniTerminal SSE 架構
- ADR-032: GenUI 動態渲染框架
- ADR-033: K3s HA 架構設計

GenUI 組件:
- GenUIRenderer, K8sPodStatusCard, SentryErrorCard
- MetricsSummaryCard, IncidentTimelineCard
- TraceWaterfallCard, ApprovalCard, NuclearKeyButton

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

165 lines
6.0 KiB
TypeScript

/**
* ApprovalCard - 核鑰授權卡片
* ============================
* Phase 19.5 - Nuclear Key UX 整合
*
* 高風險操作需要儀式感授權:
* - 長按確認 (NuclearKeyButton)
* - 風險等級視覺化
* - 支援 Y 鍵快捷鍵
*
* @see ADR-032 GenUI Dynamic Rendering
* @see AWOOOI_AGENTIC_WORKSPACE_ROADMAP.md - Nuclear Key UX
* @author Claude Code (首席架構師)
* @version 2.0.0 - Nuclear Key 整合
* @date 2026-03-28 (台北時間)
*/
import React, { useState, useCallback } from 'react'
import { ShieldAlert, Terminal, Clock, User } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { NuclearKeyButton } from './NuclearKeyButton'
interface ApprovalCardProps {
data: {
approvalId: string
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
kubectl: string
requestedBy?: string
requestedAt?: string
description?: string
}
}
/** 風險等級映射 (API → NuclearKeyButton) */
const mapRiskLevel = (level: string): 'low' | 'medium' | 'high' | 'critical' => {
const mapping: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
CRITICAL: 'critical',
}
return mapping[level] ?? 'medium'
}
export const ApprovalCard: React.FC<ApprovalCardProps> = ({ data }) => {
const t = useTranslations('nuclearKey')
const tApproval = useTranslations('approval')
const [isExecuting, setIsExecuting] = useState(false)
const [executionResult, setExecutionResult] = useState<'success' | 'error' | null>(null)
const isCritical = data.riskLevel === 'CRITICAL' || data.riskLevel === 'HIGH'
const nuclearRiskLevel = mapRiskLevel(data.riskLevel)
const handleAuthorize = useCallback(async () => {
setIsExecuting(true)
setExecutionResult(null)
try {
// 實際呼叫審批 API
const response = await fetch(`/api/v1/approvals/${data.approvalId}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (response.ok) {
setExecutionResult('success')
} else {
setExecutionResult('error')
}
} catch {
setExecutionResult('error')
} finally {
setIsExecuting(false)
}
}, [data.approvalId])
return (
<div className="w-full max-w-2xl bg-white border-2 border-nothing-black shadow-lg rounded-sm overflow-hidden mt-3 sm:mt-4 mb-2">
{/* Header - Responsive layout */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b-2 border-nothing-black bg-nothing-gray-50 p-2 sm:p-3 gap-2">
<div className="flex items-center gap-2">
<ShieldAlert className={`${isCritical ? 'text-red-500' : 'text-blue-500'} shrink-0`} size={18} />
<span className="font-['VT323'] text-lg sm:text-xl font-bold uppercase tracking-wider text-nothing-black truncate">
<span className="hidden sm:inline">Approval Required // </span>{data.approvalId}
</span>
</div>
<div className={`px-2 py-0.5 sm:py-1 text-xs font-mono font-bold border-2 self-start sm:self-auto shrink-0 ${
isCritical ? 'border-red-500 text-red-500' : 'border-blue-500 text-blue-500'
}`}>
{data.riskLevel}
</div>
</div>
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4">
{/* Metadata */}
{(data.requestedBy || data.requestedAt) && (
<div className="flex flex-wrap gap-2 sm:gap-4 text-xs font-mono text-gray-500">
{data.requestedBy && (
<div className="flex items-center gap-1">
<User size={12} />
<span>{data.requestedBy}</span>
</div>
)}
{data.requestedAt && (
<div className="flex items-center gap-1">
<Clock size={12} />
<span>{data.requestedAt}</span>
</div>
)}
</div>
)}
{/* Description */}
{data.description && (
<div>
<Label>Description</Label>
<p className="mt-1 text-xs sm:text-sm text-gray-700">{data.description}</p>
</div>
)}
{/* Target Action */}
<div>
<Label>Target Action</Label>
<div className="mt-1 bg-nothing-gray-100 p-2 sm:p-3 rounded-sm border border-gray-200 shadow-inner flex items-start gap-2">
<Terminal size={14} className="text-gray-400 mt-0.5 flex-shrink-0 sm:w-4 sm:h-4" />
<code className="font-['VT323'] text-lg sm:text-xl text-[#4A90D9] break-all">
{data.kubectl}
</code>
</div>
</div>
{/* Nuclear Key Authorization */}
<div className="pt-1 sm:pt-2">
{executionResult === 'success' ? (
<div className="w-full py-3 sm:py-4 bg-green-50 text-green-700 border-2 border-green-500 font-mono font-bold text-xs sm:text-sm uppercase flex justify-center items-center gap-2 rounded-sm">
<span className="text-lg sm:text-xl"></span>
<span className="hidden sm:inline">{t('executionAuthorized')}</span>
<span className="sm:hidden">{t('authorized')}</span>
</div>
) : executionResult === 'error' ? (
<div className="w-full py-3 sm:py-4 bg-red-50 text-red-700 border-2 border-red-500 font-mono font-bold text-xs sm:text-sm uppercase flex justify-center items-center gap-2 rounded-sm">
<span className="text-lg sm:text-xl"></span>
{t('executionFailed')}
</div>
) : (
<NuclearKeyButton
label={tApproval('card.authorizeExecution')}
riskLevel={nuclearRiskLevel}
onConfirm={handleAuthorize}
disabled={isExecuting}
showShortcut={true}
/>
)}
</div>
</div>
</div>
)
}
const Label = ({ children }: { children: React.ReactNode }) => (
<span className="text-xs uppercase font-mono font-bold text-gray-500 tracking-wider">
{children}
</span>
)