feat(platform): add notification reachability checks

This commit is contained in:
lingniu
2026-07-05 14:31:32 +08:00
parent 109ba09934
commit ee257b4aa1
3 changed files with 229 additions and 0 deletions

View File

@@ -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}

View File

@@ -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,

View File

@@ -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'));