feat(flywheel): expose incident processing timeline
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 10m56s

This commit is contained in:
Your Name
2026-04-29 23:38:30 +08:00
parent dae0aa2312
commit 4a57c2d04f
12 changed files with 984 additions and 12 deletions

View File

@@ -15,7 +15,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import type { IncidentResponse, DecisionInfo } from '@/lib/api-client'
import type { IncidentResponse, DecisionInfo, IncidentTimelineResponse } from '@/lib/api-client'
import { apiClient } from '@/lib/api-client'
import { CURRENT_USER } from '@/lib/constants'
import { useCSRF } from '@/hooks/useCSRF'
@@ -73,6 +73,34 @@ function formatDuration(createdAt: string | undefined): string {
}
}
function statusColor(status: string): string {
switch (status) {
case 'success':
case 'completed':
return '#16a34a'
case 'warning':
return '#d97000'
case 'error':
return '#cc2200'
case 'pending':
return '#87867f'
default:
return '#4A90D9'
}
}
function formatTimelineTime(value: string | null): string {
if (!value) return '--'
try {
return new Date(value).toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
})
} catch {
return '--'
}
}
// =============================================================================
// 2026-04-02 Claude Code: Phase R-UI2 handleApprove/Reject 重複邏輯抽取
// useApprovalAction — 統一 setup/teardownloading 狀態、timeout、error 處理
@@ -139,6 +167,10 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
const [currentProposalId, setCurrentProposalId] = useState<string | null>(null)
const [aiExpanded, setAiExpanded] = useState(false)
const [timelineExpanded, setTimelineExpanded] = useState(false)
const [timelineData, setTimelineData] = useState<IncidentTimelineResponse | null>(null)
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineError, setTimelineError] = useState<string | null>(null)
const incidentStatus = incident.status as string
const sev = incident.severity as keyof typeof SEV_CONFIG
@@ -154,6 +186,13 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
const decisionAction = decision?.proposal_data?.action ?? ''
const decisionReasoning = decision?.proposal_data?.reasoning ?? ''
useEffect(() => {
setTimelineExpanded(false)
setTimelineData(null)
setTimelineError(null)
setTimelineLoading(false)
}, [incident.incident_id])
// ── 解析 proposalIdapprove/reject 共用前置步驟)─────────────────────────
const resolveProposalId = useCallback(async (): Promise<string> => {
let approvalId = currentProposalId
@@ -208,6 +247,23 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
const handleApprove = approveHook.execute
const handleReject = rejectHook.execute
const handleTimelineToggle = useCallback(async () => {
const nextExpanded = !timelineExpanded
setTimelineExpanded(nextExpanded)
if (!nextExpanded || timelineData || timelineLoading) return
setTimelineLoading(true)
setTimelineError(null)
try {
const timeline = await apiClient.getIncidentTimeline(incident.incident_id)
setTimelineData(timeline)
} catch (error) {
setTimelineError(error instanceof Error ? error.message : String(error))
} finally {
setTimelineLoading(false)
}
}, [incident.incident_id, timelineData, timelineExpanded, timelineLoading])
// ── 授權按鈕渲染 ───────────────────────────────────────────────────────────
const renderApproveButtons = () => {
switch (buttonState) {
@@ -349,6 +405,108 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
</span>
</div>
<button
onClick={handleTimelineToggle}
style={{
margin: '0 14px 10px',
padding: '7px 0',
background: '#f8f7f1',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
fontSize: 12,
color: '#4A90D9',
width: 'calc(100% - 28px)',
textAlign: 'center' as const,
fontFamily: 'inherit',
}}
>
<span>{timelineExpanded ? '▾' : '▸'} </span>
{timelineData?.ascii_timeline && (
<span style={{
color: '#87867f',
maxWidth: 260,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{timelineData.ascii_timeline}
</span>
)}
</button>
{timelineExpanded && (
<div style={{
margin: '0 10px 10px',
padding: '8px',
background: '#fff',
border: '0.5px solid #e0ddd4',
borderLeft: '3px solid #4A90D9',
borderRadius: 6,
}}>
{timelineLoading && (
<div style={{ fontSize: 12, color: '#87867f' }}>...</div>
)}
{timelineError && (
<div style={{ fontSize: 12, color: '#cc2200' }}>{timelineError}</div>
)}
{!timelineLoading && !timelineError && timelineData && (
<>
<div style={{
fontSize: 11,
color: '#87867f',
fontFamily: 'var(--font-body), monospace',
marginBottom: 7,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{timelineData.ascii_timeline}
</div>
<div style={{ display: 'grid', gap: 5 }}>
{timelineData.timeline
.filter(stage => stage.status !== 'skipped')
.map(stage => (
<div
key={stage.stage}
style={{
display: 'grid',
gridTemplateColumns: '68px 1fr auto',
gap: 6,
alignItems: 'center',
minHeight: 24,
fontSize: 11,
}}
>
<span style={{
color: statusColor(stage.status),
fontWeight: 700,
textTransform: 'uppercase',
}}>
{stage.stage}
</span>
<span style={{
color: '#141413',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={stage.description ?? stage.title}>
{stage.title}
</span>
<span style={{ color: '#b0ad9f', fontFamily: 'var(--font-body), monospace' }}>
{formatTimelineTime(stage.timestamp)}
</span>
</div>
))}
</div>
</>
)}
</div>
)}
{/* AI 提案行(可展開)*/}
{decisionAction && (
<>