feat(platform): add notification coverage board
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user