From c27767bc5e89e9b907eacd01d9c964c6625fbf59 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 22:03:21 +0800 Subject: [PATCH] feat(platform): add notification execution matrix --- .../apps/web/src/pages/NotificationRules.tsx | 108 ++++++++++++++++++ .../apps/web/src/test/App.test.tsx | 9 ++ 2 files changed, 117 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx index e0db1cd7..3e0a10ff 100644 --- a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx +++ b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx @@ -59,6 +59,16 @@ type SlaEscalationRow = { statusColor: 'red' | 'orange' | 'green' | 'grey'; }; +type NotificationExecutionRow = { + key: string; + issue: QualityPriorityIssue; + rule?: QualityAlertRule; + policy?: QualityNotificationPolicy; + slaRow: SlaEscalationRow; + evidenceText: string; + evidenceColor: 'green' | 'orange'; +}; + const notificationExportColumns: CsvColumn[] = [ { title: '类型', value: (row) => row.category }, { title: '名称', value: (row) => row.name }, @@ -163,6 +173,69 @@ function slaEscalationReport(rows: SlaEscalationRow[], release?: string) { ].join('\n'); } +function notificationExecutionRows( + issues: QualityPriorityIssue[], + rules: QualityAlertRule[], + policies: QualityNotificationPolicy[], + nowMs = Date.now() +): NotificationExecutionRow[] { + const slaByIssue = new Map(slaEscalationRows(issues, policies, nowMs).map((row) => [ + `${row.issue.priority}-${row.issue.protocol}-${row.issue.issueType}-${row.issue.vehicleLabel}-${row.issue.lastSeen}`, + row + ])); + return issues.map((issue) => { + const key = `${issue.priority}-${issue.protocol}-${issue.issueType}-${issue.vehicleLabel}-${issue.lastSeen}`; + const rule = rules.find((item) => item.issueType === issue.issueType); + const policy = policyForPriority(issue, policies); + const slaRow = slaByIssue.get(key) ?? { + issue, + policy, + status: 'unknown', + statusLabel: '待确认时间', + statusColor: 'grey' + } as SlaEscalationRow; + return { + key, + issue, + rule, + policy, + slaRow, + evidenceText: evidenceSummary(issue), + evidenceColor: evidenceColor(issue) + }; + }).sort((left, right) => { + const priorityWeight = (value: string) => value === 'P0' ? 2 : value === 'P1' ? 1 : 0; + const statusWeight: Record = { overdue: 3, due_soon: 2, tracking: 1, unknown: 0 }; + return priorityWeight(right.issue.priority) - priorityWeight(left.issue.priority) + || statusWeight[right.slaRow.status] - statusWeight[left.slaRow.status] + || left.issue.vehicleLabel.localeCompare(right.issue.vehicleLabel); + }); +} + +function notificationExecutionMatrixReport(rows: NotificationExecutionRow[], release?: string) { + const p0Count = rows.filter((row) => row.issue.priority === 'P0').length; + const incompleteEvidence = rows.filter((row) => countEvidenceLinks(row.issue) < 4).length; + const overdueCount = rows.filter((row) => row.slaRow.status === 'overdue').length; + return [ + '【通知执行矩阵】', + `运行版本:${release?.trim() || '-'}`, + `执行态势:P0 ${p0Count.toLocaleString()} / 已超时 ${overdueCount.toLocaleString()} / 证据不完整 ${incompleteEvidence.toLocaleString()}`, + '', + ...(rows.length > 0 ? rows.slice(0, 12).map((row, index) => [ + `${index + 1}. [${row.issue.priority}] ${row.issue.vehicleLabel}`, + ` 问题:${qualityIssueLabel(row.issue.issueType)} / ${row.issue.protocol}`, + ` 规则:${row.rule ? `${ruleTitle(row.rule)} / ${row.rule.owner}` : '-'}`, + ` 通知:${row.policy ? `${row.policy.target} / ${row.policy.channel}` : '-'}`, + ` 升级:${row.policy ? formatEscalationMinutes(row.policy.escalationMinutes) : '-'}`, + ` 状态:${row.slaRow.statusLabel}`, + ` 证据:${row.evidenceText}`, + ` 动作:${row.issue.actionLabel} - ${row.issue.actionDetail || '-'}`, + ` 车辆服务:${window.location.origin}${window.location.pathname}${row.issue.vehicleHash}`, + ` RAW证据:${window.location.origin}${window.location.pathname}${row.issue.rawHash}` + ].join('\n')) : ['暂无待执行通知']) + ].join('\n'); +} + function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) { const rules = plan?.rules ?? []; const policies = plan?.policies ?? []; @@ -391,6 +464,7 @@ export function NotificationRules() { const overdueCount = slaRows.filter((row) => row.status === 'overdue').length; const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length; const nextSlaRow = slaRows[0]; + const executionRows = notificationExecutionRows(priorityIssues, rules, policies); const exportNotificationRules = () => { const rows = notificationExportRows(plan, release); if (rows.length === 0) { @@ -472,6 +546,40 @@ export function NotificationRules() { + 通知执行矩阵} + style={{ marginTop: 16 }} + > + + pagination={false} + dataSource={executionRows.slice(0, 10)} + rowKey={(row?: NotificationExecutionRow) => row?.key ?? ''} + columns={[ + { title: '优先级', width: 90, render: (_: unknown, row: NotificationExecutionRow) => {row.issue.priority} }, + { title: '车辆', width: 220, render: (_: unknown, row: NotificationExecutionRow) => row.issue.vehicleLabel }, + { title: '问题', width: 130, render: (_: unknown, row: NotificationExecutionRow) => qualityIssueLabel(row.issue.issueType) }, + { title: '触发规则', width: 180, render: (_: unknown, row: NotificationExecutionRow) => row.rule ? `${ruleTitle(row.rule)} / ${row.rule.owner}` : '-' }, + { title: '通知对象', width: 210, render: (_: unknown, row: NotificationExecutionRow) => row.policy?.target || '-' }, + { title: '渠道', width: 150, render: (_: unknown, row: NotificationExecutionRow) => row.policy?.channel || '-' }, + { title: '升级窗口', width: 120, render: (_: unknown, row: NotificationExecutionRow) => row.policy ? formatEscalationMinutes(row.policy.escalationMinutes) : '-' }, + { title: 'SLA状态', width: 150, render: (_: unknown, row: NotificationExecutionRow) => {row.slaRow.statusLabel} }, + { title: '证据', width: 110, render: (_: unknown, row: NotificationExecutionRow) => {row.evidenceText} }, + { + title: '操作', + render: (_: unknown, row: NotificationExecutionRow) => ( + + + + + + ) + } + ]} + /> + + { expect(screen.getByText('主责任方')).toBeInTheDocument(); expect(screen.getAllByText('证据完整').length).toBeGreaterThan(0); expect(screen.getByText('核对平台转发配置')).toBeInTheDocument(); + expect(screen.getByText('通知执行矩阵')).toBeInTheDocument(); + expect(screen.getByText('无来源规则 / 平台接入')).toBeInTheDocument(); + expect(screen.getAllByText('邮件 / 企业微信').length).toBeGreaterThan(0); + expect(screen.getAllByText('30 分钟升级').length).toBeGreaterThan(0); expect(screen.getByText('SLA升级态势')).toBeInTheDocument(); expect(screen.getAllByText('已超时').length).toBeGreaterThan(0); expect(screen.getByText('即将升级')).toBeInTheDocument(); @@ -3602,6 +3606,11 @@ test('renders notification rules as a standalone operations page', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 对第一条待通知告警打开车辆服务、实时、轨迹、RAW 四类证据')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知文本:【P0 告警通知】通知规则页待通知')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹证据:http://localhost:3000/#/history?keyword=VIN-RULES-001')); + fireEvent.click(screen.getByRole('button', { name: '复制通知执行矩阵' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知执行矩阵】')); + 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: '复制SLA升级报告' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));