From 5bc346b97ecbfcae5016adf10a47eb4f8ba89bb7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 12:11:41 +0800 Subject: [PATCH] feat(web): drive incident flow summaries from status chain --- apps/web/messages/en.json | 5 + apps/web/messages/zh-TW.json | 5 + apps/web/src/app/[locale]/page.tsx | 56 ++++++++++- .../src/components/incident/incident-card.tsx | 94 +++++++++++++++++-- 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 7ced2ba6..8f27757d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 8101a6b6..bfbc717d 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -421,6 +421,11 @@ "aiProposalPreview": "AI 提案:{action}", "flowCurrentLabel": "目前階段", "flowNextLabel": "下一步", + "flowSourceLabel": "來源", + "flowSourceTruthChain": "truth-chain / ADR-100", + "flowSourceHeuristic": "事件狀態推導", + "flowVerdictLabel": "判定", + "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "已完成", "flowStages": { "alert": "告警收到", diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index ddf7ba22..0620a8db 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -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>({}) + + 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} /> ))} diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index 95eb9d8c..78bc1053 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -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 = { 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 {/* 流程狀態圖 */} - +
- {t('flowCurrentLabel')}: {flowStageLabels[flowStage]} + {t('flowCurrentLabel')}: {currentStageText} / - {t('flowNextLabel')}: {nextStage ? flowStageLabels[nextStage] : t('flowComplete')} + {t('flowNextLabel')}: {nextStageText} + / + + {t('flowSourceLabel')}: {flowSourceText} + + {hasTruthChain && ( + <> + / + + {t('flowVerdictLabel')}: {chainValue(statusChain?.verdict)} + + + )}
{/* Impact 指標列 */}