feat(web): show source dossier coverage on alerts
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 核心",
|
||||
|
||||
@@ -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<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 }
|
||||
const ALERTS_PAGE_SIZE = 50
|
||||
const SOURCE_COVERAGE_LIMIT = 100
|
||||
|
||||
const SEVERITY_STYLE: Record<string, { bg: string; text: string }> = {
|
||||
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<SourceCoverageResponse | null>(null)
|
||||
const [providerCoverage, setProviderCoverage] = useState<Record<string, SourceCoverageResponse | null>>({
|
||||
sentry: null,
|
||||
signoz: null,
|
||||
})
|
||||
const [sourceCoverageError, setSourceCoverageError] = useState<string | null>(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<SourceCoverageResponse>,
|
||||
sentryResponse.json() as Promise<SourceCoverageResponse>,
|
||||
signozResponse.json() as Promise<SourceCoverageResponse>,
|
||||
])
|
||||
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 (
|
||||
<AppLayout locale={params.locale}>
|
||||
@@ -156,6 +243,85 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
<StatPill label={t('incident.severity.P3')} value={p3Count} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-nothing-gray-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-body text-sm font-semibold text-nothing-black">
|
||||
{tAlerts('sourceCoverageTitle')}
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs text-nothing-gray-500">
|
||||
{sourceCoverageError
|
||||
? tAlerts('sourceCoverageError', { error: sourceCoverageError })
|
||||
: tAlerts('sourceCoverageSubtitle', { limit: SOURCE_COVERAGE_LIMIT })}
|
||||
{sourceCoverageLoading ? ` · ${t('common.loading')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
|
||||
<StatPill
|
||||
label={tAlerts('sourceCoverageWithRefs')}
|
||||
value={coverageSummary?.with_source_refs_total ?? 0}
|
||||
/>
|
||||
<StatPill
|
||||
label={tAlerts('sourceCoverageMissing')}
|
||||
value={coverageSummary?.missing_source_refs_total ?? 0}
|
||||
highlight
|
||||
/>
|
||||
<StatPill
|
||||
label={tAlerts('sourceCoverageAlert')}
|
||||
value={coverageSummary?.alert_ref_total ?? 0}
|
||||
/>
|
||||
<StatPill
|
||||
label={tAlerts('sourceCoverageSentry')}
|
||||
value={coverageSummary?.sentry_ref_total ?? 0}
|
||||
highlight={(coverageSummary?.source_count ?? 0) > 0 && (coverageSummary?.sentry_ref_total ?? 0) === 0}
|
||||
/>
|
||||
<StatPill
|
||||
label={tAlerts('sourceCoverageSigNoz')}
|
||||
value={coverageSummary?.signoz_ref_total ?? 0}
|
||||
highlight={(coverageSummary?.source_count ?? 0) > 0 && (coverageSummary?.signoz_ref_total ?? 0) === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 font-body text-xs text-nothing-gray-500">
|
||||
<span className="rounded border border-nothing-gray-200 bg-nothing-gray-50 px-2 py-1">
|
||||
{tAlerts('sourceCoverageRatio', {
|
||||
ratio: percentLabel(coverageSummary?.with_source_refs_total ?? 0, coverageSummary?.source_count ?? 0),
|
||||
total: coverageSummary?.source_count ?? 0,
|
||||
})}
|
||||
</span>
|
||||
{topCoverageProviders.map(provider => (
|
||||
<span
|
||||
key={provider.provider}
|
||||
className="rounded border border-nothing-gray-200 bg-nothing-gray-50 px-2 py-1"
|
||||
>
|
||||
{tAlerts('sourceCoverageProvider', {
|
||||
provider: provider.provider,
|
||||
total: provider.total,
|
||||
missing: provider.missing_source_refs_total,
|
||||
sentry: provider.sentry_ref_total,
|
||||
signoz: provider.signoz_ref_total,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
<span className="rounded border border-nothing-gray-200 bg-nothing-gray-50 px-2 py-1">
|
||||
{tAlerts('sourceCoverageProviderWindow', {
|
||||
provider: 'sentry',
|
||||
total: sentryCoverageSummary?.source_count ?? 0,
|
||||
withRefs: sentryCoverageSummary?.with_source_refs_total ?? 0,
|
||||
missing: sentryCoverageSummary?.missing_source_refs_total ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="rounded border border-nothing-gray-200 bg-nothing-gray-50 px-2 py-1">
|
||||
{tAlerts('sourceCoverageProviderWindow', {
|
||||
provider: 'signoz',
|
||||
total: signozCoverageSummary?.source_count ?? 0,
|
||||
withRefs: signozCoverageSummary?.with_source_refs_total ?? 0,
|
||||
missing: signozCoverageSummary?.missing_source_refs_total ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sorted.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>
|
||||
|
||||
Reference in New Issue
Block a user