feat(platform): surface notification priority queue

This commit is contained in:
lingniu
2026-07-04 18:17:09 +08:00
parent b8c8ff35fc
commit bdad6f7bf7
2 changed files with 104 additions and 3 deletions

View File

@@ -1,8 +1,8 @@
import { Button, Card, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy } from '../api/types';
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { qualityIssueLabel } from '../domain/qualityIssue';
@@ -56,6 +56,31 @@ function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?
return lines.join('\n');
}
function priorityIssueDigest(plan: QualityNotificationPlan | null, release?: string) {
const issues = plan?.priorityIssues ?? [];
return [
'【当前待通知告警】',
`运行版本:${release?.trim() || '-'}`,
`待通知:${issues.length.toLocaleString()}`,
...issues.map((issue, index) => [
`${index + 1}. [${issue.priority}] ${issue.vehicleLabel}`,
` 问题:${qualityIssueLabel(issue.issueType)} / ${issue.protocol}`,
` SLA${issue.sla}`,
` 建议动作:${issue.actionLabel}`,
` 详情:${issue.detail || '-'}`,
` 车辆服务:${window.location.origin}${window.location.pathname}${issue.vehicleHash}`,
` 实时定位:${window.location.origin}${window.location.pathname}${issue.realtimeHash}`,
` 轨迹证据:${window.location.origin}${window.location.pathname}${issue.historyHash}`,
` RAW证据${window.location.origin}${window.location.pathname}${issue.rawHash}`
].join('\n'))
].join('\n');
}
function navigateHash(hash: string) {
if (!hash) return;
window.location.hash = hash;
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
@@ -90,6 +115,7 @@ export function NotificationRules() {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const priorityIssues = plan?.priorityIssues ?? [];
const activeRuleCount = plan?.activeRuleCount ?? rules.filter((rule) => rule.count > 0).length;
const p0RuleCount = plan?.p0RuleCount ?? rules.filter((rule) => rule.count > 0 && rule.level === 'P0').length;
const release = health?.runtime?.platformRelease ?? '';
@@ -110,6 +136,10 @@ export function NotificationRules() {
<div className="vp-kpi-value">{policies.length.toLocaleString()}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{priorityIssues.length.toLocaleString()}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{release || '-'}</div>
<div className="vp-kpi-label"></div>
@@ -152,6 +182,46 @@ export function NotificationRules() {
/>
</Card>
<Card
bordered
loading={loading}
title={<Space><span></span><Button size="small" aria-label="复制待通知汇总" disabled={priorityIssues.length === 0} icon={<IconCopy />} onClick={() => copyText(priorityIssueDigest(plan, release), '待通知告警')}></Button></Space>}
style={{ marginTop: 16 }}
>
<Table<QualityPriorityIssue>
pagination={false}
dataSource={priorityIssues}
rowKey={(row?: QualityPriorityIssue) => `${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`}
columns={[
{ title: '优先级', width: 90, render: (_: unknown, row: QualityPriorityIssue) => <Tag color={row.priority === 'P0' ? 'red' : 'orange'}>{row.priority}</Tag> },
{ title: '车辆', width: 210, dataIndex: 'vehicleLabel' },
{ title: '问题', width: 130, render: (_: unknown, row: QualityPriorityIssue) => qualityIssueLabel(row.issueType) },
{ title: 'SLA', width: 120, dataIndex: 'sla' },
{ title: '建议动作', width: 160, dataIndex: 'actionLabel' },
{ title: '最后时间', width: 170, dataIndex: 'lastSeen' },
{
title: '说明',
render: (_: unknown, row: QualityPriorityIssue) => (
<Typography.Text ellipsis={{ showTooltip: true }}>{row.detail || row.actionDetail || '-'}</Typography.Text>
)
},
{
title: '证据',
width: 360,
render: (_: unknown, row: QualityPriorityIssue) => (
<Space spacing={4} wrap>
<Button size="small" disabled={!row.vehicleHash} onClick={() => navigateHash(row.vehicleHash)}></Button>
<Button size="small" disabled={!row.realtimeHash} onClick={() => navigateHash(row.realtimeHash)}></Button>
<Button size="small" disabled={!row.historyHash} onClick={() => navigateHash(row.historyHash)}></Button>
<Button size="small" disabled={!row.rawHash} onClick={() => navigateHash(row.rawHash)}>RAW</Button>
<Button size="small" aria-label="复制单条告警通知" icon={<IconCopy />} onClick={() => copyText(row.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</Card>
<Card bordered title="通知策略" loading={loading} style={{ marginTop: 16 }}>
<div className="vp-notification-policy-list">
{policies.map((policy: QualityNotificationPolicy) => (

View File

@@ -3294,7 +3294,27 @@ test('renders notification rules as a standalone operations page', async () => {
{ name: 'P0 实时中断', target: '接入运维 + 业务责任人', channel: '邮件 / 企业微信', condition: '无来源、VIN 缺失、存储不可写', escalationMinutes: 30, acceptanceCriteria: '来源恢复并持续 10 分钟' },
{ name: 'P1 数据质量', target: '协议解析 + 数据治理', channel: '每日汇总邮件', condition: '字段缺失、链路间断', escalationMinutes: 120, acceptanceCriteria: '核心字段恢复解析' }
],
priorityIssues: [],
priorityIssues: [{
vin: 'VIN-RULES-001',
plate: '粤A规则1',
phone: '',
sourceEndpoint: 'vehicle_identity_binding',
protocol: 'VEHICLE_SERVICE',
issueType: 'NO_SOURCE',
severity: 'error',
lastSeen: '2026-07-03 20:12:10',
detail: '通知规则页待通知告警',
priority: 'P0',
actionLabel: '确认平台转发',
actionDetail: '核对平台转发配置',
sla: '30 分钟确认',
vehicleLabel: '粤A规则1 / VIN-RULES-001',
realtimeHash: '#/realtime?keyword=VIN-RULES-001',
historyHash: '#/history?keyword=VIN-RULES-001',
rawHash: '#/history-query?keyword=VIN-RULES-001&tab=raw',
vehicleHash: '#/detail?keyword=VIN-RULES-001',
notificationText: '【P0 告警通知】通知规则页待通知'
}],
activeRuleCount: 2,
p0RuleCount: 1
},
@@ -3320,12 +3340,23 @@ test('renders notification rules as a standalone operations page', async () => {
expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument();
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument();
expect(screen.getByText('当前待通知告警')).toBeInTheDocument();
expect(screen.getByText('粤A规则1 / VIN-RULES-001')).toBeInTheDocument();
expect(screen.getByText('确认平台转发')).toBeInTheDocument();
expect(screen.getByText('通知规则页待通知告警')).toBeInTheDocument();
expect(screen.getByText('platform-rules-test')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined);
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
fireEvent.click(screen.getByRole('button', { name: '复制待通知汇总' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【当前待通知告警】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A规则1 / VIN-RULES-001'));
fireEvent.click(screen.getByRole('button', { name: '复制单条告警通知' }));
expect(writeText).toHaveBeenCalledWith('【P0 告警通知】通知规则页待通知');
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
expect(window.location.hash).toBe('#/detail?keyword=VIN-RULES-001');
});
test('renders ops quality as a standalone runtime health page', async () => {