feat(platform): add notification execution matrix
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user