feat(web): drive incident flow summaries from status chain
All checks were successful
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Successful in 4m12s
CD Pipeline / build-and-deploy (push) Successful in 4m34s
CD Pipeline / post-deploy-checks (push) Successful in 1m48s

This commit is contained in:
Your Name
2026-05-20 12:11:41 +08:00
parent 1d6636cd0d
commit 5bc346b97e
4 changed files with 153 additions and 7 deletions

View File

@@ -20,6 +20,7 @@ import { apiClient } from '@/lib/api-client'
import { CURRENT_USER } from '@/lib/constants'
import { useCSRF } from '@/hooks/useCSRF'
import { FlowPipeline, type FlowStage } from './flow-pipeline'
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
// =============================================================================
// Types
@@ -32,6 +33,7 @@ const EXECUTION_TIMEOUT_MS = 30000
export interface IncidentCardProps {
incident: IncidentResponse
decision?: DecisionInfo | null
statusChain?: AwoooPStatusChain | null
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
@@ -83,6 +85,58 @@ function nextFlowStage(stage: FlowStage, isResolved: boolean): FlowStage | null
return FLOW_STAGE_ORDER[index + 1]
}
function statusChainFlowStage(chain?: AwoooPStatusChain | null): FlowStage | null {
if (!chain || chain.fetch_error) return null
const currentStage = String(chain.current_stage ?? '').toLowerCase()
const repairState = String(chain.repair_state ?? '').toLowerCase()
const verdict = String(chain.verdict ?? '').toLowerCase()
const nextStep = String(chain.next_step ?? '').toLowerCase()
if (verdict === 'auto_repaired_verified' || repairState === 'auto_repaired_verified') {
return 'resolved'
}
if (
currentStage.includes('execution') ||
repairState.startsWith('executed') ||
nextStep.includes('verify_execution')
) {
return 'execution'
}
if (
currentStage.includes('approval') ||
repairState.includes('manual_required') ||
nextStep.includes('manual') ||
chain.needs_human === true
) {
return 'approval'
}
if (
currentStage.includes('proposal') ||
currentStage.includes('safe') ||
currentStage.includes('target') ||
currentStage.includes('blast') ||
currentStage.includes('llm')
) {
return 'proposal'
}
if (
currentStage.includes('analysis') ||
currentStage.includes('investigator') ||
currentStage.includes('router')
) {
return 'analysis'
}
if (currentStage.includes('webhook') || currentStage.includes('source') || currentStage.includes('alert')) {
return 'alert'
}
return null
}
function chainValue(value: string | null | undefined, fallback = '--'): string {
const normalized = String(value ?? '').trim()
return normalized || fallback
}
/** 格式化持續時間 */
function formatDuration(createdAt: string | undefined): string {
if (!createdAt) return '--'
@@ -218,7 +272,7 @@ function useApprovalAction(
// Component
// =============================================================================
export function IncidentCard({ incident, decision, onApprovalChange }: IncidentCardProps) {
export function IncidentCard({ incident, decision, statusChain, onApprovalChange }: IncidentCardProps) {
const t = useTranslations('incident.card')
const { csrfToken } = useCSRF()
@@ -232,9 +286,14 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
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, decision)
const heuristicFlowStage = toFlowStage(incidentStatus, incident.severity, decision)
const statusChainStage = statusChainFlowStage(statusChain)
const flowStage = statusChainStage ?? heuristicFlowStage
const isResolved = incidentStatus === 'resolved' || incidentStatus === 'closed'
const nextStage = nextFlowStage(flowStage, isResolved)
const isTruthChainResolved = statusChain?.repair_state === 'auto_repaired_verified' || statusChain?.verdict === 'auto_repaired_verified'
const isFlowResolved = isResolved || isTruthChainResolved
const nextStage = nextFlowStage(flowStage, isFlowResolved)
const hasTruthChain = Boolean(statusChain && !statusChain.fetch_error && statusChain.source_id)
const flowStageLabels: Record<FlowStage, string> = {
alert: t('flowStages.alert'),
detection: t('flowStages.detection'),
@@ -244,6 +303,16 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
execution: t('flowStages.execution'),
resolved: t('flowStages.resolved'),
}
const currentStageText = hasTruthChain
? t('flowTruthChainCurrent', {
stage: chainValue(statusChain?.current_stage),
status: chainValue(statusChain?.stage_status),
})
: flowStageLabels[flowStage]
const nextStageText = hasTruthChain
? chainValue(statusChain?.next_step)
: nextStage ? flowStageLabels[nextStage] : t('flowComplete')
const flowSourceText = hasTruthChain ? t('flowSourceTruthChain') : t('flowSourceHeuristic')
const serviceName = incident.affected_services?.[0] ?? '--'
const duration = formatDuration(incident.created_at)
@@ -461,10 +530,11 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
</div>
{/* 流程狀態圖 */}
<FlowPipeline activeStage={flowStage} isResolved={isResolved} severity={sev} />
<FlowPipeline activeStage={flowStage} isResolved={isFlowResolved} severity={sev} />
<div
data-testid="incident-flow-summary"
data-flow-source={hasTruthChain ? 'truth-chain' : 'heuristic'}
style={{
margin: '0 14px 8px',
padding: '7px 9px',
@@ -480,12 +550,24 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
}}
>
<span>
{t('flowCurrentLabel')}: <strong style={{ color: sevCfg.labelColor }}>{flowStageLabels[flowStage]}</strong>
{t('flowCurrentLabel')}: <strong style={{ color: sevCfg.labelColor }}>{currentStageText}</strong>
</span>
<span style={{ color: '#b0ad9f' }}>/</span>
<span>
{t('flowNextLabel')}: <strong style={{ color: '#141413' }}>{nextStage ? flowStageLabels[nextStage] : t('flowComplete')}</strong>
{t('flowNextLabel')}: <strong style={{ color: '#141413' }}>{nextStageText}</strong>
</span>
<span style={{ color: '#b0ad9f' }}>/</span>
<span>
{t('flowSourceLabel')}: <strong style={{ color: '#555550' }}>{flowSourceText}</strong>
</span>
{hasTruthChain && (
<>
<span style={{ color: '#b0ad9f' }}>/</span>
<span>
{t('flowVerdictLabel')}: <strong style={{ color: statusChain?.needs_human ? '#9f2f25' : '#17602a' }}>{chainValue(statusChain?.verdict)}</strong>
</span>
</>
)}
</div>
{/* Impact 指標列 */}