feat(platform): add notification execution matrix

This commit is contained in:
lingniu
2026-07-04 22:03:21 +08:00
parent ae42a4092b
commit c27767bc5e
2 changed files with 117 additions and 0 deletions

View File

@@ -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<NotificationExportRow>[] = [
{ 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<SlaEscalationRow['status'], number> = { 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() {
</div>
</Card>
<Card
bordered
loading={loading}
title={<Space><span></span><Button size="small" aria-label="复制通知执行矩阵" icon={<IconCopy />} disabled={executionRows.length === 0} onClick={() => copyText(notificationExecutionMatrixReport(executionRows, release), '通知执行矩阵')}></Button></Space>}
style={{ marginTop: 16 }}
>
<Table<NotificationExecutionRow>
pagination={false}
dataSource={executionRows.slice(0, 10)}
rowKey={(row?: NotificationExecutionRow) => row?.key ?? ''}
columns={[
{ title: '优先级', width: 90, render: (_: unknown, row: NotificationExecutionRow) => <Tag color={row.issue.priority === 'P0' ? 'red' : 'orange'}>{row.issue.priority}</Tag> },
{ 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) => <Tag color={row.slaRow.statusColor}>{row.slaRow.statusLabel}</Tag> },
{ title: '证据', width: 110, render: (_: unknown, row: NotificationExecutionRow) => <Tag color={row.evidenceColor}>{row.evidenceText}</Tag> },
{
title: '操作',
render: (_: unknown, row: NotificationExecutionRow) => (
<Space wrap>
<Button size="small" disabled={!row.issue.vehicleHash} onClick={() => navigateHash(row.issue.vehicleHash)}></Button>
<Button size="small" disabled={!row.issue.rawHash} onClick={() => navigateHash(row.issue.rawHash)}>RAW证据</Button>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.issue.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</Card>
<Card
bordered
loading={loading}

View File

@@ -3564,6 +3564,10 @@ test('renders notification rules as a standalone operations page', async () => {
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'));