fix(web): connect alerts to incident evidence chain
All checks were successful
CD Pipeline / tests (push) Successful in 1m31s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 6m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m49s

This commit is contained in:
Your Name
2026-05-31 22:41:29 +08:00
parent 3c7a469ae4
commit 7d30b0342c
4 changed files with 388 additions and 15 deletions

View File

@@ -814,7 +814,13 @@
"timelineEvents": "事件明細",
"timelineSource": "來源",
"timelineRoute": "MCP",
"timelineWrites": "寫入"
"timelineWrites": "寫入",
"truthLinksLabel": "真相鏈",
"truthLinkMonitoring": "監控",
"truthLinkWorkItems": "工作項",
"truthLinkRuns": "Runs",
"truthLinkApprovals": "審批",
"truthLinkTickets": "Tickets"
}
},
"status": {
@@ -1163,6 +1169,9 @@
"autoRefresh": "每 {seconds} 秒自動刷新",
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}",
"pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆",
"focusedPageSummary": "焦點 Incident{incidentId};活躍列表符合 {total} 筆",
"focusedNotActiveTitle": "{incidentId} 不在目前活躍列表",
"focusedNotActiveDescription": "此 Incident 可能已結案、已封存或不屬於目前活躍視窗;上方仍可用 truth-chain、Runs、Work Items、Monitoring 追查完整處理證據。",
"statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 真相鏈",
"previousPage": "上一頁",
"nextPage": "下一頁",
@@ -1182,7 +1191,38 @@
"sourceCoverageFresh": "新鮮",
"sourceCoverageStaleHours": "已過期 {hours} 小時",
"sourceCoverageStaleDays": "已過期 {days} 天",
"sourceCoverageNoEvents": "無事件"
"sourceCoverageNoEvents": "無事件",
"focus": {
"emptyValue": "尚無資料",
"title": "焦點告警真相鏈",
"subtitle": "{incidentId}{service}",
"loading": "讀取中",
"notInActiveList": "此 Incident 不在目前活躍告警列表,但仍可從 AwoooP truth-chain / ADR-100 history 查詢歷史處置證據。",
"boundary": "此區塊只做只讀追蹤,不會建立新 Incident、不會觸發修復、不會靜音 Telegram 告警。",
"links": {
"monitoring": "監控證據",
"workItems": "工作項",
"runs": "Runs",
"approvals": "審批",
"tickets": "Tickets"
},
"metrics": {
"sourceRefs": "Source refs",
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
"provider": "Sentry / SigNoz",
"mcp": "MCP Gateway",
"mcpValue": "{success} / {total}",
"ansible": "Ansible"
},
"sourceStatuses": {
"linked": "已匹配 provider event",
"candidateFound": "找到候選但未套用",
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
"missing": "缺少 provider 證據",
"noIncidentContext": "缺少 Incident context",
"fetchFailed": "讀取 provider 證據失敗"
}
}
},
"navSection": {
"aiCore": "AI 核心",

View File

@@ -814,7 +814,13 @@
"timelineEvents": "事件明細",
"timelineSource": "來源",
"timelineRoute": "MCP",
"timelineWrites": "寫入"
"timelineWrites": "寫入",
"truthLinksLabel": "真相鏈",
"truthLinkMonitoring": "監控",
"truthLinkWorkItems": "工作項",
"truthLinkRuns": "Runs",
"truthLinkApprovals": "審批",
"truthLinkTickets": "Tickets"
}
},
"status": {
@@ -1163,6 +1169,9 @@
"autoRefresh": "每 {seconds} 秒自動刷新",
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}",
"pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆",
"focusedPageSummary": "焦點 Incident{incidentId};活躍列表符合 {total} 筆",
"focusedNotActiveTitle": "{incidentId} 不在目前活躍列表",
"focusedNotActiveDescription": "此 Incident 可能已結案、已封存或不屬於目前活躍視窗;上方仍可用 truth-chain、Runs、Work Items、Monitoring 追查完整處理證據。",
"statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 真相鏈",
"previousPage": "上一頁",
"nextPage": "下一頁",
@@ -1182,7 +1191,38 @@
"sourceCoverageFresh": "新鮮",
"sourceCoverageStaleHours": "已過期 {hours} 小時",
"sourceCoverageStaleDays": "已過期 {days} 天",
"sourceCoverageNoEvents": "無事件"
"sourceCoverageNoEvents": "無事件",
"focus": {
"emptyValue": "尚無資料",
"title": "焦點告警真相鏈",
"subtitle": "{incidentId}{service}",
"loading": "讀取中",
"notInActiveList": "此 Incident 不在目前活躍告警列表,但仍可從 AwoooP truth-chain / ADR-100 history 查詢歷史處置證據。",
"boundary": "此區塊只做只讀追蹤,不會建立新 Incident、不會觸發修復、不會靜音 Telegram 告警。",
"links": {
"monitoring": "監控證據",
"workItems": "工作項",
"runs": "Runs",
"approvals": "審批",
"tickets": "Tickets"
},
"metrics": {
"sourceRefs": "Source refs",
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
"provider": "Sentry / SigNoz",
"mcp": "MCP Gateway",
"mcpValue": "{success} / {total}",
"ansible": "Ansible"
},
"sourceStatuses": {
"linked": "已匹配 provider event",
"candidateFound": "找到候選但未套用",
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
"missing": "缺少 provider 證據",
"noIncidentContext": "缺少 Incident context",
"fetchFailed": "讀取 provider 證據失敗"
}
}
},
"navSection": {
"aiCore": "AI 核心",

View File

@@ -11,14 +11,37 @@
import { AppLayout } from '@/components/layout'
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useIncidents } from '@/hooks/useIncidents'
import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains'
import { IncidentCard } from '@/components/incident'
import {
AwoooPStatusChainPanel,
type AwoooPStatusChain,
} from '@/components/awooop/status-chain'
import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge'
import { cn } from '@/lib/utils'
import { API_V1_URL } from '@/lib/config'
import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft, ChevronRight } from 'lucide-react'
import type { IncidentResponse } from '@/lib/api-client'
import { Link } from '@/i18n/routing'
import {
Activity,
Bell,
BellOff,
RefreshCw,
AlertTriangle,
AlertCircle,
Info,
ChevronLeft,
ChevronRight,
GitBranch,
Link2,
ListChecks,
Monitor,
SearchCheck,
ShieldCheck,
} from 'lucide-react'
// =============================================================================
// Severity helpers
@@ -138,6 +161,155 @@ function freshnessToneClass(ageHours: number | null) {
return 'border-status-healthy/30 bg-status-healthy/5 text-status-healthy'
}
function valueOrEmpty(value: unknown, emptyLabel: string) {
if (value === null || value === undefined || value === '') return emptyLabel
return String(value)
}
function FocusIncidentEvidencePanel({
incidentId,
projectId,
incident,
chain,
loading,
}: {
incidentId: string
projectId: string
incident?: IncidentResponse | null
chain?: AwoooPStatusChain | null
loading: boolean
}) {
const t = useTranslations('alerts.focus')
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 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 sourceStatusLabel = statusLabels[sourceStatus] ?? valueOrEmpty(sourceStatus, emptyLabel)
const mcpGateway = chain?.mcp?.gateway
const ansible = chain?.execution?.ansible
const navItems = [
{
key: 'monitoring',
label: t('links.monitoring'),
href: `/monitoring?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: Monitor,
},
{
key: 'workItems',
label: t('links.workItems'),
href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: ListChecks,
},
{
key: 'runs',
label: t('links.runs'),
href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: Activity,
},
{
key: 'approvals',
label: t('links.approvals'),
href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: ShieldCheck,
},
{
key: 'tickets',
label: t('links.tickets'),
href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: GitBranch,
},
]
return (
<section
data-testid="alerts-incident-focus"
className="mb-6 overflow-hidden rounded-lg 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="flex min-w-0 items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]">
<SearchCheck className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[#141413]">{t('title')}</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">
{t('subtitle', {
incidentId,
service: incident?.affected_services?.[0] ?? valueOrEmpty(chain?.source_id, emptyLabel),
})}
{loading ? ` · ${t('loading')}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{navItems.map(({ key, label, href, Icon }) => (
<Link
key={key}
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}
<Link2 className="h-3 w-3" aria-hidden="true" />
</Link>
))}
</div>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.sourceRefs')}</p>
<p className="mt-2 font-mono text-sm font-semibold text-[#141413]">
{t('metrics.sourceRefsValue', {
inbound: chain?.source_refs?.inbound_total ?? 0,
outbound: chain?.source_refs?.outbound_total ?? 0,
})}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.provider')}</p>
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={sourceStatusLabel}>
{sourceStatusLabel}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.mcp')}</p>
<p className="mt-2 font-mono text-sm font-semibold text-[#141413]">
{t('metrics.mcpValue', { success: mcpGateway?.success ?? 0, total: mcpGateway?.total ?? 0 })}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.ansible')}</p>
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={valueOrEmpty(ansible?.latest_playbook_path, emptyLabel)}>
{valueOrEmpty(ansible?.latest_status ?? chain?.execution?.latest_status, emptyLabel)}
</p>
</div>
</div>
{!incident && (
<div className="border-t border-[#e0ddd4] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
{t('notInActiveList')}
</div>
)}
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
{t('boundary')}
</div>
<AwoooPStatusChainPanel chain={chain ?? null} compact className="border-x-0 border-b-0" />
</section>
)
}
// =============================================================================
// Page
// =============================================================================
@@ -145,6 +317,9 @@ function freshnessToneClass(ageHours: number | null) {
export default function AlertsPage({ params }: { params: { locale: string } }) {
const t = useTranslations()
const tAlerts = useTranslations('alerts')
const searchParams = useSearchParams()
const projectId = searchParams.get('project_id') ?? 'awoooi'
const focusedIncidentId = searchParams.get('incident_id')?.trim() || null
const { incidents, isLoading, error, refresh, lastUpdated } = useIncidents({
pollInterval: 15000,
@@ -163,24 +338,42 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
(a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9)
)
}, [incidents])
const visibleSorted = useMemo(() => {
if (!focusedIncidentId) return sorted
return sorted.filter(incident => incident.incident_id === focusedIncidentId)
}, [focusedIncidentId, sorted])
const focusedIncident = focusedIncidentId
? sorted.find(incident => incident.incident_id === focusedIncidentId) ?? null
: null
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(sorted.length / ALERTS_PAGE_SIZE))
const totalPages = Math.max(1, Math.ceil(visibleSorted.length / ALERTS_PAGE_SIZE))
const pageStart = (currentPage - 1) * ALERTS_PAGE_SIZE
const pageIncidents = sorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE)
const pageFrom = sorted.length === 0 ? 0 : pageStart + 1
const pageIncidents = visibleSorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE)
const pageFrom = visibleSorted.length === 0 ? 0 : pageStart + 1
const pageTo = pageStart + pageIncidents.length
useEffect(() => {
setCurrentPage(page => Math.min(Math.max(page, 1), totalPages))
}, [totalPages])
useEffect(() => {
setCurrentPage(1)
}, [focusedIncidentId])
const statusChainIncidentIds = useMemo(() => {
return Array.from(new Set([
...pageIncidents.map(incident => incident.incident_id),
...(focusedIncidentId ? [focusedIncidentId] : []),
]))
}, [focusedIncidentId, pageIncidents])
const {
statusChains,
requestedIncidentIds,
isLoading: isStatusChainLoading,
} = useIncidentStatusChains({
incidentIds: pageIncidents.map(incident => incident.incident_id),
limit: ALERTS_PAGE_SIZE,
incidentIds: statusChainIncidentIds,
limit: ALERTS_PAGE_SIZE + 1,
projectId,
refreshKey: lastUpdated?.toISOString() ?? null,
})
const truthChainLoaded = requestedIncidentIds.filter(incidentId => {
@@ -395,11 +588,23 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
</div>
</div>
{sorted.length > 0 && (
{focusedIncidentId && (
<FocusIncidentEvidencePanel
incidentId={focusedIncidentId}
projectId={projectId}
incident={focusedIncident}
chain={statusChains[focusedIncidentId] ?? null}
loading={isStatusChainLoading}
/>
)}
{visibleSorted.length > 0 && (
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-nothing-gray-200 bg-white px-4 py-3">
<div>
<p className="font-body text-sm font-semibold text-nothing-black">
{tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: sorted.length })}
{focusedIncidentId
? tAlerts('focusedPageSummary', { incidentId: focusedIncidentId, total: visibleSorted.length })
: tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: visibleSorted.length })}
</p>
<p className="mt-1 font-body text-xs text-nothing-gray-500">
{tAlerts('statusChainWindow', {
@@ -435,6 +640,17 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
</div>
)}
{focusedIncidentId && visibleSorted.length === 0 && !isLoading && (
<div className="mb-6 rounded-lg border border-status-warning/25 bg-status-warning/5 px-4 py-3">
<p className="font-body text-sm font-semibold text-status-warning">
{tAlerts('focusedNotActiveTitle', { incidentId: focusedIncidentId })}
</p>
<p className="mt-1 font-body text-xs leading-5 text-nothing-gray-600">
{tAlerts('focusedNotActiveDescription')}
</p>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
@@ -481,6 +697,7 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
<IncidentCard
key={incident.incident_id}
incident={incident}
projectId={projectId}
statusChain={statusChains[incident.incident_id] ?? null}
/>
))}

View File

@@ -19,8 +19,10 @@ import type { IncidentResponse, DecisionInfo, IncidentTimelineResponse } from '@
import { apiClient } from '@/lib/api-client'
import { CURRENT_USER } from '@/lib/constants'
import { useCSRF } from '@/hooks/useCSRF'
import { Link } from '@/i18n/routing'
import { FlowPipeline, type FlowStage } from './flow-pipeline'
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
import { Activity, GitBranch, Lightbulb, ListChecks, Monitor, ShieldCheck } from 'lucide-react'
// =============================================================================
// Types
@@ -34,6 +36,7 @@ export interface IncidentCardProps {
incident: IncidentResponse
decision?: DecisionInfo | null
statusChain?: AwoooPStatusChain | null
projectId?: string
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
@@ -286,7 +289,7 @@ function useApprovalAction(
// Component
// =============================================================================
export function IncidentCard({ incident, decision, statusChain, onApprovalChange }: IncidentCardProps) {
export function IncidentCard({ incident, decision, statusChain, projectId = 'awoooi', onApprovalChange }: IncidentCardProps) {
const t = useTranslations('incident.card')
const { csrfToken } = useCSRF()
@@ -428,6 +431,40 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
latest: latestSource,
})
: null
const encodedProjectId = encodeURIComponent(projectId)
const encodedIncidentId = encodeURIComponent(incident.incident_id)
const truthLinks = [
{
key: 'monitoring',
label: t('truthLinkMonitoring'),
href: `/monitoring?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: Monitor,
},
{
key: 'workItems',
label: t('truthLinkWorkItems'),
href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: ListChecks,
},
{
key: 'runs',
label: t('truthLinkRuns'),
href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: Activity,
},
{
key: 'approvals',
label: t('truthLinkApprovals'),
href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: ShieldCheck,
},
{
key: 'tickets',
label: t('truthLinkTickets'),
href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
Icon: GitBranch,
},
]
const serviceName = incident.affected_services?.[0] ?? '--'
const duration = formatDuration(incident.created_at)
@@ -738,6 +775,44 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
</span>
</div>
<div style={{
margin: '0 14px 10px',
padding: '7px 9px',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
display: 'flex',
alignItems: 'center',
gap: 7,
flexWrap: 'wrap',
}}>
<span style={{ fontSize: 11, color: '#87867f', fontWeight: 700 }}>
{t('truthLinksLabel')}
</span>
{truthLinks.map(({ key, label, href, Icon }) => (
<Link
key={key}
href={href}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 7px',
border: '0.5px solid #d8d3c7',
borderRadius: 5,
background: '#faf9f3',
color: '#3f3a32',
fontSize: 11,
fontWeight: 600,
textDecoration: 'none',
}}
>
<Icon aria-hidden="true" size={12} strokeWidth={1.8} />
{label}
</Link>
))}
</div>
<button
onClick={handleTimelineToggle}
style={{
@@ -954,8 +1029,9 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
}}>
<div style={{ color: '#141413', marginBottom: 4 }}>{decisionAction}</div>
{decisionReasoning && (
<div style={{ color: '#87867f', fontStyle: 'italic' }}>
💡 {decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}
<div style={{ color: '#87867f', fontStyle: 'italic', display: 'flex', alignItems: 'flex-start', gap: 5 }}>
<Lightbulb aria-hidden="true" size={14} strokeWidth={1.8} style={{ flexShrink: 0, marginTop: 1 }} />
<span>{decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}</span>
</div>
)}
</div>