From 5a23dec72ef8ee4db1df908bf9c1aeac6067d917 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Sun, 31 May 2026 21:41:55 +0800
Subject: [PATCH] fix(web): connect monitoring to incident evidence chain
---
apps/web/messages/en.json | 66 ++-
apps/web/messages/zh-TW.json | 66 ++-
.../src/components/panels/MonitoringPanel.tsx | 477 +++++++++++++++++-
3 files changed, 603 insertions(+), 6 deletions(-)
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 2ee713b4..01446622 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1327,10 +1327,74 @@
"hostStatus": "主機狀態 (FOUR-HOST ARCHITECTURE)",
"serviceList": "服務清單",
"serviceName": "服務名稱",
+ "serviceHealth": "服務健康",
+ "service": "服務",
"status": "狀態",
"latency": "延遲",
"uptime": "可用率",
- "lastCheck": "最後檢查"
+ "lastCheck": "最後檢查",
+ "incidentFocus": {
+ "emptyValue": "尚無資料",
+ "title": "焦點 Incident 監控證據鏈",
+ "subtitle": "{incidentId}|{incidentTitle}",
+ "loading": "正在讀取 Incident status-chain 與 timeline...",
+ "loadFailed": "焦點 Incident 資料讀取失敗:{error}",
+ "boundary": "此區塊只讀取監控證據,不會自動標記 Sentry/SigNoz 已匹配,也不會觸發修復或靜音告警。最新 PlayBook:{playbook};executor:{executor}。",
+ "human": {
+ "yes": "需要人工",
+ "no": "未要求人工"
+ },
+ "links": {
+ "workItems": "工作項",
+ "runs": "Runs",
+ "approvals": "審批",
+ "authorizations": "授權",
+ "tickets": "Tickets"
+ },
+ "sourceStatuses": {
+ "linked": "已匹配 provider event",
+ "candidateFound": "找到候選但未套用",
+ "providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
+ "missing": "缺少 provider 證據",
+ "noIncidentContext": "缺少 Incident context",
+ "fetchFailed": "讀取 provider 證據失敗"
+ },
+ "sourceReasons": {
+ "providerHeartbeatNoMatch": "Sentry / SigNoz 有心跳,但此 Incident 尚未匹配 provider event",
+ "noMatchingProviderSourceEvent": "沒有找到可對應此 Incident 的 provider source event",
+ "noIncidentIds": "缺少 Incident ID,無法比對 provider",
+ "incidentNotFound": "找不到此 Incident 的 provider 關聯",
+ "fetchFailed": "provider 關聯查詢失敗"
+ },
+ "tiles": {
+ "sourceRefs": "Source refs",
+ "sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
+ "sourceRefsDetail": "direct {direct}、candidate {candidate}、applied {applied}",
+ "provider": "Sentry / SigNoz",
+ "providerDetail": "{reason};provider events {providerEvents}",
+ "mcp": "MCP Gateway",
+ "mcpValue": "{success} / {total}",
+ "mcpDetail": "failed {failed}、blocked {blocked}、policy {policy}",
+ "ansible": "Ansible",
+ "ansibleDetail": "mode {mode}、rc {rc}、apply {apply}",
+ "km": "KM",
+ "kmDetail": "verification {verification};next {next}",
+ "handoff": "交接狀態"
+ },
+ "providerEvidence": {
+ "title": "Provider 匹配狀態",
+ "rawIdsHidden": "raw id 已收斂",
+ "summary": "目前判斷:{status}。原因:{reason}。",
+ "counts": "direct {direct} / candidate {candidate} / applied {applied}",
+ "latest": "latest event {event};heartbeat {heartbeat}"
+ },
+ "timeline": {
+ "title": "Incident Timeline 寫入證據",
+ "summary": "status {status};severity {severity};stages {stages}",
+ "sourceTable": "source_table:{table}",
+ "empty": "此 Incident 尚未回傳 timeline stage"
+ }
+ }
},
"services": {
"title": "服務目錄",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 2ee713b4..01446622 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1327,10 +1327,74 @@
"hostStatus": "主機狀態 (FOUR-HOST ARCHITECTURE)",
"serviceList": "服務清單",
"serviceName": "服務名稱",
+ "serviceHealth": "服務健康",
+ "service": "服務",
"status": "狀態",
"latency": "延遲",
"uptime": "可用率",
- "lastCheck": "最後檢查"
+ "lastCheck": "最後檢查",
+ "incidentFocus": {
+ "emptyValue": "尚無資料",
+ "title": "焦點 Incident 監控證據鏈",
+ "subtitle": "{incidentId}|{incidentTitle}",
+ "loading": "正在讀取 Incident status-chain 與 timeline...",
+ "loadFailed": "焦點 Incident 資料讀取失敗:{error}",
+ "boundary": "此區塊只讀取監控證據,不會自動標記 Sentry/SigNoz 已匹配,也不會觸發修復或靜音告警。最新 PlayBook:{playbook};executor:{executor}。",
+ "human": {
+ "yes": "需要人工",
+ "no": "未要求人工"
+ },
+ "links": {
+ "workItems": "工作項",
+ "runs": "Runs",
+ "approvals": "審批",
+ "authorizations": "授權",
+ "tickets": "Tickets"
+ },
+ "sourceStatuses": {
+ "linked": "已匹配 provider event",
+ "candidateFound": "找到候選但未套用",
+ "providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
+ "missing": "缺少 provider 證據",
+ "noIncidentContext": "缺少 Incident context",
+ "fetchFailed": "讀取 provider 證據失敗"
+ },
+ "sourceReasons": {
+ "providerHeartbeatNoMatch": "Sentry / SigNoz 有心跳,但此 Incident 尚未匹配 provider event",
+ "noMatchingProviderSourceEvent": "沒有找到可對應此 Incident 的 provider source event",
+ "noIncidentIds": "缺少 Incident ID,無法比對 provider",
+ "incidentNotFound": "找不到此 Incident 的 provider 關聯",
+ "fetchFailed": "provider 關聯查詢失敗"
+ },
+ "tiles": {
+ "sourceRefs": "Source refs",
+ "sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
+ "sourceRefsDetail": "direct {direct}、candidate {candidate}、applied {applied}",
+ "provider": "Sentry / SigNoz",
+ "providerDetail": "{reason};provider events {providerEvents}",
+ "mcp": "MCP Gateway",
+ "mcpValue": "{success} / {total}",
+ "mcpDetail": "failed {failed}、blocked {blocked}、policy {policy}",
+ "ansible": "Ansible",
+ "ansibleDetail": "mode {mode}、rc {rc}、apply {apply}",
+ "km": "KM",
+ "kmDetail": "verification {verification};next {next}",
+ "handoff": "交接狀態"
+ },
+ "providerEvidence": {
+ "title": "Provider 匹配狀態",
+ "rawIdsHidden": "raw id 已收斂",
+ "summary": "目前判斷:{status}。原因:{reason}。",
+ "counts": "direct {direct} / candidate {candidate} / applied {applied}",
+ "latest": "latest event {event};heartbeat {heartbeat}"
+ },
+ "timeline": {
+ "title": "Incident Timeline 寫入證據",
+ "summary": "status {status};severity {severity};stages {stages}",
+ "sourceTable": "source_table:{table}",
+ "empty": "此 Incident 尚未回傳 timeline stage"
+ }
+ }
},
"services": {
"title": "服務目錄",
diff --git a/apps/web/src/components/panels/MonitoringPanel.tsx b/apps/web/src/components/panels/MonitoringPanel.tsx
index 1820f6b1..7adfc932 100644
--- a/apps/web/src/components/panels/MonitoringPanel.tsx
+++ b/apps/web/src/components/panels/MonitoringPanel.tsx
@@ -11,15 +11,36 @@
*/
import { useState, useEffect, useCallback, useRef } from 'react'
+import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
import { useHosts } from '@/stores/dashboard.store'
import { GlobalPulseChart } from '@/components/charts/global-pulse-chart'
import { HostGrid } from '@/components/infra/host-grid'
import { cn } from '@/lib/utils'
+import { Link } from '@/i18n/routing'
import {
- Monitor, RefreshCw, AlertCircle,
- CheckCircle2, XCircle, Minus,
+ AwoooPStatusChainPanel,
+ type AwoooPStatusChain,
+} from '@/components/awooop/status-chain'
+import type { IncidentTimelineResponse } from '@/lib/api-client'
+import {
+ Activity,
+ BookOpenCheck,
+ CheckCircle2,
+ ExternalLink,
+ GitBranch,
+ Link2,
+ ListChecks,
+ Minus,
+ Monitor,
+ RadioTower,
+ RefreshCw,
+ ShieldCheck,
+ TriangleAlert,
+ Wrench,
+ AlertCircle,
+ XCircle,
} from 'lucide-react'
// =============================================================================
@@ -43,6 +64,15 @@ interface DashboardResponse {
timestamp: string
}
+interface IncidentFocusState {
+ chain: AwoooPStatusChain | null
+ timeline: IncidentTimelineResponse | null
+ loading: boolean
+ error: string | null
+}
+
+type EvidenceTone = 'success' | 'warning' | 'blocked' | 'neutral'
+
// =============================================================================
// Helpers
// =============================================================================
@@ -52,6 +82,50 @@ const getApiBaseUrl = () => {
return process.env.NEXT_PUBLIC_API_URL ?? ''
}
+async function fetchJson(url: string, signal?: AbortSignal): Promise {
+ const response = await fetch(url, { signal })
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return response.json()
+}
+
+function valueOrEmpty(value: unknown, emptyLabel: string) {
+ if (value === null || value === undefined || value === '') return emptyLabel
+ return String(value)
+}
+
+function formatDateTime(value: string | null | undefined, emptyLabel: string) {
+ if (!value) return emptyLabel
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return value
+ return new Intl.DateTimeFormat('zh-TW', {
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(date)
+}
+
+function compactPath(value: string | null | undefined, emptyLabel: string) {
+ if (!value) return emptyLabel
+ if (value.length <= 42) return value
+ return `...${value.slice(-39)}`
+}
+
+function evidenceToneClass(tone: EvidenceTone) {
+ if (tone === 'success') return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]'
+ if (tone === 'blocked') return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]'
+ if (tone === 'warning') return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]'
+ return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]'
+}
+
+function providerLabel(provider: string) {
+ const normalized = provider.toLowerCase()
+ if (normalized === 'sentry') return 'Sentry'
+ if (normalized === 'signoz') return 'SigNoz'
+ return provider
+}
+
const STATUS_ICON = {
healthy: ,
warning: ,
@@ -90,6 +164,324 @@ function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string)
)
}
+function EvidenceTile({
+ icon: Icon,
+ label,
+ value,
+ detail,
+ tone = 'neutral',
+}: {
+ icon: typeof Monitor
+ label: string
+ value: string | number
+ detail: string
+ tone?: EvidenceTone
+}) {
+ return (
+
+
+
+ {value}
+
+
+ {detail}
+
+
+ )
+}
+
+function IncidentObservabilityFocus({
+ incidentId,
+ projectId,
+ state,
+}: {
+ incidentId: string
+ projectId: string
+ state: IncidentFocusState
+}) {
+ const t = useTranslations('monitoring.incidentFocus')
+ const chain = state.chain
+ const timeline = state.timeline
+ const emptyLabel = t('emptyValue')
+ const encodedProjectId = encodeURIComponent(projectId)
+ const encodedIncidentId = encodeURIComponent(incidentId)
+ const sourceCorrelation = chain?.source_refs?.correlation
+ const sourceStatus = String(sourceCorrelation?.status ?? 'missing')
+ const sourceReason = String(sourceCorrelation?.missing_reason ?? '')
+ const directRefTotal = sourceCorrelation?.direct_ref_total ?? 0
+ const candidateTotal = sourceCorrelation?.candidate_total ?? 0
+ const appliedLinkTotal = sourceCorrelation?.applied_link_total ?? 0
+ const providerEventTotal = sourceCorrelation?.provider_event_total ?? 0
+ const mcpGateway = chain?.mcp?.gateway
+ const ansible = chain?.execution?.ansible
+ const timelineStages = timeline?.timeline?.slice(0, 10) ?? []
+ const approvalId = timeline?.approval_ids?.[0]
+ const statusLabels: Record = {
+ linked: t('sourceStatuses.linked'),
+ candidate_found: t('sourceStatuses.candidateFound'),
+ provider_fresh_no_match: t('sourceStatuses.providerFreshNoMatch'),
+ missing: t('sourceStatuses.missing'),
+ no_incident_context: t('sourceStatuses.noIncidentContext'),
+ fetch_failed: t('sourceStatuses.fetchFailed'),
+ }
+ const reasonLabels: Record = {
+ provider_heartbeat_present_but_no_incident_match: t('sourceReasons.providerHeartbeatNoMatch'),
+ no_matching_provider_source_event: t('sourceReasons.noMatchingProviderSourceEvent'),
+ no_incident_ids: t('sourceReasons.noIncidentIds'),
+ incident_not_found: t('sourceReasons.incidentNotFound'),
+ source_correlation_fetch_failed: t('sourceReasons.fetchFailed'),
+ }
+ const sourceStatusLabel = statusLabels[sourceStatus] ?? valueOrEmpty(sourceStatus, emptyLabel)
+ const sourceReasonLabel = reasonLabels[sourceReason] ?? valueOrEmpty(sourceReason, emptyLabel)
+ const providerEntries = Object.entries(sourceCorrelation?.providers ?? {})
+ const sourceTone: EvidenceTone = appliedLinkTotal > 0
+ ? 'success'
+ : (directRefTotal > 0 || candidateTotal > 0 ? 'warning' : 'blocked')
+ const mcpTone: EvidenceTone = (mcpGateway?.total ?? 0) > 0
+ ? (((mcpGateway?.failed ?? 0) + (mcpGateway?.blocked ?? 0)) > 0 ? 'warning' : 'success')
+ : 'neutral'
+ const ansibleTone: EvidenceTone = ansible?.applied || (ansible?.apply_total ?? 0) > 0
+ ? 'success'
+ : (ansible?.considered || (ansible?.record_total ?? 0) > 0 ? 'warning' : 'neutral')
+ const handoffTone: EvidenceTone = chain?.needs_human ? 'blocked' : (chain ? 'success' : 'neutral')
+
+ const navItems = [
+ {
+ label: t('links.workItems'),
+ href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
+ Icon: ListChecks,
+ },
+ {
+ label: t('links.runs'),
+ href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
+ Icon: Activity,
+ },
+ {
+ label: t('links.approvals'),
+ href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
+ Icon: ShieldCheck,
+ },
+ {
+ label: t('links.authorizations'),
+ href: `/authorizations?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}${approvalId ? `&approval_id=${encodeURIComponent(approvalId)}` : ''}` as never,
+ Icon: CheckCircle2,
+ },
+ {
+ label: t('links.tickets'),
+ href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
+ Icon: GitBranch,
+ },
+ ]
+
+ return (
+
+
+
+
+
+ {chain?.needs_human ? (
+
+ ) : (
+
+ )}
+
+
+
+ {t('title')}
+
+
+ {t('subtitle', {
+ incidentId,
+ incidentTitle: timeline?.title ?? valueOrEmpty(chain?.source_id, emptyLabel),
+ })}
+
+
+
+
+
+ {navItems.map(({ label, href, Icon }) => (
+
+
+ {label}
+
+
+ ))}
+
+
+
+ {state.error && (
+
+ {t('loadFailed', { error: state.error })}
+
+ )}
+
+ {state.loading && !chain && !timeline && (
+
+ {t('loading')}
+
+ )}
+
+
+
+
+
+
+ 0 ? 'success' : 'neutral'}
+ />
+
+
+
+
+
+
+
{t('providerEvidence.title')}
+
+ {t('providerEvidence.rawIdsHidden')}
+
+
+
+ {t('providerEvidence.summary', {
+ status: sourceStatusLabel,
+ reason: sourceReasonLabel,
+ })}
+
+
+ {(providerEntries.length > 0 ? providerEntries : [['sentry', null], ['signoz', null]] as const).map(([provider, stats]) => (
+
+
{providerLabel(provider)}
+
+ {t('providerEvidence.counts', {
+ direct: stats?.direct_ref_total ?? 0,
+ candidate: stats?.candidate_total ?? 0,
+ applied: stats?.applied_link_total ?? 0,
+ })}
+
+
+ {t('providerEvidence.latest', {
+ event: formatDateTime(stats?.latest_event_at, emptyLabel),
+ heartbeat: formatDateTime(stats?.latest_heartbeat_at, emptyLabel),
+ })}
+
+
+ ))}
+
+
+
+
+
{t('timeline.title')}
+
+ {t('timeline.summary', {
+ status: valueOrEmpty(timeline?.status, emptyLabel),
+ severity: valueOrEmpty(timeline?.severity, emptyLabel),
+ stages: timeline?.timeline?.length ?? 0,
+ })}
+
+
+ {timelineStages.map((stage) => (
+
+
+
+ {stage.label || stage.stage}
+
+
{stage.status}
+
+
+ {t('timeline.sourceTable', { table: valueOrEmpty(stage.source_table, emptyLabel) })}
+
+
+ ))}
+ {timelineStages.length === 0 && (
+
+ {t('timeline.empty')}
+
+ )}
+
+
+
+
+
+ {t('boundary', {
+ playbook: compactPath(ansible?.latest_playbook_path, emptyLabel),
+ executor: valueOrEmpty(chain?.execution?.latest_executor, emptyLabel),
+ })}
+
+
+
+
+ )
+}
+
// =============================================================================
// Panel 元件 (不含 AppLayout)
// =============================================================================
@@ -99,6 +491,9 @@ export function MonitoringPanel() {
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const tAlerts = useTranslations('alerts')
+ const searchParams = useSearchParams()
+ const projectId = searchParams.get('project_id') ?? 'awoooi'
+ const incidentId = searchParams.get('incident_id')
const hosts = useHosts()
const { metrics, isLoading: metricsLoading } = useGlobalPulseMetrics({
@@ -109,7 +504,14 @@ export function MonitoringPanel() {
const [dashboard, setDashboard] = useState(null)
const [dashLoading, setDashLoading] = useState(true)
const [dashError, setDashError] = useState(null)
+ const [incidentFocus, setIncidentFocus] = useState({
+ chain: null,
+ timeline: null,
+ loading: false,
+ error: null,
+ })
const abortRef = useRef(null)
+ const focusAbortRef = useRef(null)
const fetchDashboard = useCallback(async () => {
const base = getApiBaseUrl()
@@ -133,6 +535,48 @@ export function MonitoringPanel() {
}
}, [])
+ const fetchIncidentFocus = useCallback(async () => {
+ const base = getApiBaseUrl()
+ if (!base || !incidentId) {
+ focusAbortRef.current?.abort()
+ setIncidentFocus({ chain: null, timeline: null, loading: false, error: null })
+ return
+ }
+
+ focusAbortRef.current?.abort()
+ const ctrl = new AbortController()
+ focusAbortRef.current = ctrl
+
+ setIncidentFocus((current) => ({ ...current, loading: true, error: null }))
+ const encodedProjectId = encodeURIComponent(projectId)
+ const encodedIncidentId = encodeURIComponent(incidentId)
+ const [statusChainResult, timelineResult] = await Promise.allSettled([
+ fetchJson(
+ `${base}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`,
+ ctrl.signal
+ ),
+ fetchJson(
+ `${base}/api/v1/incidents/${encodedIncidentId}/timeline`,
+ ctrl.signal
+ ),
+ ])
+
+ if (ctrl.signal.aborted) return
+
+ const chain = statusChainResult.status === 'fulfilled' ? statusChainResult.value : null
+ const timeline = timelineResult.status === 'fulfilled' ? timelineResult.value : null
+ const errors = [statusChainResult, timelineResult]
+ .filter((item): item is PromiseRejectedResult => item.status === 'rejected')
+ .map((item) => item.reason instanceof Error ? item.reason.message : String(item.reason))
+
+ setIncidentFocus({
+ chain,
+ timeline,
+ loading: false,
+ error: errors.length > 0 ? errors.join(' / ') : null,
+ })
+ }, [incidentId, projectId])
+
useEffect(() => {
fetchDashboard()
const id = setInterval(fetchDashboard, 30000)
@@ -142,7 +586,24 @@ export function MonitoringPanel() {
}
}, [fetchDashboard])
- const isLoading = metricsLoading || dashLoading
+ useEffect(() => {
+ fetchIncidentFocus()
+ if (!incidentId) return () => {
+ focusAbortRef.current?.abort()
+ }
+ const id = setInterval(fetchIncidentFocus, 30000)
+ return () => {
+ clearInterval(id)
+ focusAbortRef.current?.abort()
+ }
+ }, [fetchIncidentFocus, incidentId])
+
+ const isLoading = metricsLoading || dashLoading || incidentFocus.loading
+
+ const handleRefresh = useCallback(() => {
+ fetchDashboard()
+ fetchIncidentFocus()
+ }, [fetchDashboard, fetchIncidentFocus])
return (
<>
@@ -158,7 +619,7 @@ export function MonitoringPanel() {