diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 377ab35d..29dbb33b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -747,8 +747,13 @@ "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}" + "sourceCoverageProvider": "{provider}: total {total}, missing {missing}, Sentry {sentry}, SigNoz {signoz}, latest {latest} ({age})", + "sourceCoverageProviderWindow": "{provider} window: total {total}, with refs {withRefs}, missing {missing}, latest {latest} ({age})", + "sourceCoverageFreshness": "{provider} latest {latest} ({age})", + "sourceCoverageFresh": "fresh", + "sourceCoverageStaleHours": "stale {hours}h", + "sourceCoverageStaleDays": "stale {days}d", + "sourceCoverageNoEvents": "no events" }, "navSection": { "aiCore": "AI Core", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 345d038c..a912767a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -748,8 +748,13 @@ "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}" + "sourceCoverageProvider": "{provider}: total {total}, missing {missing}, Sentry {sentry}, SigNoz {signoz}, latest {latest} ({age})", + "sourceCoverageProviderWindow": "{provider} window: total {total}, with refs {withRefs}, missing {missing}, latest {latest} ({age})", + "sourceCoverageFreshness": "{provider} latest {latest} ({age})", + "sourceCoverageFresh": "fresh", + "sourceCoverageStaleHours": "stale {hours}h", + "sourceCoverageStaleDays": "stale {days}d", + "sourceCoverageNoEvents": "no events" }, "navSection": { "aiCore": "AI 核心", diff --git a/apps/web/src/app/[locale]/alerts/page.tsx b/apps/web/src/app/[locale]/alerts/page.tsx index e166ee57..94d8a6d6 100644 --- a/apps/web/src/app/[locale]/alerts/page.tsx +++ b/apps/web/src/app/[locale]/alerts/page.tsx @@ -26,6 +26,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 SOURCE_FRESHNESS_STALE_HOURS = 24 const SEVERITY_STYLE: Record = { P0: { bg: 'bg-status-critical/10', text: 'text-status-critical' }, @@ -91,6 +92,7 @@ interface SourceCoverageProvider { sentry_ref_total: number signoz_ref_total: number alert_ref_total: number + latest_received_at?: string | null } interface SourceCoverageResponse { @@ -103,6 +105,38 @@ function percentLabel(value: number, total: number) { return `${Math.round((value / total) * 100)}%` } +function parseApiTimestamp(value?: string | null) { + if (!value) return null + const normalized = /(?:z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z` + const date = new Date(normalized) + return Number.isNaN(date.getTime()) ? null : date +} + +function formatTimestamp(value: string | null | undefined, locale: string, emptyLabel: string) { + const date = parseApiTimestamp(value) + if (!date) return emptyLabel + return new Intl.DateTimeFormat(locale, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date) +} + +function freshnessAgeHours(value?: string | null) { + const date = parseApiTimestamp(value) + if (!date) return null + return Math.max(0, (Date.now() - date.getTime()) / 3_600_000) +} + +function freshnessToneClass(ageHours: number | null) { + if (ageHours === null || ageHours > SOURCE_FRESHNESS_STALE_HOURS) { + return 'border-status-warning/30 bg-status-warning/5 text-status-warning' + } + return 'border-status-healthy/30 bg-status-healthy/5 text-status-healthy' +} + // ============================================================================= // Page // ============================================================================= @@ -206,6 +240,17 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { const topCoverageProviders = sourceCoverage?.providers?.slice(0, 3) ?? [] const sentryCoverageSummary = providerCoverage.sentry?.summary const signozCoverageSummary = providerCoverage.signoz?.summary + const emptyFreshnessLabel = tAlerts('sourceCoverageNoEvents') + const formatFreshnessAge = (value?: string | null) => { + const ageHours = freshnessAgeHours(value) + if (ageHours === null) return emptyFreshnessLabel + if (ageHours <= SOURCE_FRESHNESS_STALE_HOURS) return tAlerts('sourceCoverageFresh') + if (ageHours < 48) return tAlerts('sourceCoverageStaleHours', { hours: Math.floor(ageHours) }) + return tAlerts('sourceCoverageStaleDays', { days: Math.floor(ageHours / 24) }) + } + const coverageFreshnessAge = freshnessAgeHours(coverageSummary?.latest_received_at) + const sentryFreshnessAge = freshnessAgeHours(sentryCoverageSummary?.latest_received_at) + const signozFreshnessAge = freshnessAgeHours(signozCoverageSummary?.latest_received_at) return ( @@ -289,10 +334,23 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { total: coverageSummary?.source_count ?? 0, })} + + {tAlerts('sourceCoverageFreshness', { + provider: 'overall', + latest: formatTimestamp(coverageSummary?.latest_received_at, params.locale, emptyFreshnessLabel), + age: formatFreshnessAge(coverageSummary?.latest_received_at), + })} + {topCoverageProviders.map(provider => ( {tAlerts('sourceCoverageProvider', { provider: provider.provider, @@ -300,23 +358,35 @@ export default function AlertsPage({ params }: { params: { locale: string } }) { missing: provider.missing_source_refs_total, sentry: provider.sentry_ref_total, signoz: provider.signoz_ref_total, + latest: formatTimestamp(provider.latest_received_at, params.locale, emptyFreshnessLabel), + age: formatFreshnessAge(provider.latest_received_at), })} ))} - + {tAlerts('sourceCoverageProviderWindow', { provider: 'sentry', total: sentryCoverageSummary?.source_count ?? 0, withRefs: sentryCoverageSummary?.with_source_refs_total ?? 0, missing: sentryCoverageSummary?.missing_source_refs_total ?? 0, + latest: formatTimestamp(sentryCoverageSummary?.latest_received_at, params.locale, emptyFreshnessLabel), + age: formatFreshnessAge(sentryCoverageSummary?.latest_received_at), })} - + {tAlerts('sourceCoverageProviderWindow', { provider: 'signoz', total: signozCoverageSummary?.source_count ?? 0, withRefs: signozCoverageSummary?.with_source_refs_total ?? 0, missing: signozCoverageSummary?.missing_source_refs_total ?? 0, + latest: formatTimestamp(signozCoverageSummary?.latest_received_at, params.locale, emptyFreshnessLabel), + age: formatFreshnessAge(signozCoverageSummary?.latest_received_at), })}