feat(platform): add notification coverage board

This commit is contained in:
lingniu
2026-07-04 22:49:49 +08:00
parent 4aa4e6086b
commit c0f5ec186e
3 changed files with 223 additions and 0 deletions

View File

@@ -69,6 +69,14 @@ type NotificationExecutionRow = {
evidenceColor: 'green' | 'orange';
};
type NotificationCoverageOwnerRow = {
owner: string;
activeRules: number;
p0Rules: number;
hitCount: number;
primaryPolicy?: QualityNotificationPolicy;
};
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
{ title: '类型', value: (row) => row.category },
{ title: '名称', value: (row) => row.name },
@@ -236,6 +244,81 @@ function notificationExecutionMatrixReport(rows: NotificationExecutionRow[], rel
].join('\n');
}
function policyLevel(policy: QualityNotificationPolicy) {
if (policy.name.startsWith('P0')) return 'P0';
if (policy.name.startsWith('P1')) return 'P1';
return '-';
}
function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificationPolicy[]): NotificationCoverageOwnerRow[] {
const rows = new Map<string, QualityAlertRule[]>();
for (const rule of rules) {
if (Number(rule.count ?? 0) <= 0) continue;
const owner = rule.owner?.trim() || '未分配责任方';
rows.set(owner, [...(rows.get(owner) ?? []), rule]);
}
return Array.from(rows.entries()).map(([owner, ownerRules]) => {
const p0Rules = ownerRules.filter((rule) => rule.level === 'P0').length;
const primaryPolicy = policies.find((policy) => policyLevel(policy) === (p0Rules > 0 ? 'P0' : 'P1')) ?? policies[0];
return {
owner,
activeRules: ownerRules.length,
p0Rules,
hitCount: ownerRules.reduce((total, rule) => total + Number(rule.count ?? 0), 0),
primaryPolicy
};
}).sort((left, right) => right.hitCount - left.hitCount || right.p0Rules - left.p0Rules || left.owner.localeCompare(right.owner));
}
function notificationCoverageReport({
plan,
release,
ownerRows,
overdueCount,
dueSoonCount,
health
}: {
plan: QualityNotificationPlan | null;
release?: string;
ownerRows: NotificationCoverageOwnerRow[];
overdueCount: number;
dueSoonCount: number;
health: OpsHealth | null;
}) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const issues = plan?.priorityIssues ?? [];
const activeRules = rules.filter((rule) => Number(rule.count ?? 0) > 0);
const p0Issues = issues.filter((issue) => issue.priority === 'P0').length;
const missingEvidence = issues.filter((issue) => countEvidenceLinks(issue) < 4).length;
const unhealthyLinks = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const policyChannels = Array.from(new Set(policies.map((policy) => policy.channel).filter(Boolean)));
return [
'【通知覆盖与升级风险】',
`运行版本:${release?.trim() || '-'}`,
`责任覆盖:${ownerRows.length.toLocaleString()} 个责任方,${activeRules.length.toLocaleString()} 类活跃规则`,
`告警压力:待通知 ${issues.length.toLocaleString()} / P0 ${p0Issues.toLocaleString()} / 证据不完整 ${missingEvidence.toLocaleString()}`,
`升级风险:已超时 ${overdueCount.toLocaleString()} / 即将升级 ${dueSoonCount.toLocaleString()}`,
`通知渠道:${policyChannels.length > 0 ? policyChannels.join('') : '-'}`,
`链路前置:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'}`,
'',
'责任方覆盖:',
...(ownerRows.length > 0 ? ownerRows.slice(0, 8).map((row, index) => [
`${index + 1}. ${row.owner}`,
` 活跃规则:${row.activeRules.toLocaleString()}P0规则${row.p0Rules.toLocaleString()},命中:${row.hitCount.toLocaleString()}`,
` 主策略:${row.primaryPolicy ? `${row.primaryPolicy.name} / ${row.primaryPolicy.target} / ${row.primaryPolicy.channel}` : '-'}`,
` 升级窗口:${row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'}`,
` 验收:${row.primaryPolicy?.acceptanceCriteria || '-'}`
].join('\n')) : ['暂无活跃责任方']),
'',
'管理动作:',
'1. P0 命中先确认责任方、渠道和升级窗口是否明确',
'2. 证据不完整的车辆先补齐车辆服务、实时、轨迹、RAW 链接',
'3. 超时或即将升级的告警必须同步业务责任人',
'4. 链路异常时优先恢复采集、MySQL、TDEngine、Redis、Kafka 等前置能力'
].join('\n');
}
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
@@ -465,6 +548,9 @@ export function NotificationRules() {
const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length;
const nextSlaRow = slaRows[0];
const executionRows = notificationExecutionRows(priorityIssues, rules, policies);
const ownerRows = coverageOwnerRows(rules, policies);
const totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0);
const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
const exportNotificationRules = () => {
const rows = notificationExportRows(plan, release);
if (rows.length === 0) {
@@ -501,6 +587,65 @@ export function NotificationRules() {
</Card>
</div>
<Card
bordered
loading={loading}
title={<Space><span></span><Button size="small" aria-label="复制通知覆盖报告" icon={<IconCopy />} onClick={() => copyText(notificationCoverageReport({ plan, release, ownerRows, overdueCount, dueSoonCount, health }), '通知覆盖报告')}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-notification-coverage-board">
<div className="vp-notification-coverage-summary">
<Space wrap>
<Tag color={p0IssueCount > 0 || overdueCount > 0 ? 'red' : dueSoonCount > 0 ? 'orange' : 'green'}>
{p0IssueCount > 0 || overdueCount > 0 ? '高优先级跟进' : dueSoonCount > 0 ? '临近升级' : '覆盖稳定'}
</Tag>
<Tag color={unhealthyLinkCount > 0 ? 'orange' : 'green'}>{unhealthyLinkCount > 0 ? '链路需关注' : '链路正常'}</Tag>
</Space>
<Typography.Text strong>{ownerRows.length.toLocaleString()} </Typography.Text>
<Typography.Text type="secondary">
SLA 便
</Typography.Text>
</div>
<div className="vp-notification-coverage-grid">
{[
{ label: '责任覆盖', value: ownerRows.length.toLocaleString(), detail: `${activeRuleCount.toLocaleString()} 类活跃规则已映射责任方。`, color: ownerRows.length > 0 ? 'green' as const : 'orange' as const },
{ label: '活跃命中', value: totalActiveHits.toLocaleString(), detail: '来自当前告警规则命中数量,用于判断通知压力。', color: totalActiveHits > 0 ? 'orange' as const : 'green' as const },
{ label: 'P0压力', value: p0IssueCount.toLocaleString(), detail: `${p0RuleCount.toLocaleString()} 类 P0 规则,优先进入升级队列。`, color: p0IssueCount > 0 ? 'red' as const : 'green' as const },
{ label: '升级风险', value: `${overdueCount}/${dueSoonCount}`, detail: '已超时 / 即将升级,直接影响通知闭环时效。', color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const },
{ label: '链路前置', value: unhealthyLinkCount.toLocaleString(), detail: '采集、存储、缓存等前置链路异常会影响告警判定。', color: unhealthyLinkCount > 0 ? 'orange' as const : 'green' as const }
].map((item) => (
<div key={item.label} className="vp-notification-coverage-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</div>
<div className="vp-notification-owner-list">
{ownerRows.length > 0 ? ownerRows.slice(0, 6).map((row) => (
<div key={row.owner} className="vp-notification-owner-item">
<div>
<Space wrap>
<Tag color={row.p0Rules > 0 ? 'red' : 'orange'}>{row.owner}</Tag>
<Typography.Text strong> {row.hitCount.toLocaleString()}</Typography.Text>
</Space>
<Typography.Text type="secondary">
{row.activeRules.toLocaleString()} {row.p0Rules.toLocaleString()} P0
</Typography.Text>
</div>
<Space wrap>
<Tag color={row.primaryPolicy?.name.startsWith('P0') ? 'red' : 'blue'}>{row.primaryPolicy?.name || '未匹配策略'}</Tag>
<Tag color="blue">{row.primaryPolicy?.channel || '-'}</Tag>
<Tag color="grey">{row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'}</Tag>
</Space>
</div>
)) : (
<Tag color="green"></Tag>
)}
</div>
</Card>
<Card
bordered
loading={loading}

View File

@@ -1020,6 +1020,66 @@ button.vp-realtime-command-item:focus-visible {
gap: 10px;
}
.vp-notification-coverage-board {
display: grid;
grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr);
gap: 16px;
}
.vp-notification-coverage-summary {
min-height: 166px;
padding: 12px;
border: 1px solid rgba(22, 100, 255, 0.28);
border-radius: var(--vp-radius);
background: #f5f9ff;
display: grid;
gap: 10px;
align-content: start;
}
.vp-notification-coverage-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.vp-notification-coverage-item {
min-height: 166px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 10px;
align-content: start;
}
.vp-notification-coverage-item strong {
color: var(--vp-text);
font-size: 20px;
line-height: 26px;
font-weight: 700;
word-break: break-word;
}
.vp-notification-owner-list {
margin-top: 12px;
display: grid;
gap: 10px;
}
.vp-notification-owner-item {
min-height: 72px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fff;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.vp-alert-policy-detail {
margin-top: 8px;
color: var(--vp-text-muted);
@@ -2159,6 +2219,9 @@ button.vp-realtime-command-item:focus-visible {
.vp-alert-dispatch-grid,
.vp-alert-command-board,
.vp-alert-command-grid,
.vp-notification-coverage-board,
.vp-notification-coverage-grid,
.vp-notification-owner-item,
.vp-sla-summary,
.vp-risk-grid,
.vp-ops-action-grid,

View File

@@ -3595,6 +3595,13 @@ test('renders notification rules as a standalone operations page', async () => {
expect(screen.getByText('确认平台转发')).toBeInTheDocument();
expect(screen.getByText('通知规则页待通知告警')).toBeInTheDocument();
expect(screen.getByText('platform-rules-test')).toBeInTheDocument();
expect(screen.getByText('通知覆盖与升级风险')).toBeInTheDocument();
expect(screen.getByText('责任覆盖')).toBeInTheDocument();
expect(screen.getByText('活跃命中')).toBeInTheDocument();
expect(screen.getByText('P0压力')).toBeInTheDocument();
expect(screen.getByText('升级风险')).toBeInTheDocument();
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' }));
@@ -3629,6 +3636,14 @@ test('renders notification rules as a standalone operations page', async () => {
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: '复制通知覆盖报告' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知覆盖与升级风险】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('责任覆盖2 个责任方2 类活跃规则'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警压力:待通知 1 / P0 1 / 证据不完整 0'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级风险:已超时 1 / 即将升级 0'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知渠道:邮件 / 企业微信;每日汇总邮件'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('主策略P0 实时中断 / 接入运维 + 业务责任人 / 邮件 / 企业微信'));
fireEvent.click(screen.getByRole('button', { name: '复制SLA升级报告' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));