feat(incident): redesign IncidentCard with FlowPipeline, migrate auth logic from DualState

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-01 20:00:16 +08:00
parent 8b99dde05e
commit 823e2b95a3
4 changed files with 360 additions and 247 deletions

View File

@@ -277,7 +277,9 @@
"brainAnalyzing": ">_ Brain analyzing...",
"decisionReady": ">_ Decision ready (Tier {tier})",
"waitingCommander": ">_ Awaiting commander approval (Tier {tier})",
"suggestedAction": "> Suggested action:"
"suggestedAction": "> Suggested action:",
"authorize": "Authorize",
"reject": "Reject"
}
},
"status": {
@@ -554,4 +556,4 @@
}
}
}
}
}

View File

@@ -278,7 +278,9 @@
"brainAnalyzing": ">_ 大腦分析中...",
"decisionReady": ">_ 決策就緒 (Tier {tier})",
"waitingCommander": ">_ 等待統帥親核 (Tier {tier})",
"suggestedAction": "> 建議行動:"
"suggestedAction": "> 建議行動:",
"authorize": "授權",
"reject": "拒絕"
}
},
"status": {
@@ -555,4 +557,4 @@
}
}
}
}
}

View File

@@ -1,273 +1,382 @@
'use client'
/**
* IncidentCard - Phase 7 事件卡片
* ================================
* IncidentCard - AI中心 v6 事件卡片
* =====================================
* Anthropic Warmth 米色系 + OpenClaw Blue
* - 頂邊條顏色 = 嚴重度P0=紅/P2=橙/P3=藍/OK=綠)
* - FlowPipeline 7節點 + Q版龍蝦
* - 授權邏輯從 DualStateIncidentCard (Phase 6.5) 移植
* handleApprove / handleReject / CSRF / 30s 超時重試
*
* Nothing.tech 視覺規範:
* - 純白底色 (bg-white)
* - 極細淺灰邊框 (border border-gray-200)
* - 無圓角或微圓角 (rounded-sm)
* - 嚴禁陰影 (shadow-none)
*
* 統帥鐵律: 禁止假數據!所有資料必須來自真實 API
* 統帥鐵律: 禁止假數據!無數據顯示 "--"
* @deprecated dual-state-incident-card.tsx (Phase 6.5 舊風格,此元件取代後刪除)
*/
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import type { IncidentResponse } from '@/lib/api-client'
import { AlertTriangle, Clock, Server, ChevronRight } from 'lucide-react'
import type { IncidentResponse, DecisionInfo } from '@/lib/api-client'
import { apiClient } from '@/lib/api-client'
import { useCSRF } from '@/hooks/useCSRF'
import { FlowPipeline, type FlowStage } from './flow-pipeline'
// =============================================================================
// Severity Config
// Types
// =============================================================================
const SEVERITY_CONFIG = {
P0: {
label: 'P0',
color: 'text-red-600',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
dotColor: 'bg-red-500',
},
P1: {
label: 'P1',
color: 'text-orange-600',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
dotColor: 'bg-orange-500',
},
P2: {
label: 'P2',
color: 'text-amber-600',
bgColor: 'bg-amber-50',
borderColor: 'border-amber-200',
dotColor: 'bg-amber-500',
},
P3: {
label: 'P3',
color: 'text-gray-600',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
dotColor: 'bg-gray-400',
},
type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout'
const EXECUTION_TIMEOUT_MS = 30000
export interface IncidentCardProps {
incident: IncidentResponse
decision?: DecisionInfo | null
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
// =============================================================================
// Severity / Status Config
// =============================================================================
const SEV_CONFIG = {
P0: { barColor: '#cc2200', label: 'P0', labelBg: '#ffeaea', labelColor: '#cc2200' },
P1: { barColor: '#d97757', label: 'P1', labelBg: '#fff0ea', labelColor: '#b05030' },
P2: { barColor: '#4A90D9', label: 'P2', labelBg: '#eaf4ff', labelColor: '#2a6cb0' },
P3: { barColor: '#22C55E', label: 'P3', labelBg: '#eafff2', labelColor: '#166534' },
} as const
const STATUS_CONFIG = {
investigating: {
label: 'investigating',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
mitigating: {
label: 'mitigating',
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
resolved: {
label: 'resolved',
color: 'text-green-600',
bgColor: 'bg-green-50',
},
closed: {
label: 'closed',
color: 'text-gray-500',
bgColor: 'bg-gray-50',
},
} as const
/** 根據 incident status 對應 FlowStage */
function toFlowStage(status: string, severity: string): FlowStage {
switch (status) {
case 'new': return 'alert'
case 'investigating': return 'detection'
case 'analyzing': return 'analysis'
case 'proposal_generated': return 'proposal'
case 'waiting_approval': return 'approval'
case 'executing': return 'execution'
case 'resolved': return 'resolved'
default: return severity === 'P0' ? 'alert' : 'detection'
}
}
/** 格式化持續時間 */
function formatDuration(createdAt: string | undefined): string {
if (!createdAt) return '--'
try {
const ms = Date.now() - new Date(createdAt).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 60) return `${mins}m`
return `${Math.floor(mins / 60)}h${mins % 60}m`
} catch {
return '--'
}
}
// =============================================================================
// Component
// =============================================================================
interface IncidentCardProps {
incident: IncidentResponse
onClick?: () => void
className?: string
}
export function IncidentCard({ incident, decision, onApprovalChange }: IncidentCardProps) {
const t = useTranslations('incident.card')
const { csrfToken } = useCSRF()
export function IncidentCard({
incident,
onClick,
className,
}: IncidentCardProps) {
const t = useTranslations('incident')
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [currentProposalId, setCurrentProposalId] = useState<string | null>(null)
const [aiExpanded, setAiExpanded] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const severityConfig = SEVERITY_CONFIG[incident.severity]
const statusConfig = STATUS_CONFIG[incident.status]
useEffect(() => {
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) }
}, [])
// 格式化時間
const createdAt = new Date(incident.created_at)
const timeAgo = getTimeAgo(createdAt)
const incidentStatus = incident.status as string
const sev = incident.severity as keyof typeof SEV_CONFIG
const sevCfg = SEV_CONFIG[sev] ?? SEV_CONFIG.P3
const flowStage = toFlowStage(incidentStatus, incident.severity)
const isResolved = incidentStatus === 'resolved'
const isWaitingApproval = incidentStatus === 'waiting_approval'
return (
<div
onClick={onClick}
className={cn(
// Nothing.tech 核心規範
'bg-white',
'border border-gray-200',
'rounded-sm',
'shadow-none',
// 互動狀態
'transition-colors duration-150',
onClick && 'cursor-pointer hover:bg-gray-50 hover:border-gray-300',
// 佈局 (p-6 靈魂注入)
'p-6',
className
)}
>
{/* Header: ID + Severity Badge */}
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-2 min-w-0">
{/* Severity Dot */}
<span
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
severityConfig.dotColor,
(incident.severity === 'P0' || incident.severity === 'P1') &&
'animate-pulse'
)}
/>
{/* Incident ID */}
<span className="font-mono text-sm text-ink truncate">
{incident.incident_id}
const serviceName = incident.affected_services?.[0] ?? '--'
const duration = formatDuration(incident.created_at)
const isDecisionReady = decision?.state === 'ready' || !!currentProposalId
const isAnalyzing = decision?.state === 'analyzing'
const decisionAction = decision?.proposal_data?.action ?? ''
const decisionReasoning = decision?.proposal_data?.reasoning ?? ''
// ── handleApprove移植自 DualStateIncidentCard Phase 6.5)──────────────
const handleApprove = useCallback(async () => {
if (!isDecisionReady || buttonState === 'loading') return
setButtonState('loading')
setErrorMessage(null)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setButtonState('timeout')
setErrorMessage(t('timeoutMessage'))
}, EXECUTION_TIMEOUT_MS)
try {
let approvalId = currentProposalId
if (!approvalId && decision?.token) {
const proposalResult = await apiClient.generateProposal(incident.incident_id)
if (!proposalResult.success || !proposalResult.proposal) {
throw new Error(proposalResult.message || 'Failed to generate proposal')
}
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
}
if (!approvalId) throw new Error('No approval ID available')
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken)
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
const approvalStatus = result.approval?.status?.toLowerCase()
if (approvalStatus === 'approved') {
setButtonState('approved')
onApprovalChange?.(approvalId, 'approved')
} else {
setButtonState('idle')
}
} catch (error) {
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : String(error))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
// ── handleReject移植自 DualStateIncidentCard Phase 6.5)────────────────
const handleReject = useCallback(async () => {
if (!isDecisionReady || buttonState === 'loading') return
setButtonState('loading')
setErrorMessage(null)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setButtonState('timeout')
setErrorMessage(t('timeoutMessage'))
}, EXECUTION_TIMEOUT_MS)
try {
let approvalId = currentProposalId
if (!approvalId && decision?.token) {
const proposalResult = await apiClient.generateProposal(incident.incident_id)
if (proposalResult.success && proposalResult.proposal) {
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
}
}
if (!approvalId) throw new Error('No approval ID available')
await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken)
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
setButtonState('rejected')
onApprovalChange?.(approvalId, 'rejected')
} catch (error) {
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : String(error))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
// ── 授權按鈕渲染 ───────────────────────────────────────────────────────────
const renderApproveButtons = () => {
switch (buttonState) {
case 'loading':
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: '#87867f' }}>
<span style={{ width: 8, height: 8, border: '1.5px solid #e0ddd4', borderTopColor: '#d97757', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
{t('executing')}
</span>
</div>
{/* Severity Badge (Dot Matrix 靈魂注入) */}
<span
className={cn(
'px-3 py-1 text-sm font-dot-matrix font-bold rounded-sm border',
severityConfig.bgColor,
severityConfig.color,
severityConfig.borderColor
)}
>
{severityConfig.label}
</span>
</div>
{/* Status Badge */}
<div className="mb-4">
<span
className={cn(
'inline-flex items-center px-2.5 py-1 text-xs font-mono font-medium rounded-sm',
statusConfig.bgColor,
statusConfig.color
)}
>
{t(`status.${statusConfig.label}`)}
</span>
</div>
{/* Affected Services */}
<div className="mb-4">
<div className="flex items-center gap-1.5 text-xs text-ink-secondary mb-2">
<Server className="w-3 h-3" />
<span>{t('affectedServices')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{incident.affected_services.slice(0, 3).map((service) => (
<span
key={service}
className="px-2 py-1 text-xs font-mono bg-gray-100 text-ink rounded-sm"
)
case 'approved':
return <span style={{ fontSize: 9, color: '#22C55E', fontWeight: 700 }}> {t('approved')}</span>
case 'rejected':
return <span style={{ fontSize: 9, color: '#cc2200', fontWeight: 700 }}> {t('rejected')}</span>
case 'error':
case 'timeout':
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 9, color: buttonState === 'timeout' ? '#F59E0B' : '#cc2200' }}>
{buttonState === 'timeout' ? t('timeout') : t('error')}
</span>
<button
onClick={() => { setButtonState('idle'); setErrorMessage(null) }}
style={{ fontSize: 9, padding: '2px 7px', background: '#f0efe8', border: '0.5px solid #e0ddd4', borderRadius: 10, cursor: 'pointer', color: '#87867f' }}
>
{service}
</span>
))}
{incident.affected_services.length > 3 && (
<span className="px-2 py-1 text-xs text-ink-secondary">
+{incident.affected_services.length - 3}
</span>
)}
</div>
</div>
{t('retry')}
</button>
</div>
)
default:
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
onClick={handleApprove}
disabled={!isDecisionReady}
style={{
padding: '5px 16px',
background: '#141413',
color: '#f5f4ed',
border: 'none',
borderRadius: 20,
fontSize: 10,
cursor: isDecisionReady ? 'pointer' : 'not-allowed',
opacity: isDecisionReady ? 1 : 0.4,
fontFamily: 'inherit',
}}
title={isAnalyzing ? t('analyzing') : decisionAction || t('authorizeExecution')}
>
{t('authorize')}
</button>
<button
onClick={handleReject}
disabled={!isDecisionReady}
style={{
padding: '5px 16px',
background: 'transparent',
color: '#87867f',
border: '0.5px solid #e0ddd4',
borderRadius: 20,
fontSize: 10,
cursor: isDecisionReady ? 'pointer' : 'not-allowed',
opacity: isDecisionReady ? 1 : 0.4,
fontFamily: 'inherit',
}}
>
{t('reject')}
</button>
{isAnalyzing && <span style={{ fontSize: 9, color: '#F59E0B' }}></span>}
</div>
)
}
}
{/* Footer: Stats + Time */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-4 text-xs text-ink-secondary">
{/* Signal Count */}
<span className="flex items-center gap-1.5 font-mono">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-dot-matrix text-ink">{incident.signal_count}</span> {t('signals')}
</span>
{/* Proposal Count */}
{incident.proposal_count > 0 && (
<span className="flex items-center gap-1.5 font-mono text-purple-600">
<span className="font-dot-matrix">{incident.proposal_count}</span> {t('proposals')}
</span>
)}
</div>
{/* Time + Arrow */}
<div className="flex items-center gap-1.5 text-xs text-ink-secondary">
<Clock className="w-3.5 h-3.5" />
<span className="font-mono">{timeAgo}</span>
{onClick && <ChevronRight className="w-3.5 h-3.5" />}
</div>
</div>
</div>
)
}
// =============================================================================
// Grid Container
// =============================================================================
interface IncidentCardGridProps {
children: React.ReactNode
className?: string
}
export function IncidentCardGrid({ children, className }: IncidentCardGridProps) {
return (
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-3',
className
<div style={{
background: '#faf9f3',
border: '0.5px solid #e0ddd4',
borderRadius: 8,
overflow: 'hidden',
marginBottom: 8,
}}>
{/* 頂邊條 3px */}
<div style={{ height: 3, background: sevCfg.barColor, borderRadius: '8px 8px 0 0' }} />
{/* 標頭列:嚴重度 + 服務標籤 + 時間 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px 4px' }}>
<span style={{
fontSize: 9, fontWeight: 700, padding: '2px 8px',
background: sevCfg.labelBg, color: sevCfg.labelColor,
borderRadius: 10, flexShrink: 0,
}}>
{sevCfg.label}
</span>
<span style={{
fontSize: 9, color: '#87867f', background: '#ece9e0',
border: '0.5px solid #dedad0', padding: '2px 7px', borderRadius: 10,
}}>
{serviceName}
</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#b0ad9f' }}>
{duration}
</span>
</div>
{/* 事件標題 */}
<div style={{ padding: '2px 10px 4px', fontSize: 11, fontWeight: 600, color: '#141413', lineHeight: 1.4 }}>
{(incident as IncidentResponse & { description?: string }).description ?? '--'}
</div>
{/* INC-ID */}
<div style={{ padding: '0 10px 4px', fontSize: 9, color: '#b0ad9f', fontFamily: 'monospace' }}>
{incident.incident_id}
</div>
{/* 流程狀態圖 */}
<FlowPipeline activeStage={flowStage} isResolved={isResolved} />
{/* Impact 指標列 */}
<div style={{
margin: '0 10px 6px',
padding: '5px 8px',
background: '#f0efe8',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
display: 'flex',
gap: 12,
flexWrap: 'wrap' as const,
}}>
<span style={{ fontSize: 9, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.affected_services?.length ?? 0}</strong>
</span>
<span style={{ fontSize: 9, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.signal_count ?? '--'}</strong>
</span>
<span style={{ fontSize: 9, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.status}</strong>
</span>
</div>
{/* AI 提案行(可展開)*/}
{decisionAction && (
<>
<div
onClick={() => setAiExpanded(v => !v)}
style={{
margin: '0 10px 6px',
padding: '5px 8px',
background: '#f0efe8',
border: '0.5px solid #e0ddd4',
borderLeft: '3px solid rgba(217,119,87,0.5)',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
fontSize: 9,
color: '#87867f',
}}
>
<span style={{ color: '#d97757' }}></span>
<span>AI : {decisionAction.slice(0, 50)}{decisionAction.length > 50 ? '...' : ''}</span>
<span style={{ marginLeft: 'auto' }}>{aiExpanded ? '▲' : '▼'}</span>
</div>
{aiExpanded && (
<div style={{
margin: '0 10px 6px',
padding: '8px',
background: '#f0efe8',
border: '0.5px solid #e0ddd4',
borderLeft: '3px solid #d97757',
borderRadius: 6,
fontSize: 9,
color: '#87867f',
}}>
<div style={{ color: '#141413', marginBottom: 4 }}>{decisionAction}</div>
{decisionReasoning && (
<div style={{ color: '#87867f', fontStyle: 'italic' }}>
💡 {decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}
</div>
)}
</div>
)}
</>
)}
>
{children}
{/* 授權按鈕(僅 waiting_approval 狀態)*/}
{isWaitingApproval && (
<div style={{ padding: '6px 10px 10px', display: 'flex', justifyContent: 'flex-end' }}>
{renderApproveButtons()}
{errorMessage && (
<span style={{ fontSize: 8, color: '#cc2200', marginLeft: 6, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={errorMessage}>
{errorMessage}
</span>
)}
</div>
)}
{/* spin keyframe */}
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</div>
)
}
// =============================================================================
// Empty State
// =============================================================================
export function IncidentEmptyState() {
const t = useTranslations('incident')
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 font-medium">
{t('emptyState')}
</p>
<p className="text-xs text-gray-400 mt-1">
{t('emptyStateDescription')}
</p>
</div>
)
}
// =============================================================================
// Utilities
// =============================================================================
function getTimeAgo(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
export default IncidentCard

View File

@@ -2,7 +2,7 @@
* Incident Components - Phase 7 + 6.5a
*/
export { IncidentCard, IncidentCardGrid, IncidentEmptyState } from './incident-card'
export { IncidentCard } from './incident-card'
export {
DualStateIncidentCard,
type DualStateIncidentCardProps,