From c0f5ec186e01989fe769e3afb31cf09a7c83ee4a Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 22:49:49 +0800 Subject: [PATCH] feat(platform): add notification coverage board --- .../apps/web/src/pages/NotificationRules.tsx | 145 ++++++++++++++++++ .../apps/web/src/styles/global.css | 63 ++++++++ .../apps/web/src/test/App.test.tsx | 15 ++ 3 files changed, 223 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx index 3e0a10ff..aa502ccc 100644 --- a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx +++ b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx @@ -69,6 +69,14 @@ type NotificationExecutionRow = { evidenceColor: 'green' | 'orange'; }; +type NotificationCoverageOwnerRow = { + owner: string; + activeRules: number; + p0Rules: number; + hitCount: number; + primaryPolicy?: QualityNotificationPolicy; +}; + const notificationExportColumns: CsvColumn[] = [ { title: '类型', value: (row) => row.category }, { title: '名称', value: (row) => row.name }, @@ -236,6 +244,81 @@ function notificationExecutionMatrixReport(rows: NotificationExecutionRow[], rel ].join('\n'); } +function policyLevel(policy: QualityNotificationPolicy) { + if (policy.name.startsWith('P0')) return 'P0'; + if (policy.name.startsWith('P1')) return 'P1'; + return '-'; +} + +function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificationPolicy[]): NotificationCoverageOwnerRow[] { + const rows = new Map(); + for (const rule of rules) { + if (Number(rule.count ?? 0) <= 0) continue; + const owner = rule.owner?.trim() || '未分配责任方'; + rows.set(owner, [...(rows.get(owner) ?? []), rule]); + } + return Array.from(rows.entries()).map(([owner, ownerRules]) => { + const p0Rules = ownerRules.filter((rule) => rule.level === 'P0').length; + const primaryPolicy = policies.find((policy) => policyLevel(policy) === (p0Rules > 0 ? 'P0' : 'P1')) ?? policies[0]; + return { + owner, + activeRules: ownerRules.length, + p0Rules, + hitCount: ownerRules.reduce((total, rule) => total + Number(rule.count ?? 0), 0), + primaryPolicy + }; + }).sort((left, right) => right.hitCount - left.hitCount || right.p0Rules - left.p0Rules || left.owner.localeCompare(right.owner)); +} + +function notificationCoverageReport({ + plan, + release, + ownerRows, + overdueCount, + dueSoonCount, + health +}: { + plan: QualityNotificationPlan | null; + release?: string; + ownerRows: NotificationCoverageOwnerRow[]; + overdueCount: number; + dueSoonCount: number; + health: OpsHealth | null; +}) { + const rules = plan?.rules ?? []; + const policies = plan?.policies ?? []; + const issues = plan?.priorityIssues ?? []; + const activeRules = rules.filter((rule) => Number(rule.count ?? 0) > 0); + const p0Issues = issues.filter((issue) => issue.priority === 'P0').length; + const missingEvidence = issues.filter((issue) => countEvidenceLinks(issue) < 4).length; + const unhealthyLinks = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok'); + const policyChannels = Array.from(new Set(policies.map((policy) => policy.channel).filter(Boolean))); + return [ + '【通知覆盖与升级风险】', + `运行版本:${release?.trim() || '-'}`, + `责任覆盖:${ownerRows.length.toLocaleString()} 个责任方,${activeRules.length.toLocaleString()} 类活跃规则`, + `告警压力:待通知 ${issues.length.toLocaleString()} / P0 ${p0Issues.toLocaleString()} / 证据不完整 ${missingEvidence.toLocaleString()}`, + `升级风险:已超时 ${overdueCount.toLocaleString()} / 即将升级 ${dueSoonCount.toLocaleString()}`, + `通知渠道:${policyChannels.length > 0 ? policyChannels.join(';') : '-'}`, + `链路前置:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join(';') : '正常'}`, + '', + '责任方覆盖:', + ...(ownerRows.length > 0 ? ownerRows.slice(0, 8).map((row, index) => [ + `${index + 1}. ${row.owner}`, + ` 活跃规则:${row.activeRules.toLocaleString()},P0规则:${row.p0Rules.toLocaleString()},命中:${row.hitCount.toLocaleString()}`, + ` 主策略:${row.primaryPolicy ? `${row.primaryPolicy.name} / ${row.primaryPolicy.target} / ${row.primaryPolicy.channel}` : '-'}`, + ` 升级窗口:${row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'}`, + ` 验收:${row.primaryPolicy?.acceptanceCriteria || '-'}` + ].join('\n')) : ['暂无活跃责任方']), + '', + '管理动作:', + '1. P0 命中先确认责任方、渠道和升级窗口是否明确', + '2. 证据不完整的车辆先补齐车辆服务、实时、轨迹、RAW 链接', + '3. 超时或即将升级的告警必须同步业务责任人', + '4. 链路异常时优先恢复采集、MySQL、TDEngine、Redis、Kafka 等前置能力' + ].join('\n'); +} + function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) { const rules = plan?.rules ?? []; const policies = plan?.policies ?? []; @@ -465,6 +548,9 @@ export function NotificationRules() { const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length; const nextSlaRow = slaRows[0]; const executionRows = notificationExecutionRows(priorityIssues, rules, policies); + const ownerRows = coverageOwnerRows(rules, policies); + const totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0); + const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length; const exportNotificationRules = () => { const rows = notificationExportRows(plan, release); if (rows.length === 0) { @@ -501,6 +587,65 @@ export function NotificationRules() { + 通知覆盖与升级风险} + style={{ marginTop: 16 }} + > +
+
+ + 0 || overdueCount > 0 ? 'red' : dueSoonCount > 0 ? 'orange' : 'green'}> + {p0IssueCount > 0 || overdueCount > 0 ? '高优先级跟进' : dueSoonCount > 0 ? '临近升级' : '覆盖稳定'} + + 0 ? 'orange' : 'green'}>{unhealthyLinkCount > 0 ? '链路需关注' : '链路正常'} + + {ownerRows.length.toLocaleString()} 个责任方 + + 将告警规则、通知策略、证据完整度和 SLA 升级风险按责任团队归并,便于值班人确认是否能真正通知到人。 + +
+
+ {[ + { label: '责任覆盖', value: ownerRows.length.toLocaleString(), detail: `${activeRuleCount.toLocaleString()} 类活跃规则已映射责任方。`, color: ownerRows.length > 0 ? 'green' as const : 'orange' as const }, + { label: '活跃命中', value: totalActiveHits.toLocaleString(), detail: '来自当前告警规则命中数量,用于判断通知压力。', color: totalActiveHits > 0 ? 'orange' as const : 'green' as const }, + { label: 'P0压力', value: p0IssueCount.toLocaleString(), detail: `${p0RuleCount.toLocaleString()} 类 P0 规则,优先进入升级队列。`, color: p0IssueCount > 0 ? 'red' as const : 'green' as const }, + { label: '升级风险', value: `${overdueCount}/${dueSoonCount}`, detail: '已超时 / 即将升级,直接影响通知闭环时效。', color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const }, + { label: '链路前置', value: unhealthyLinkCount.toLocaleString(), detail: '采集、存储、缓存等前置链路异常会影响告警判定。', color: unhealthyLinkCount > 0 ? 'orange' as const : 'green' as const } + ].map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+
+ {ownerRows.length > 0 ? ownerRows.slice(0, 6).map((row) => ( +
+
+ + 0 ? 'red' : 'orange'}>{row.owner} + 命中 {row.hitCount.toLocaleString()} + + + {row.activeRules.toLocaleString()} 类活跃规则,{row.p0Rules.toLocaleString()} 类 P0。 + +
+ + {row.primaryPolicy?.name || '未匹配策略'} + {row.primaryPolicy?.channel || '-'} + {row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'} + +
+ )) : ( + 暂无活跃告警责任方 + )} +
+
+ { expect(screen.getByText('确认平台转发')).toBeInTheDocument(); expect(screen.getByText('通知规则页待通知告警')).toBeInTheDocument(); expect(screen.getByText('platform-rules-test')).toBeInTheDocument(); + expect(screen.getByText('通知覆盖与升级风险')).toBeInTheDocument(); + expect(screen.getByText('责任覆盖')).toBeInTheDocument(); + expect(screen.getByText('活跃命中')).toBeInTheDocument(); + expect(screen.getByText('P0压力')).toBeInTheDocument(); + expect(screen.getByText('升级风险')).toBeInTheDocument(); + expect(screen.getByText('链路前置')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制通知覆盖报告' })).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined); fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' })); @@ -3629,6 +3636,14 @@ test('renders notification rules as a standalone operations page', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('执行态势:P0 1 / 已超时 1 / 证据不完整 0')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知:接入运维 + 业务责任人 / 邮件 / 企业微信')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('规则:无来源规则 / 平台接入')); + fireEvent.click(screen.getByRole('button', { name: '复制通知覆盖报告' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知覆盖与升级风险】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('责任覆盖:2 个责任方,2 类活跃规则')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警压力:待通知 1 / P0 1 / 证据不完整 0')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级风险:已超时 1 / 即将升级 0')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知渠道:邮件 / 企业微信;每日汇总邮件')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('主策略:P0 实时中断 / 接入运维 + 业务责任人 / 邮件 / 企业微信')); fireEvent.click(screen.getByRole('button', { name: '复制SLA升级报告' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));