fix(web): connect monitoring to incident evidence chain
All checks were successful
CD Pipeline / tests (push) Successful in 1m36s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 5m4s
CD Pipeline / post-deploy-checks (push) Successful in 2m16s

This commit is contained in:
Your Name
2026-05-31 21:41:55 +08:00
parent 54a93d29ba
commit 5a23dec72e
3 changed files with 603 additions and 6 deletions

View File

@@ -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": "服務目錄",

View File

@@ -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": "服務目錄",

View File

@@ -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<T>(url: string, signal?: AbortSignal): Promise<T> {
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: <CheckCircle2 className="w-4 h-4 text-status-healthy" />,
warning: <AlertCircle className="w-4 h-4 text-status-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 (
<div className={cn('min-w-0 overflow-hidden border bg-white p-4', evidenceToneClass(tone))}>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="truncate text-xs font-semibold uppercase tracking-wider">{label}</p>
</div>
<p className="mt-3 truncate font-mono text-base font-semibold text-[#141413]" title={String(value)}>
{value}
</p>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[#5f5b52]" title={detail}>
{detail}
</p>
</div>
)
}
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<string, string> = {
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<string, string> = {
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 (
<section
data-testid="monitoring-incident-focus"
className="mb-6 overflow-hidden border border-[#e0ddd4] bg-white"
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('inline-flex h-8 w-8 items-center justify-center border', evidenceToneClass(handoffTone))}>
{chain?.needs_human ? (
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
) : (
<Monitor className="h-4 w-4" aria-hidden="true" />
)}
</span>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-[#141413]">
{t('title')}
</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">
{t('subtitle', {
incidentId,
incidentTitle: timeline?.title ?? valueOrEmpty(chain?.source_id, emptyLabel),
})}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{navItems.map(({ label, href, Icon }) => (
<Link
key={label}
href={href}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1.5 text-xs font-semibold text-[#3f3a32] hover:bg-[#f5f1e8]"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</Link>
))}
</div>
</div>
{state.error && (
<div className="border-b border-[#e0ddd4] bg-[#fff0ef] px-4 py-3 text-xs font-semibold text-[#9f2f25]" role="alert">
{t('loadFailed', { error: state.error })}
</div>
)}
{state.loading && !chain && !timeline && (
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs text-[#77736a]">
{t('loading')}
</div>
)}
<div className="grid min-w-0 gap-px bg-[#e0ddd4] md:grid-cols-3 xl:grid-cols-6">
<EvidenceTile
icon={Link2}
label={t('tiles.sourceRefs')}
value={t('tiles.sourceRefsValue', {
inbound: chain?.source_refs?.inbound_total ?? 0,
outbound: chain?.source_refs?.outbound_total ?? 0,
})}
detail={t('tiles.sourceRefsDetail', {
direct: directRefTotal,
candidate: candidateTotal,
applied: appliedLinkTotal,
})}
tone={sourceTone}
/>
<EvidenceTile
icon={RadioTower}
label={t('tiles.provider')}
value={sourceStatusLabel}
detail={t('tiles.providerDetail', {
reason: sourceReasonLabel,
providerEvents: providerEventTotal,
})}
tone={sourceTone}
/>
<EvidenceTile
icon={Activity}
label={t('tiles.mcp')}
value={t('tiles.mcpValue', {
success: mcpGateway?.success ?? 0,
total: mcpGateway?.total ?? 0,
})}
detail={t('tiles.mcpDetail', {
failed: mcpGateway?.failed ?? 0,
blocked: mcpGateway?.blocked ?? 0,
policy: mcpGateway?.policy_enforced_total ?? 0,
})}
tone={mcpTone}
/>
<EvidenceTile
icon={Wrench}
label={t('tiles.ansible')}
value={valueOrEmpty(ansible?.latest_status ?? chain?.execution?.latest_status, emptyLabel)}
detail={t('tiles.ansibleDetail', {
mode: valueOrEmpty(ansible?.latest_execution_mode, emptyLabel),
rc: valueOrEmpty(ansible?.latest_returncode, emptyLabel),
apply: valueOrEmpty(ansible?.latest_apply_executed, emptyLabel),
})}
tone={ansibleTone}
/>
<EvidenceTile
icon={BookOpenCheck}
label={t('tiles.km')}
value={chain?.evidence?.knowledge_entries ?? 0}
detail={t('tiles.kmDetail', {
verification: valueOrEmpty(chain?.verification, emptyLabel),
next: valueOrEmpty(chain?.next_step ?? chain?.operator_outcome?.next_action, emptyLabel),
})}
tone={(chain?.evidence?.knowledge_entries ?? 0) > 0 ? 'success' : 'neutral'}
/>
<EvidenceTile
icon={chain?.needs_human ? TriangleAlert : CheckCircle2}
label={t('tiles.handoff')}
value={chain?.needs_human ? t('human.yes') : t('human.no')}
detail={valueOrEmpty(chain?.operator_outcome?.summary_zh ?? chain?.operator_outcome?.human_action_reason, emptyLabel)}
tone={handoffTone}
/>
</div>
<div className="grid min-w-0 gap-px bg-[#e0ddd4] lg:grid-cols-2">
<div className="min-w-0 bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold text-[#141413]">{t('providerEvidence.title')}</h4>
<span className="text-xs font-semibold text-[#77736a]">
{t('providerEvidence.rawIdsHidden')}
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t('providerEvidence.summary', {
status: sourceStatusLabel,
reason: sourceReasonLabel,
})}
</p>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{(providerEntries.length > 0 ? providerEntries : [['sentry', null], ['signoz', null]] as const).map(([provider, stats]) => (
<div key={provider} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
<p className="text-xs font-semibold text-[#141413]">{providerLabel(provider)}</p>
<p className="mt-2 font-mono text-xs text-[#3f3a32]">
{t('providerEvidence.counts', {
direct: stats?.direct_ref_total ?? 0,
candidate: stats?.candidate_total ?? 0,
applied: stats?.applied_link_total ?? 0,
})}
</p>
<p className="mt-2 text-xs leading-5 text-[#77736a]">
{t('providerEvidence.latest', {
event: formatDateTime(stats?.latest_event_at, emptyLabel),
heartbeat: formatDateTime(stats?.latest_heartbeat_at, emptyLabel),
})}
</p>
</div>
))}
</div>
</div>
<div className="min-w-0 bg-white px-4 py-4">
<h4 className="text-sm font-semibold text-[#141413]">{t('timeline.title')}</h4>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t('timeline.summary', {
status: valueOrEmpty(timeline?.status, emptyLabel),
severity: valueOrEmpty(timeline?.severity, emptyLabel),
stages: timeline?.timeline?.length ?? 0,
})}
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{timelineStages.map((stage) => (
<div key={`${stage.stage}-${stage.status}-${stage.timestamp ?? ''}`} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-semibold text-[#141413]" title={stage.label || stage.stage}>
{stage.label || stage.stage}
</p>
<span className="shrink-0 font-mono text-[11px] text-[#77736a]">{stage.status}</span>
</div>
<p className="mt-2 truncate text-xs text-[#5f5b52]" title={valueOrEmpty(stage.source_table, emptyLabel)}>
{t('timeline.sourceTable', { table: valueOrEmpty(stage.source_table, emptyLabel) })}
</p>
</div>
))}
{timelineStages.length === 0 && (
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-3 text-xs text-[#77736a]">
{t('timeline.empty')}
</div>
)}
</div>
</div>
</div>
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
{t('boundary', {
playbook: compactPath(ansible?.latest_playbook_path, emptyLabel),
executor: valueOrEmpty(chain?.execution?.latest_executor, emptyLabel),
})}
</div>
<div className="overflow-hidden">
<AwoooPStatusChainPanel chain={chain} compact className="border-x-0 border-b-0" />
</div>
</section>
)
}
// =============================================================================
// 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<DashboardResponse | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [dashError, setDashError] = useState<string | null>(null)
const [incidentFocus, setIncidentFocus] = useState<IncidentFocusState>({
chain: null,
timeline: null,
loading: false,
error: null,
})
const abortRef = useRef<AbortController | null>(null)
const focusAbortRef = useRef<AbortController | null>(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<AwoooPStatusChain>(
`${base}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`,
ctrl.signal
),
fetchJson<IncidentTimelineResponse>(
`${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() {
</p>
</div>
<button
onClick={fetchDashboard}
onClick={handleRefresh}
disabled={isLoading}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
@@ -172,6 +633,14 @@ export function MonitoringPanel() {
</button>
</div>
{incidentId && (
<IncidentObservabilityFocus
incidentId={incidentId}
projectId={projectId}
state={incidentFocus}
/>
)}
{/* Gold Metrics */}
{metrics && <GlobalPulseChart metrics={metrics} />}