feat(flywheel): expose incident processing timeline
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 10m56s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 10m56s
This commit is contained in:
@@ -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/teardown:loading 狀態、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])
|
||||
|
||||
// ── 解析 proposalId(approve/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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user