diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ccbe652f..ec12bc07 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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 核心", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ccbe652f..ec12bc07 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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 核心", diff --git a/apps/web/src/app/[locale]/alerts/page.tsx b/apps/web/src/app/[locale]/alerts/page.tsx index 5a4363a0..149c06a5 100644 --- a/apps/web/src/app/[locale]/alerts/page.tsx +++ b/apps/web/src/app/[locale]/alerts/page.tsx @@ -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 = { + 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 ( +
+
+
+ + +
+

{t('title')}

+

+ {t('subtitle', { + incidentId, + service: incident?.affected_services?.[0] ?? valueOrEmpty(chain?.source_id, emptyLabel), + })} + {loading ? ` · ${t('loading')}` : ''} +

+
+
+
+ {navItems.map(({ key, label, href, Icon }) => ( + +
+
+ +
+
+

{t('metrics.sourceRefs')}

+

+ {t('metrics.sourceRefsValue', { + inbound: chain?.source_refs?.inbound_total ?? 0, + outbound: chain?.source_refs?.outbound_total ?? 0, + })} +

+
+
+

{t('metrics.provider')}

+

+ {sourceStatusLabel} +

+
+
+

{t('metrics.mcp')}

+

+ {t('metrics.mcpValue', { success: mcpGateway?.success ?? 0, total: mcpGateway?.total ?? 0 })} +

+
+
+

{t('metrics.ansible')}

+

+ {valueOrEmpty(ansible?.latest_status ?? chain?.execution?.latest_status, emptyLabel)} +

+
+
+ + {!incident && ( +
+ {t('notInActiveList')} +
+ )} + +
+ {t('boundary')} +
+ + +
+ ) +} + // ============================================================================= // 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 } }) { - {sorted.length > 0 && ( + {focusedIncidentId && ( + + )} + + {visibleSorted.length > 0 && (

- {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 })}

{tAlerts('statusChainWindow', { @@ -435,6 +640,17 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {

)} + {focusedIncidentId && visibleSorted.length === 0 && !isLoading && ( +
+

+ {tAlerts('focusedNotActiveTitle', { incidentId: focusedIncidentId })} +

+

+ {tAlerts('focusedNotActiveDescription')} +

+
+ )} + {/* Error */} {error && (
@@ -481,6 +697,7 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { ))} diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index bb5b2364..e4fe85f9 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -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
+
+ + {t('truthLinksLabel')} + + {truthLinks.map(({ key, label, href, Icon }) => ( + +
+