@@ -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' ,
bg Color : '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 : { bar Color : '#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 : 'm itigating' ,
color : 't ext-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 'wa iting_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 ,
classNam e,
} : 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 ( fals e)
const timeoutRef = useRef < NodeJS.Timeout | null > ( null )
const severityConfig = SEVERITY_CONFIG [ incident . severity ]
const statusConfig = STATUS_CONFIG [ inciden t. status ]
useEffect ( ( ) = > {
return ( ) = > { if ( timeoutRef . current ) clearTimeou t ( 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 }
classNam e ={ 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 ? . stat e === '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 borde r' ,
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 'erro r':
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 }
< / spa n>
) ) }
{ 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' ) }
< / butto n>
< / 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