From e9977f39c18f77121d5d72096abd4ba0bc00705b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 19:57:21 +0800 Subject: [PATCH] fix(web): connect tickets to incident truth chain --- apps/web/messages/en.json | 31 +- apps/web/messages/zh-TW.json | 31 +- .../src/components/panels/TicketsPanel.tsx | 571 ++++++++++++++++-- docs/LOGBOOK.md | 68 +++ 4 files changed, 640 insertions(+), 61 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 0ccd408c..e0a965e0 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1497,8 +1497,37 @@ "status": "狀態", "priority": "優先級", "createdAt": "建立時間", + "signals": "訊號 / 提案", + "actions": "操作", "error": "載入失敗", - "noTickets": "目前無工單" + "noTickets": "目前無工單", + "readOnly": "純讀,不觸發 AI 推理", + "unknownService": "未標示服務", + "serviceCount": "{count} 個受影響服務", + "signalProposal": "signal={signals} / proposal={proposals}", + "openTruth": "真相鏈", + "truth": { + "title": "焦點 Incident 真相鏈", + "unknownTitle": "未標示事件標題", + "emptyIncident": "尚未選取 Incident", + "loading": "讀取真相鏈", + "loadFailed": "此 Incident 尚未取得可判讀的狀態鏈或處理時間線", + "openWorkItems": "Work Items", + "openRuns": "Runs", + "metrics": { + "stages": "階段", + "events": "事件", + "source": "Sentry / SigNoz", + "verification": "驗證" + }, + "flowTitle": "處理流程", + "timelineEmpty": "尚無處理時間線", + "evidenceTitle": "執行與學習證據", + "executor": "Executor", + "ansible": "Ansible / PlayBook", + "mcp": "MCP 調查", + "km": "KM / Learning" + } }, "users": { "title": "操作稽核", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 0ccd408c..e0a965e0 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1497,8 +1497,37 @@ "status": "狀態", "priority": "優先級", "createdAt": "建立時間", + "signals": "訊號 / 提案", + "actions": "操作", "error": "載入失敗", - "noTickets": "目前無工單" + "noTickets": "目前無工單", + "readOnly": "純讀,不觸發 AI 推理", + "unknownService": "未標示服務", + "serviceCount": "{count} 個受影響服務", + "signalProposal": "signal={signals} / proposal={proposals}", + "openTruth": "真相鏈", + "truth": { + "title": "焦點 Incident 真相鏈", + "unknownTitle": "未標示事件標題", + "emptyIncident": "尚未選取 Incident", + "loading": "讀取真相鏈", + "loadFailed": "此 Incident 尚未取得可判讀的狀態鏈或處理時間線", + "openWorkItems": "Work Items", + "openRuns": "Runs", + "metrics": { + "stages": "階段", + "events": "事件", + "source": "Sentry / SigNoz", + "verification": "驗證" + }, + "flowTitle": "處理流程", + "timelineEmpty": "尚無處理時間線", + "evidenceTitle": "執行與學習證據", + "executor": "Executor", + "ansible": "Ansible / PlayBook", + "mcp": "MCP 調查", + "km": "KM / Learning" + } }, "users": { "title": "操作稽核", diff --git a/apps/web/src/components/panels/TicketsPanel.tsx b/apps/web/src/components/panels/TicketsPanel.tsx index 1ec2dbbd..5bb11669 100644 --- a/apps/web/src/components/panels/TicketsPanel.tsx +++ b/apps/web/src/components/panels/TicketsPanel.tsx @@ -6,26 +6,80 @@ * Sprint 5: 從 /tickets/page.tsx 抽取 * 供原始頁面和整合頁面 (/operations) 共用 * - * 建立時間: 2026-04-09 (台北時區) + * 2026-05-31 Codex: 接上 Incident status-chain / timeline,讓 Tickets 不再只是清單。 */ -import { useState, useEffect } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' +import { + ArrowRight, + GitBranch, + ListChecks, + RefreshCw, + SearchCheck, + ShieldCheck, + TriangleAlert, +} from 'lucide-react' + +import { Link } from '@/i18n/routing' +import { + AwoooPStatusChainPanel, + type AwoooPStatusChain, +} from '@/components/awooop/status-chain' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' interface Incident { - id: string - title: string + incident_id?: string + id?: string + title?: string | null severity: string status: string + signal_count?: number + proposal_count?: number created_at: string - affected_service: string | null + updated_at?: string + affected_services?: string[] + affected_service?: string | null } interface IncidentListResponse { incidents: Incident[] - total: number + count?: number + total?: number +} + +interface IncidentTimelineEvent { + stage: string + status: string + title: string + description?: string | null + actor?: string | null + timestamp?: string | null + source_table?: string | null + data?: Record +} + +type IncidentTimelineStage = IncidentTimelineEvent & { + label: string + events?: IncidentTimelineEvent[] +} + +interface IncidentTimelineResponse { + incident_id: string + title: string + status: string + severity: string + started_at?: string | null + updated_at?: string | null + resolved_at?: string | null + affected_services?: string[] + approval_ids?: string[] + timeline: IncidentTimelineStage[] + events: IncidentTimelineEvent[] + ascii_timeline: string } const SEV_COLOR: Record = { @@ -37,84 +91,483 @@ const SEV_COLOR: Record = { const STATUS_COLOR: Record = { open: '#cc2200', + investigating: '#F59E0B', in_progress: '#F59E0B', + mitigating: '#4A90D9', resolved: '#22C55E', closed: '#87867f', } +async function fetchJson(url: string, timeoutMs = 10_000): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + cache: 'no-store', + signal: controller.signal, + }) + if (!response.ok) return null + return (await response.json()) as T + } catch { + return null + } finally { + window.clearTimeout(timeout) + } +} + +function incidentId(incident?: Incident | null) { + return incident?.incident_id ?? incident?.id ?? '' +} + +function incidentServices(incident?: Incident | null) { + const services = incident?.affected_services?.filter(Boolean) ?? [] + if (services.length > 0) return services + return incident?.affected_service ? [incident.affected_service] : [] +} + +function formatLocalTime(value?: string | null) { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '--' + return date.toLocaleString('zh-TW', { + timeZone: 'Asia/Taipei', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function timelineStatusClass(status?: string | null) { + const normalized = String(status ?? '').toLowerCase() + if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('resolved')) { + return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]' + } + if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) { + return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]' + } + if (normalized.includes('warn') || normalized.includes('pending') || normalized.includes('investigating')) { + return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]' + } + return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]' +} + +function FocusedIncidentTruthPanel({ + projectId, + incident, + incidentId, + chain, + timeline, + loading, + error, +}: { + projectId: string + incident?: Incident | null + incidentId: string | null + chain: AwoooPStatusChain | null + timeline: IncidentTimelineResponse | null + loading: boolean + error: string | null +}) { + const t = useTranslations('tickets.truth') + const stages = timeline?.timeline?.filter((stage) => stage.status !== 'skipped') ?? [] + const verifier = timeline?.timeline?.find((stage) => stage.stage === 'verifier') + const sourceCorrelation = chain?.source_refs?.correlation + const importantEvents = (timeline?.events ?? []) + .filter((event) => ( + event.source_table === 'automation_operation_log' || + event.source_table === 'knowledge_entries' || + event.source_table === 'incident_evidence' || + event.source_table === 'alert_operation_log' || + event.stage === 'executor' || + event.stage === 'verifier' || + event.stage === 'km' || + event.stage === 'ai_router' + )) + .slice(-5) + .reverse() + const title = incident?.title || timeline?.title || incidentServices(incident).join(' / ') || t('unknownTitle') + const selectedIncidentId = incidentId || timeline?.incident_id || incidentId + const encodedProjectId = encodeURIComponent(projectId) + const encodedIncidentId = selectedIncidentId ? encodeURIComponent(selectedIncidentId) : '' + + return ( +
+
+
+ + +
+

{t('title')}

+

+ {selectedIncidentId || t('emptyIncident')} +

+

+ {title} +

+
+
+
+ {loading ? ( + + + ) : null} + {selectedIncidentId ? ( + <> + + {t('openWorkItems')} +
+
+ + {error ? ( +
+
+ ) : null} + +
+
+

{t('metrics.stages')}

+

+ {timeline ? stages.length : '--'} +

+
+
+

{t('metrics.events')}

+

+ {timeline ? timeline.events.length : '--'} +

+
+
+

{t('metrics.source')}

+

+ {sourceCorrelation + ? `${sourceCorrelation.direct_ref_total ?? 0}/${sourceCorrelation.candidate_total ?? 0}/${sourceCorrelation.applied_link_total ?? 0}` + : '--'} +

+
+
+

{t('metrics.verification')}

+ + {verifier?.status ?? chain?.verification ?? '--'} + +
+
+ + + +
+
+
+
+ {timeline?.ascii_timeline ? ( +

+ {timeline.ascii_timeline} +

+ ) : ( +

+ {loading ? t('loading') : t('timelineEmpty')} +

+ )} + {stages.length > 0 ? ( +
+ {stages.slice(0, 6).map((stage) => ( +
+
+ {stage.label} + + {stage.status} + +
+

+ {stage.title} +

+
+ ))} +
+ ) : null} +
+ +
+
+
+
+ {[ + [t('executor'), timeline?.timeline?.find((stage) => stage.stage === 'executor')?.title ?? chain?.execution?.latest_operation_type ?? '--'], + [t('ansible'), chain?.execution?.ansible?.latest_playbook_path ?? chain?.execution?.ansible?.latest_catalog_id ?? '--'], + [t('mcp'), timeline?.timeline?.find((stage) => stage.stage === 'investigator')?.title ?? '--'], + [t('km'), timeline?.timeline?.find((stage) => stage.stage === 'km')?.title ?? '--'], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+ {importantEvents.length > 0 ? ( +
+ {importantEvents.map((event, index) => ( +
+
+ + {event.status} + + {event.source_table ?? '--'} +
+

+ {event.title} +

+
+ ))} +
+ ) : null} +
+
+
+ ) +} + export function TicketsPanel() { const t = useTranslations('tickets') + const searchParams = useSearchParams() + const queryIncidentId = searchParams.get('incident_id') + const projectId = searchParams.get('project_id') ?? 'awoooi' const [incidents, setIncidents] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [chain, setChain] = useState(null) + const [timeline, setTimeline] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + const [detailError, setDetailError] = useState(null) useEffect(() => { - fetch(`${API_BASE}/api/v1/incidents`) - .then(r => r.json()) - .then((data: IncidentListResponse) => { + let cancelled = false + setLoading(true) + setError(null) + fetchJson(`${API_BASE}/api/v1/incidents`, 12_000) + .then((data) => { + if (cancelled) return + if (!data) { + setError(t('error')) + setIncidents([]) + setTotal(0) + return + } setIncidents(data.incidents ?? []) - setTotal(data.total ?? 0) - setLoading(false) + setTotal(data.count ?? data.total ?? data.incidents?.length ?? 0) }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : t('error')) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [t]) + + const selectedIncidentId = useMemo(() => { + if (queryIncidentId) return queryIncidentId + return incidentId(incidents[0]) || null + }, [incidents, queryIncidentId]) + + const selectedIncident = useMemo( + () => incidents.find((incident) => incidentId(incident) === selectedIncidentId) ?? null, + [incidents, selectedIncidentId] + ) + + useEffect(() => { + let cancelled = false + if (!selectedIncidentId) { + setChain(null) + setTimeline(null) + setDetailError(null) + setDetailLoading(false) + return () => { + cancelled = true + } + } + const targetIncidentId = selectedIncidentId + + async function loadIncidentTruth() { + setDetailLoading(true) + setDetailError(null) + const encodedProjectId = encodeURIComponent(projectId) + const encodedIncidentId = encodeURIComponent(targetIncidentId) + const [statusChain, incidentTimeline] = await Promise.all([ + fetchJson( + `${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`, + 12_000 + ), + fetchJson( + `${API_BASE}/api/v1/incidents/${encodedIncidentId}/timeline`, + 12_000 + ), + ]) + if (cancelled) return + setChain(statusChain) + setTimeline(incidentTimeline) + if (!statusChain && !incidentTimeline) { + setDetailError(t('truth.loadFailed')) + } + setDetailLoading(false) + } + + loadIncidentTruth() + return () => { + cancelled = true + } + }, [projectId, selectedIncidentId, t]) return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+
+

{t('title')}

+

{t('subtitle')}

-
-
- - {t('title')} ({loading ? '...' : total}) + + + +
+
+
+ + {t('title')} ({loading ? '...' : total}) +
+ +
{loading ? ( -
{t('loading')}
+
{t('loading')}
) : error ? ( -
{t('error')}
+
{error}
) : incidents.length === 0 ? ( -
{t('noTickets')}
+
{t('noTickets')}
) : ( - - - - {[t('id'), t('title_col'), t('priority'), t('status'), t('createdAt')].map(col => ( - - ))} - - - - {incidents.map((inc) => ( - - - - - - +
+
{col}
{inc.id.slice(0, 8)} -
{inc.title}
- {inc.affected_service && ( -
{inc.affected_service}
- )} -
- - {inc.severity} - - - - {inc.status} - - - {new Date(inc.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} -
+ + + {[t('id'), t('title_col'), t('priority'), t('status'), t('signals'), t('createdAt'), t('actions')].map((col) => ( + + ))} - ))} - -
+ {col} +
+ + + {incidents.map((incident) => { + const rowIncidentId = incidentId(incident) + const isSelected = rowIncidentId === selectedIncidentId + const services = incidentServices(incident) + const title = incident.title || services.join(' / ') || t('unknownService') + return ( + + + + {rowIncidentId.slice(0, 16)} +
)} -
+
) } diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 263afbb5..04fe8612 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,71 @@ +## 2026-05-31|Tickets 接上 Incident 真相鏈與處理時間線 + +**背景**: + +- 使用者指出 Telegram 告警、詳情、歷史與前端頁面無法讓人判斷同一個 Incident 是否重複、AI 流程跑到哪、是否已自動修復、是否卡在人工介入。 +- Work Items 已能看到 `INC-20260530-0DD83C` 的 status-chain / timeline,但 `/tickets` 仍只有事件清單,且前端欄位還停在舊 schema:讀 `total/id/title/affected_service`,後端實際回 `count/incident_id/affected_services`。 + +**本次調整**: + +- `apps/web/src/components/panels/TicketsPanel.tsx`: + - 修正 incident list schema 對齊,清單總數改讀 `count`,列資料改讀 `incident_id`、`affected_services`、`signal_count`、`proposal_count`。 + - 支援 `?project_id=awoooi&incident_id=...` 焦點事件視圖。 + - 焦點事件讀取: + - `/api/v1/platform/status-chain?project_id=...&incident_id=...` + - `/api/v1/incidents/{incident_id}/timeline` + - 新增「焦點 Incident 真相鏈」區塊:階段數、事件數、Sentry / SigNoz 關聯、驗證狀態、AwoooP 狀態鏈、處理流程、Executor / Ansible / MCP / KM 證據。 + - 每列事件可直接打開同一頁焦點視圖與 Work Items 真相鏈。 +- `apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`: + - 補齊 Tickets 真相鏈、純讀提示、訊號 / 提案、Work Items / Runs 導航文案。 + +**驗證**: + +```text +python3 -m json.tool apps/web/messages/zh-TW.json / en.json -> pass +git diff --check -> pass +pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-tickets-truth-chain-20260531.tsbuildinfo -> pass +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build -> pass + +local browser with read-only production API proxy: + /zh-TW/tickets?project_id=awoooi&incident_id=INC-20260530-0DD83C + hasTickets=true + hasTruth=true + hasStatusChain=true + hasTimeline=true + hasEvidence=true + hasIncident=true + hasWorkItemsLink=true + hasRunsLink=true + hasReadOnly=true + hasLoadFailed=false + hasListRows=true + listCount=504 + canScroll=true + horizontalOverflow=false + screenshot=/tmp/awoooi-tickets-truth-chain-local.png + + 焦點 Incident 實際顯示: + title=DockerContainerMemoryLimitPressure + stages=10 + events=18 + status_chain_stage=execution_succeeded / success + repair_state=executed + verification=degraded + next_step=manual_verify_or_repair + mcp_gateway=17/19 success, failed=2, blocked=0 + top_mcp=ssh_get_container_status + ansible=infra/ansible/playbooks/188-ai-web.yml + ansible_rc=2 + km=1 +``` + +**目前整體進度**: + +- Telegram / AwoooP / 前端真相鏈可見性:約 82%;Runs、Work Items、Tickets 已接上同一組 Incident status-chain / timeline 證據。 +- 前端 AI 自動化管理介面同步:約 91%;Tickets 從空清單型頁面升級為可追問單一 Incident 的操作頁。 +- 整體 AI 自動化飛輪:約 75%;可觀測性和人工接手透明度推進,但仍不可宣稱 24h 完整全自動修復閉環。 +- 24h 完整 AI Agent 自動修復 production claim:0%;仍需 production 統計連續證明。 + ## 2026-05-31|IwoooS 首屏資安工作雷達 **背景**: