feat(platform): surface notification priority queue
This commit is contained in:
@@ -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 { IconCopy } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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 { PageHeader } from '../components/PageHeader';
|
||||||
import { qualityIssueLabel } from '../domain/qualityIssue';
|
import { qualityIssueLabel } from '../domain/qualityIssue';
|
||||||
|
|
||||||
@@ -56,6 +56,31 @@ function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?
|
|||||||
return lines.join('\n');
|
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) {
|
async function copyText(value: string, label: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -90,6 +115,7 @@ export function NotificationRules() {
|
|||||||
|
|
||||||
const rules = plan?.rules ?? [];
|
const rules = plan?.rules ?? [];
|
||||||
const policies = plan?.policies ?? [];
|
const policies = plan?.policies ?? [];
|
||||||
|
const priorityIssues = plan?.priorityIssues ?? [];
|
||||||
const activeRuleCount = plan?.activeRuleCount ?? rules.filter((rule) => rule.count > 0).length;
|
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 p0RuleCount = plan?.p0RuleCount ?? rules.filter((rule) => rule.count > 0 && rule.level === 'P0').length;
|
||||||
const release = health?.runtime?.platformRelease ?? '';
|
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-value">{policies.length.toLocaleString()}</div>
|
||||||
<div className="vp-kpi-label">通知策略</div>
|
<div className="vp-kpi-label">通知策略</div>
|
||||||
</Card>
|
</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}>
|
<Card bordered loading={loading}>
|
||||||
<div className="vp-kpi-value">{release || '-'}</div>
|
<div className="vp-kpi-value">{release || '-'}</div>
|
||||||
<div className="vp-kpi-label">运行版本</div>
|
<div className="vp-kpi-label">运行版本</div>
|
||||||
@@ -152,6 +182,46 @@ export function NotificationRules() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</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 }}>
|
<Card bordered title="通知策略" loading={loading} style={{ marginTop: 16 }}>
|
||||||
<div className="vp-notification-policy-list">
|
<div className="vp-notification-policy-list">
|
||||||
{policies.map((policy: QualityNotificationPolicy) => (
|
{policies.map((policy: QualityNotificationPolicy) => (
|
||||||
|
|||||||
@@ -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: 'P0 实时中断', target: '接入运维 + 业务责任人', channel: '邮件 / 企业微信', condition: '无来源、VIN 缺失、存储不可写', escalationMinutes: 30, acceptanceCriteria: '来源恢复并持续 10 分钟' },
|
||||||
{ name: 'P1 数据质量', target: '协议解析 + 数据治理', channel: '每日汇总邮件', condition: '字段缺失、链路间断', escalationMinutes: 120, acceptanceCriteria: '核心字段恢复解析' }
|
{ 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,
|
activeRuleCount: 2,
|
||||||
p0RuleCount: 1
|
p0RuleCount: 1
|
||||||
},
|
},
|
||||||
@@ -3320,12 +3340,23 @@ test('renders notification rules as a standalone operations page', async () => {
|
|||||||
expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument();
|
expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument();
|
||||||
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
|
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
|
||||||
expect(screen.getByText('接入运维 + 业务责任人')).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(screen.getByText('platform-rules-test')).toBeInTheDocument();
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
|
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 () => {
|
test('renders ops quality as a standalone runtime health page', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user