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 }) => ( + +
+
+ + {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() {