{html.escape(incident_id)}\n"
+ f"流程:webhook>investigator>ai>safe>executor>verifier>km\n"
f"🎯 資源:{safe_resource}\n"
f"{category_line}"
f"\n"
@@ -4593,6 +4594,7 @@ class TelegramGateway:
"""
# 延遲 import 避免循環依賴 (與 approval_service 同一模式)
from src.repositories.incident_repository import get_incident_repository
+ from src.services.incident_timeline_service import fetch_incident_timeline
try:
repo = get_incident_repository()
@@ -4606,8 +4608,8 @@ class TelegramGateway:
confidence_bar = "█" * int((dc.confidence if dc else 0) * 10) + "░" * (10 - int((dc.confidence if dc else 0) * 10))
lines = [
- f"📋 事件詳情",
- f"",
+ "📋 事件詳情",
+ "",
f"🔖 ID: {html.escape(incident.incident_id)}",
f"📊 狀態: {incident.status.value}",
f"⚡ 嚴重度: {incident.severity.value}",
@@ -4618,7 +4620,7 @@ class TelegramGateway:
if dc:
lines += [
- f"",
+ "",
f"🤖 AI 分析 ({html.escape(dc.model_used)})",
f"💡 {html.escape(dc.hypothesis)}",
f"📈 信心: [{confidence_bar}] {dc.confidence:.0%}",
@@ -4630,7 +4632,7 @@ class TelegramGateway:
from zoneinfo import ZoneInfo
created_taipei = incident.created_at.astimezone(ZoneInfo("Asia/Taipei")) if incident.created_at else incident.created_at
lines += [
- f"",
+ "",
f"🕐 建立: {created_taipei.strftime('%m/%d %H:%M') if created_taipei else 'N/A'}",
]
@@ -4638,6 +4640,14 @@ class TelegramGateway:
fs = incident.frequency_stats
lines.append(f"📉 頻率: 1h={fs.count_1h} 24h={fs.count_24h} 7d={fs.count_7d}")
+ timeline = await fetch_incident_timeline(incident_id)
+ if timeline and timeline.get("ascii_timeline"):
+ lines += [
+ "",
+ "🧭 處理歷程",
+ f"{html.escape(timeline['ascii_timeline'])}",
+ ]
+
await self.send_notification("\n".join(lines))
except Exception as e:
diff --git a/apps/api/tests/test_incident_timeline_service.py b/apps/api/tests/test_incident_timeline_service.py
new file mode 100644
index 00000000..496e8041
--- /dev/null
+++ b/apps/api/tests/test_incident_timeline_service.py
@@ -0,0 +1,25 @@
+from src.services.incident_timeline_service import STAGE_DEFS, format_ascii_timeline
+
+
+def _stages(status_by_stage: dict[str, str]) -> list[dict]:
+ return [
+ {"stage": stage, "status": status_by_stage.get(stage, "skipped")}
+ for stage, _label in STAGE_DEFS
+ ]
+
+
+def test_format_ascii_timeline_skips_unrecorded_stages() -> None:
+ stages = _stages({
+ "webhook": "completed",
+ "ai_router": "success",
+ "executor": "error",
+ "km": "pending",
+ })
+
+ assert format_ascii_timeline(stages) == (
+ "webhook:ok > ai_router:ok > executor:fail > km:wait"
+ )
+
+
+def test_format_ascii_timeline_has_empty_fallback() -> None:
+ assert format_ascii_timeline(_stages({})) == "webhook:skip > ai:skip > executor:skip"
diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx
index e3914ab6..297b1f95 100644
--- a/apps/web/src/components/incident/incident-card.tsx
+++ b/apps/web/src/components/incident/incident-card.tsx
@@ -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