feat(platform): add notification reachability checks
This commit is contained in:
@@ -77,6 +77,22 @@ type NotificationCoverageOwnerRow = {
|
||||
primaryPolicy?: QualityNotificationPolicy;
|
||||
};
|
||||
|
||||
type NotificationReachabilityRow = {
|
||||
owner: string;
|
||||
issueCount: number;
|
||||
p0IssueCount: number;
|
||||
ruleCount: number;
|
||||
channel: string;
|
||||
target: string;
|
||||
escalation: string;
|
||||
acceptance: string;
|
||||
evidenceComplete: number;
|
||||
status: 'ready' | 'risk' | 'blocked';
|
||||
statusLabel: string;
|
||||
statusColor: 'green' | 'orange' | 'red';
|
||||
nextAction: string;
|
||||
};
|
||||
|
||||
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
|
||||
{ title: '类型', value: (row) => row.category },
|
||||
{ title: '名称', value: (row) => row.name },
|
||||
@@ -270,6 +286,112 @@ function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificat
|
||||
}).sort((left, right) => right.hitCount - left.hitCount || right.p0Rules - left.p0Rules || left.owner.localeCompare(right.owner));
|
||||
}
|
||||
|
||||
function notificationReachabilityRows(
|
||||
issues: QualityPriorityIssue[],
|
||||
rules: QualityAlertRule[],
|
||||
policies: QualityNotificationPolicy[],
|
||||
slaRows: SlaEscalationRow[]
|
||||
): NotificationReachabilityRow[] {
|
||||
const slaByIssue = new Map(slaRows.map((row) => [
|
||||
`${row.issue.priority}-${row.issue.protocol}-${row.issue.issueType}-${row.issue.vehicleLabel}-${row.issue.lastSeen}`,
|
||||
row
|
||||
]));
|
||||
const ownerMap = new Map<string, { issues: QualityPriorityIssue[]; rules: QualityAlertRule[]; policy?: QualityNotificationPolicy; overdue: number; dueSoon: number }>();
|
||||
|
||||
for (const rule of rules) {
|
||||
if (Number(rule.count ?? 0) <= 0) continue;
|
||||
const owner = rule.owner?.trim() || '未分配责任方';
|
||||
const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 };
|
||||
current.rules.push(rule);
|
||||
ownerMap.set(owner, current);
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
const rule = rules.find((item) => item.issueType === issue.issueType);
|
||||
const owner = rule?.owner?.trim() || policyForPriority(issue, policies)?.target || '未分配责任方';
|
||||
const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 };
|
||||
current.issues.push(issue);
|
||||
const sla = slaByIssue.get(`${issue.priority}-${issue.protocol}-${issue.issueType}-${issue.vehicleLabel}-${issue.lastSeen}`);
|
||||
if (sla?.status === 'overdue') current.overdue += 1;
|
||||
if (sla?.status === 'due_soon') current.dueSoon += 1;
|
||||
if (!current.policy) current.policy = policyForPriority(issue, policies);
|
||||
ownerMap.set(owner, current);
|
||||
}
|
||||
|
||||
return Array.from(ownerMap.entries()).map(([owner, value]) => {
|
||||
const p0IssueCount = value.issues.filter((issue) => issue.priority === 'P0').length;
|
||||
const evidenceComplete = value.issues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
|
||||
const p0Rules = value.rules.filter((rule) => rule.level === 'P0').length;
|
||||
const policy = value.policy
|
||||
?? policies.find((item) => policyLevel(item) === (p0IssueCount > 0 || p0Rules > 0 ? 'P0' : 'P1'))
|
||||
?? policies[0];
|
||||
const hasPolicy = Boolean(policy?.target && policy.channel);
|
||||
const hasAcceptance = Boolean(policy?.acceptanceCriteria);
|
||||
const evidenceMissing = value.issues.length > 0 && evidenceComplete < value.issues.length;
|
||||
const blocked = !hasPolicy || value.overdue > 0;
|
||||
const risk = !blocked && (value.dueSoon > 0 || evidenceMissing || !hasAcceptance || p0IssueCount > 0);
|
||||
const status: NotificationReachabilityRow['status'] = blocked ? 'blocked' : risk ? 'risk' : 'ready';
|
||||
const statusColor: NotificationReachabilityRow['statusColor'] = blocked ? 'red' : risk ? 'orange' : 'green';
|
||||
return {
|
||||
owner,
|
||||
issueCount: value.issues.length,
|
||||
p0IssueCount,
|
||||
ruleCount: value.rules.length,
|
||||
channel: policy?.channel || '-',
|
||||
target: policy?.target || '-',
|
||||
escalation: policy ? formatEscalationMinutes(policy.escalationMinutes) : '-',
|
||||
acceptance: policy?.acceptanceCriteria || '-',
|
||||
evidenceComplete,
|
||||
status,
|
||||
statusLabel: blocked ? (value.overdue > 0 ? '已超时' : '策略缺失') : risk ? '需跟进' : '可触达',
|
||||
statusColor,
|
||||
nextAction: blocked
|
||||
? (value.overdue > 0 ? '立即升级责任人并同步业务侧' : '补齐通知对象与渠道')
|
||||
: evidenceMissing
|
||||
? '补齐车辆服务、实时、轨迹、原始记录证据'
|
||||
: value.dueSoon > 0
|
||||
? '临近升级,先确认恢复进度'
|
||||
: p0IssueCount > 0
|
||||
? '先发送P0通知并记录回执'
|
||||
: '按策略持续观察并复核验收'
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
const statusWeight: Record<NotificationReachabilityRow['status'], number> = { blocked: 3, risk: 2, ready: 1 };
|
||||
return statusWeight[right.status] - statusWeight[left.status]
|
||||
|| right.p0IssueCount - left.p0IssueCount
|
||||
|| right.issueCount - left.issueCount
|
||||
|| left.owner.localeCompare(right.owner);
|
||||
});
|
||||
}
|
||||
|
||||
function notificationReachabilityReport(rows: NotificationReachabilityRow[], release?: string) {
|
||||
const blocked = rows.filter((row) => row.status === 'blocked').length;
|
||||
const risk = rows.filter((row) => row.status === 'risk').length;
|
||||
const ready = rows.filter((row) => row.status === 'ready').length;
|
||||
return [
|
||||
'【通知可达性检查】',
|
||||
`运行版本:${release?.trim() || '-'}`,
|
||||
`可达结论:阻断 ${blocked.toLocaleString()} / 需跟进 ${risk.toLocaleString()} / 可触达 ${ready.toLocaleString()}`,
|
||||
'',
|
||||
...(rows.length > 0 ? rows.slice(0, 10).map((row, index) => [
|
||||
`${index + 1}. ${row.owner} / ${row.statusLabel}`,
|
||||
` 待通知:${row.issueCount.toLocaleString()} 条,P0 ${row.p0IssueCount.toLocaleString()} 条,活跃规则 ${row.ruleCount.toLocaleString()} 类`,
|
||||
` 通知对象:${row.target}`,
|
||||
` 渠道:${row.channel}`,
|
||||
` 升级:${row.escalation}`,
|
||||
` 证据:${row.evidenceComplete.toLocaleString()}/${row.issueCount.toLocaleString()} 完整`,
|
||||
` 验收:${row.acceptance}`,
|
||||
` 下一步:${row.nextAction}`
|
||||
].join('\n')) : ['暂无待通知责任方']),
|
||||
'',
|
||||
'检查口径:',
|
||||
'1. 必须有责任方、通知对象和渠道',
|
||||
'2. P0 和超时告警优先进入升级队列',
|
||||
'3. 通知前至少带上车辆服务、实时、轨迹和原始记录证据',
|
||||
'4. 恢复后按验收口径复核,确认告警不再命中'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function notificationCoverageReport({
|
||||
plan,
|
||||
release,
|
||||
@@ -549,6 +671,9 @@ export function NotificationRules() {
|
||||
const nextSlaRow = slaRows[0];
|
||||
const executionRows = notificationExecutionRows(priorityIssues, rules, policies);
|
||||
const ownerRows = coverageOwnerRows(rules, policies);
|
||||
const reachabilityRows = notificationReachabilityRows(priorityIssues, rules, policies, slaRows);
|
||||
const reachabilityBlockedCount = reachabilityRows.filter((row) => row.status === 'blocked').length;
|
||||
const reachabilityRiskCount = reachabilityRows.filter((row) => row.status === 'risk').length;
|
||||
const totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0);
|
||||
const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
|
||||
const notificationCustomerSteps = [
|
||||
@@ -731,6 +856,52 @@ export function NotificationRules() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered
|
||||
loading={loading}
|
||||
title={<Space><span>通知可达性检查</span><Button size="small" aria-label="复制通知可达性检查" icon={<IconCopy />} onClick={() => copyText(notificationReachabilityReport(reachabilityRows, release), '通知可达性检查')}>复制可达性检查</Button></Space>}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div className="vp-notification-reachability-board">
|
||||
<div className="vp-notification-reachability-summary">
|
||||
<Space wrap>
|
||||
<Tag color={reachabilityBlockedCount > 0 ? 'red' : reachabilityRiskCount > 0 ? 'orange' : 'green'}>
|
||||
{reachabilityBlockedCount > 0 ? '存在阻断' : reachabilityRiskCount > 0 ? '需要跟进' : '可触达'}
|
||||
</Tag>
|
||||
<Tag color="blue">{reachabilityRows.length.toLocaleString()} 个责任方</Tag>
|
||||
</Space>
|
||||
<Typography.Text strong>
|
||||
阻断 {reachabilityBlockedCount.toLocaleString()} / 风险 {reachabilityRiskCount.toLocaleString()}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
按责任方检查通知对象、渠道、升级窗口、证据完整度和验收口径,避免告警已经触发但无人收到或无法闭环。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="vp-notification-reachability-grid">
|
||||
{reachabilityRows.length > 0 ? reachabilityRows.slice(0, 6).map((row) => (
|
||||
<div key={row.owner} className="vp-notification-reachability-item">
|
||||
<Space wrap>
|
||||
<Tag color={row.statusColor}>{row.statusLabel}</Tag>
|
||||
<Tag color={row.p0IssueCount > 0 ? 'red' : 'blue'}>{row.owner}</Tag>
|
||||
</Space>
|
||||
<strong>{row.issueCount.toLocaleString()} 条待通知</strong>
|
||||
<Typography.Text type="secondary">
|
||||
对象:{row.target};渠道:{row.channel};升级:{row.escalation}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color={row.evidenceComplete === row.issueCount ? 'green' : 'orange'}>证据 {row.evidenceComplete}/{row.issueCount}</Tag>
|
||||
<Tag color={row.acceptance === '-' ? 'orange' : 'green'}>{row.acceptance === '-' ? '缺验收' : '有验收'}</Tag>
|
||||
<Tag color={row.p0IssueCount > 0 ? 'red' : 'grey'}>P0 {row.p0IssueCount}</Tag>
|
||||
</Space>
|
||||
<Typography.Text>{row.nextAction}</Typography.Text>
|
||||
</div>
|
||||
)) : (
|
||||
<Tag color="green">暂无待检查通知责任方</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered
|
||||
loading={loading}
|
||||
|
||||
@@ -3519,6 +3519,47 @@ button.vp-realtime-command-item:focus-visible {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vp-notification-reachability-board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.56fr) minmax(0, 1.44fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vp-notification-reachability-summary {
|
||||
min-height: 176px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(22, 100, 255, 0.28);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #f8fbff;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-notification-reachability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-notification-reachability-item {
|
||||
min-height: 176px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-notification-reachability-item strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-alert-policy-detail {
|
||||
margin-top: 8px;
|
||||
color: var(--vp-text-muted);
|
||||
@@ -6209,6 +6250,8 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-notification-coverage-board,
|
||||
.vp-notification-coverage-grid,
|
||||
.vp-notification-owner-item,
|
||||
.vp-notification-reachability-board,
|
||||
.vp-notification-reachability-grid,
|
||||
.vp-sla-summary,
|
||||
.vp-risk-grid,
|
||||
.vp-ops-action-grid,
|
||||
|
||||
@@ -4057,6 +4057,14 @@ test('renders notification rules as a standalone operations page', async () => {
|
||||
expect(screen.getByText('升级风险')).toBeInTheDocument();
|
||||
expect(screen.getByText('链路前置')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '复制通知覆盖报告' })).toBeInTheDocument();
|
||||
expect(screen.getByText('通知可达性检查')).toBeInTheDocument();
|
||||
expect(screen.getByText('避免告警已经触发但无人收到或无法闭环。', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('阻断 1 / 风险 0')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('已超时').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('1 条待通知').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('对象:接入运维 + 业务责任人;渠道:邮件 / 企业微信;升级:30 分钟升级').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('立即升级责任人并同步业务侧')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '复制通知可达性检查' })).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
||||
@@ -4099,6 +4107,13 @@ test('renders notification rules as a standalone operations page', async () => {
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知渠道:邮件 / 企业微信;每日汇总邮件'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('主策略:P0 实时中断 / 接入运维 + 业务责任人 / 邮件 / 企业微信'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制通知可达性检查' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知可达性检查】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('可达结论:阻断 1 / 需跟进 0 / 可触达 1'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入 / 已超时'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('渠道:邮件 / 企业微信'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('证据:1/1 完整'));
|
||||
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