diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index dac34236..377ab35d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -737,7 +737,18 @@ "statusChainWindow": "AI flow evidence: {loaded}/{shown} on this page connected to truth-chain", "previousPage": "Previous", "nextPage": "Next", - "pageIndicator": "Page {page} of {totalPages}" + "pageIndicator": "Page {page} of {totalPages}", + "sourceCoverageTitle": "Source Dossier Coverage", + "sourceCoverageSubtitle": "DB persistence and Sentry / SigNoz references across the latest {limit} inbound source events", + "sourceCoverageError": "Source dossier coverage failed to load: {error}", + "sourceCoverageWithRefs": "With refs", + "sourceCoverageMissing": "Missing refs", + "sourceCoverageAlert": "Alert refs", + "sourceCoverageSentry": "Sentry refs", + "sourceCoverageSigNoz": "SigNoz refs", + "sourceCoverageRatio": "source refs coverage {ratio} / total {total}", + "sourceCoverageProvider": "{provider}: total {total}, missing {missing}, Sentry {sentry}, SigNoz {signoz}", + "sourceCoverageProviderWindow": "{provider} window: total {total}, with refs {withRefs}, missing {missing}" }, "navSection": { "aiCore": "AI Core", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 3a93632a..345d038c 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -738,7 +738,18 @@ "statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 truth-chain", "previousPage": "上一頁", "nextPage": "下一頁", - "pageIndicator": "第 {page} / {totalPages} 頁" + "pageIndicator": "第 {page} / {totalPages} 頁", + "sourceCoverageTitle": "來源卷宗覆蓋率", + "sourceCoverageSubtitle": "最近 {limit} 筆 inbound source event 的 DB 保存與 Sentry / SigNoz 關聯", + "sourceCoverageError": "來源卷宗覆蓋率讀取失敗:{error}", + "sourceCoverageWithRefs": "含 refs", + "sourceCoverageMissing": "缺 refs", + "sourceCoverageAlert": "Alert refs", + "sourceCoverageSentry": "Sentry refs", + "sourceCoverageSigNoz": "SigNoz refs", + "sourceCoverageRatio": "source refs 覆蓋率 {ratio} / total {total}", + "sourceCoverageProvider": "{provider}: total {total}, missing {missing}, Sentry {sentry}, SigNoz {signoz}", + "sourceCoverageProviderWindow": "{provider} window: total {total}, with refs {withRefs}, missing {missing}" }, "navSection": { "aiCore": "AI 核心", diff --git a/apps/web/src/app/[locale]/alerts/page.tsx b/apps/web/src/app/[locale]/alerts/page.tsx index 4ec96faf..e166ee57 100644 --- a/apps/web/src/app/[locale]/alerts/page.tsx +++ b/apps/web/src/app/[locale]/alerts/page.tsx @@ -16,6 +16,7 @@ import { useIncidents } from '@/hooks/useIncidents' import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains' import { IncidentCard } from '@/components/incident' import { cn } from '@/lib/utils' +import { API_V1_URL } from '@/lib/config' import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft, ChevronRight } from 'lucide-react' // ============================================================================= @@ -24,6 +25,7 @@ import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft const SEVERITY_ORDER: Record = { P0: 0, P1: 1, P2: 2, P3: 3 } const ALERTS_PAGE_SIZE = 50 +const SOURCE_COVERAGE_LIMIT = 100 const SEVERITY_STYLE: Record = { P0: { bg: 'bg-status-critical/10', text: 'text-status-critical' }, @@ -72,6 +74,35 @@ function StatPill({ label, value, highlight }: { label: string; value: number; h ) } +interface SourceCoverageSummary { + source_count: number + with_source_refs_total: number + missing_source_refs_total: number + sentry_ref_total: number + signoz_ref_total: number + alert_ref_total: number + latest_received_at?: string | null +} + +interface SourceCoverageProvider { + provider: string + total: number + missing_source_refs_total: number + sentry_ref_total: number + signoz_ref_total: number + alert_ref_total: number +} + +interface SourceCoverageResponse { + summary: SourceCoverageSummary + providers: SourceCoverageProvider[] +} + +function percentLabel(value: number, total: number) { + if (total <= 0) return '--' + return `${Math.round((value / total) * 100)}%` +} + // ============================================================================= // Page // ============================================================================= @@ -84,6 +115,13 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { pollInterval: 15000, enablePolling: true, }) + const [sourceCoverage, setSourceCoverage] = useState(null) + const [providerCoverage, setProviderCoverage] = useState>({ + sentry: null, + signoz: null, + }) + const [sourceCoverageError, setSourceCoverageError] = useState(null) + const [sourceCoverageLoading, setSourceCoverageLoading] = useState(false) const sorted = useMemo(() => { return [...(incidents ?? [])].sort( @@ -115,10 +153,59 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { return Boolean(chain?.source_id && !chain.fetch_error) }).length + useEffect(() => { + let active = true + const controller = new AbortController() + async function loadSourceCoverage() { + setSourceCoverageLoading(true) + setSourceCoverageError(null) + try { + const buildCoverageUrl = (provider?: string) => { + const params = new URLSearchParams() + params.set('project_id', 'awoooi') + params.set('limit', String(SOURCE_COVERAGE_LIMIT)) + if (provider) params.set('provider', provider) + return `${API_V1_URL}/platform/events/dossier/coverage?${params.toString()}` + } + const [overallResponse, sentryResponse, signozResponse] = await Promise.all([ + fetch(buildCoverageUrl(), { signal: controller.signal }), + fetch(buildCoverageUrl('sentry'), { signal: controller.signal }), + fetch(buildCoverageUrl('signoz'), { signal: controller.signal }), + ]) + if (!overallResponse.ok) throw new Error(`overall HTTP ${overallResponse.status}`) + if (!sentryResponse.ok) throw new Error(`sentry HTTP ${sentryResponse.status}`) + if (!signozResponse.ok) throw new Error(`signoz HTTP ${signozResponse.status}`) + const [overallData, sentryData, signozData] = await Promise.all([ + overallResponse.json() as Promise, + sentryResponse.json() as Promise, + signozResponse.json() as Promise, + ]) + if (active) { + setSourceCoverage(overallData) + setProviderCoverage({ sentry: sentryData, signoz: signozData }) + } + } catch (err) { + if (controller.signal.aborted) return + if (active) setSourceCoverageError(err instanceof Error ? err.message : String(err)) + } finally { + if (active) setSourceCoverageLoading(false) + } + } + loadSourceCoverage() + return () => { + active = false + controller.abort() + } + }, [lastUpdated]) + const p0Count = sorted.filter(i => i.severity === 'P0').length const p1Count = sorted.filter(i => i.severity === 'P1').length const p2Count = sorted.filter(i => i.severity === 'P2').length const p3Count = sorted.filter(i => i.severity === 'P3').length + const coverageSummary = sourceCoverage?.summary + const topCoverageProviders = sourceCoverage?.providers?.slice(0, 3) ?? [] + const sentryCoverageSummary = providerCoverage.sentry?.summary + const signozCoverageSummary = providerCoverage.signoz?.summary return ( @@ -156,6 +243,85 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { +
+
+
+

+ {tAlerts('sourceCoverageTitle')} +

+

+ {sourceCoverageError + ? tAlerts('sourceCoverageError', { error: sourceCoverageError }) + : tAlerts('sourceCoverageSubtitle', { limit: SOURCE_COVERAGE_LIMIT })} + {sourceCoverageLoading ? ` · ${t('common.loading')}` : ''} +

+
+
+ + + + 0 && (coverageSummary?.sentry_ref_total ?? 0) === 0} + /> + 0 && (coverageSummary?.signoz_ref_total ?? 0) === 0} + /> +
+
+
+ + {tAlerts('sourceCoverageRatio', { + ratio: percentLabel(coverageSummary?.with_source_refs_total ?? 0, coverageSummary?.source_count ?? 0), + total: coverageSummary?.source_count ?? 0, + })} + + {topCoverageProviders.map(provider => ( + + {tAlerts('sourceCoverageProvider', { + provider: provider.provider, + total: provider.total, + missing: provider.missing_source_refs_total, + sentry: provider.sentry_ref_total, + signoz: provider.signoz_ref_total, + })} + + ))} + + {tAlerts('sourceCoverageProviderWindow', { + provider: 'sentry', + total: sentryCoverageSummary?.source_count ?? 0, + withRefs: sentryCoverageSummary?.with_source_refs_total ?? 0, + missing: sentryCoverageSummary?.missing_source_refs_total ?? 0, + })} + + + {tAlerts('sourceCoverageProviderWindow', { + provider: 'signoz', + total: signozCoverageSummary?.source_count ?? 0, + withRefs: signozCoverageSummary?.with_source_refs_total ?? 0, + missing: signozCoverageSummary?.missing_source_refs_total ?? 0, + })} + +
+
+ {sorted.length > 0 && (