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

@@ -420,6 +420,11 @@
"aiProposalPreview": "AI Proposal: {action}",
"flowCurrentLabel": "Current stage",
"flowNextLabel": "Next step",
"flowSourceLabel": "Source",
"flowSourceTruthChain": "truth-chain / ADR-100",
"flowSourceHeuristic": "incident status heuristic",
"flowVerdictLabel": "Verdict",
"flowTruthChainCurrent": "{stage} / {status}",
"flowComplete": "Complete",
"flowStages": {
"alert": "Alert received",

View File

@@ -421,6 +421,11 @@
"aiProposalPreview": "AI 提案:{action}",
"flowCurrentLabel": "目前階段",
"flowNextLabel": "下一步",
"flowSourceLabel": "來源",
"flowSourceTruthChain": "truth-chain / ADR-100",
"flowSourceHeuristic": "事件狀態推導",
"flowVerdictLabel": "判定",
"flowTruthChainCurrent": "{stage} / {status}",
"flowComplete": "已完成",
"flowStages": {
"alert": "告警收到",

View File

@@ -34,8 +34,10 @@ import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card
import { AIModelStatus } from '@/components/shared/ai-model-status'
import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card'
import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card'
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
const STATUS_CHAIN_PREFETCH_LIMIT = 25
// =============================================================================
// Tab 2: 告警 & 授權 (串接真實 API)
@@ -128,7 +130,9 @@ function ActivityStreamTab() {
try {
const data = JSON.parse(e.data)
setEvents(prev => [{ ...data, _time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, ...prev].slice(0, 50))
} catch {}
} catch {
return
}
}
es.onerror = () => setConnected(false)
return () => es.close()
@@ -600,10 +604,59 @@ export default function Home({ params }: { params: { locale: string } }) {
incidents,
isLoading: isIncidentsLoading,
error: incidentsError,
lastUpdated: incidentsLastUpdated,
} = useIncidents({
pollInterval: 15000,
enablePolling: true,
})
const statusChainIncidentKey = incidents
?.slice(0, STATUS_CHAIN_PREFETCH_LIMIT)
.map(incident => incident.incident_id)
.join('|') ?? ''
const [statusChains, setStatusChains] = useState<Record<string, AwoooPStatusChain | null>>({})
useEffect(() => {
const incidentIds = statusChainIncidentKey
? statusChainIncidentKey.split('|').filter(Boolean)
: []
if (incidentIds.length === 0) {
setStatusChains({})
return
}
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 12000)
Promise.all(
incidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => {
const params = new URLSearchParams({ project_id: 'awoooi' })
params.append('incident_id', incidentId)
try {
const response = await fetch(`${API_BASE}/api/v1/platform/status-chain?${params.toString()}`, {
cache: 'no-store',
signal: controller.signal,
})
if (!response.ok) return [incidentId, null]
return [incidentId, await response.json() as AwoooPStatusChain]
} catch {
return [incidentId, null]
}
})
)
.then(entries => {
if (controller.signal.aborted) return
setStatusChains(Object.fromEntries(entries))
})
.catch(() => {
if (!controller.signal.aborted) setStatusChains({})
})
.finally(() => window.clearTimeout(timeout))
return () => {
window.clearTimeout(timeout)
controller.abort()
}
}, [statusChainIncidentKey, incidentsLastUpdated])
// ── Metrics 計算 ────────────────────────────────────────────────────────────
@@ -889,6 +942,7 @@ export default function Home({ params }: { params: { locale: string } }) {
key={incident.incident_id}
incident={incident}
decision={incident.decision}
statusChain={statusChains[incident.incident_id] ?? null}
/>
))}
</div>

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 指標列 */}