feat(platform): add notification coverage board
This commit is contained in:
@@ -69,6 +69,14 @@ type NotificationExecutionRow = {
|
|||||||
evidenceColor: 'green' | 'orange';
|
evidenceColor: 'green' | 'orange';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationCoverageOwnerRow = {
|
||||||
|
owner: string;
|
||||||
|
activeRules: number;
|
||||||
|
p0Rules: number;
|
||||||
|
hitCount: number;
|
||||||
|
primaryPolicy?: QualityNotificationPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
|
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
|
||||||
{ title: '类型', value: (row) => row.category },
|
{ title: '类型', value: (row) => row.category },
|
||||||
{ title: '名称', value: (row) => row.name },
|
{ title: '名称', value: (row) => row.name },
|
||||||
@@ -236,6 +244,81 @@ function notificationExecutionMatrixReport(rows: NotificationExecutionRow[], rel
|
|||||||
].join('\n');
|
].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) {
|
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
|
||||||
const rules = plan?.rules ?? [];
|
const rules = plan?.rules ?? [];
|
||||||
const policies = plan?.policies ?? [];
|
const policies = plan?.policies ?? [];
|
||||||
@@ -465,6 +548,9 @@ export function NotificationRules() {
|
|||||||
const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length;
|
const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length;
|
||||||
const nextSlaRow = slaRows[0];
|
const nextSlaRow = slaRows[0];
|
||||||
const executionRows = notificationExecutionRows(priorityIssues, rules, policies);
|
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 exportNotificationRules = () => {
|
||||||
const rows = notificationExportRows(plan, release);
|
const rows = notificationExportRows(plan, release);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -501,6 +587,65 @@ export function NotificationRules() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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
|
<Card
|
||||||
bordered
|
bordered
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -1020,6 +1020,66 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
gap: 10px;
|
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 {
|
.vp-alert-policy-detail {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: var(--vp-text-muted);
|
color: var(--vp-text-muted);
|
||||||
@@ -2159,6 +2219,9 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-alert-dispatch-grid,
|
.vp-alert-dispatch-grid,
|
||||||
.vp-alert-command-board,
|
.vp-alert-command-board,
|
||||||
.vp-alert-command-grid,
|
.vp-alert-command-grid,
|
||||||
|
.vp-notification-coverage-board,
|
||||||
|
.vp-notification-coverage-grid,
|
||||||
|
.vp-notification-owner-item,
|
||||||
.vp-sla-summary,
|
.vp-sla-summary,
|
||||||
.vp-risk-grid,
|
.vp-risk-grid,
|
||||||
.vp-ops-action-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('通知规则页待通知告警')).toBeInTheDocument();
|
expect(screen.getByText('通知规则页待通知告警')).toBeInTheDocument();
|
||||||
expect(screen.getByText('platform-rules-test')).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);
|
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' }));
|
||||||
@@ -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('执行态势:P0 1 / 已超时 1 / 证据不完整 0'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知:接入运维 + 业务责任人 / 邮件 / 企业微信'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知:接入运维 + 业务责任人 / 邮件 / 企业微信'));
|
||||||
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升级报告' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制SLA升级报告' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));
|
||||||
|
|||||||
Reference in New Issue
Block a user