feat(platform): export notification rules

This commit is contained in:
lingniu
2026-07-04 20:26:09 +08:00
parent 284d2e1741
commit fc57dea551
2 changed files with 71 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { qualityIssueLabel } from '../domain/qualityIssue';
function formatEscalationMinutes(minutes?: number) {
@@ -26,6 +27,32 @@ function ruleTitle(rule: QualityAlertRule) {
return rule.title || qualityIssueLabel(rule.issueType);
}
type NotificationExportRow = {
category: string;
name: string;
level: string;
ownerOrTarget: string;
triggerOrCondition: string;
notifyOrChannel: string;
slaOrEscalation: string;
acceptance: string;
count: number | string;
release: string;
};
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
{ title: '类型', value: (row) => row.category },
{ title: '名称', value: (row) => row.name },
{ title: '级别', value: (row) => row.level },
{ title: '责任方', value: (row) => row.ownerOrTarget },
{ title: '触发条件', value: (row) => row.triggerOrCondition },
{ title: '通知方式', value: (row) => row.notifyOrChannel },
{ title: 'SLA/升级', value: (row) => row.slaOrEscalation },
{ title: '验收口径', value: (row) => row.acceptance },
{ title: '当前命中', value: (row) => row.count },
{ title: '运行版本', value: (row) => row.release }
];
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
@@ -129,6 +156,35 @@ function evidenceColor(issue: QualityPriorityIssue): 'green' | 'orange' {
return countEvidenceLinks(issue) >= 4 ? 'green' : 'orange';
}
function notificationExportRows(plan: QualityNotificationPlan | null, release = ''): NotificationExportRow[] {
const version = release.trim() || '-';
const ruleRows = (plan?.rules ?? []).map((rule) => ({
category: '触发规则',
name: ruleTitle(rule),
level: rule.level,
ownerOrTarget: rule.owner,
triggerOrCondition: rule.trigger,
notifyOrChannel: rule.notify,
slaOrEscalation: rule.sla,
acceptance: '-',
count: Number(rule.count ?? 0),
release: version
}));
const policyRows = (plan?.policies ?? []).map((policy) => ({
category: '通知策略',
name: policy.name,
level: policy.name.startsWith('P0') ? 'P0' : policy.name.startsWith('P1') ? 'P1' : '-',
ownerOrTarget: policy.target,
triggerOrCondition: policy.condition,
notifyOrChannel: policy.channel,
slaOrEscalation: formatEscalationMinutes(policy.escalationMinutes),
acceptance: policy.acceptanceCriteria || '-',
count: '-',
release: version
}));
return [...ruleRows, ...policyRows];
}
function navigateHash(hash: string) {
if (!hash) return;
window.location.hash = hash;
@@ -176,6 +232,15 @@ export function NotificationRules() {
const evidenceCompleteCount = priorityIssues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
const primaryOwner = rules.find((rule) => rule.count > 0)?.owner || policies[0]?.target || '-';
const nextPriorityIssue = priorityIssues[0];
const exportNotificationRules = () => {
const rows = notificationExportRows(plan, release);
if (rows.length === 0) {
Toast.warning('当前没有可导出的通知规则');
return;
}
downloadCsv(`notification-rules-${release || 'current'}.csv`, buildCsv(notificationExportColumns, rows));
Toast.success(`已导出 ${rows.length.toLocaleString()} 条通知规则`);
};
return (
<div className="vp-page">
@@ -250,7 +315,7 @@ export function NotificationRules() {
<Card
bordered
title={<Space><span></span><Button size="small" aria-label="复制通知规则Runbook" icon={<IconCopy />} onClick={() => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')}>Runbook</Button><Button size="small" loading={loading} onClick={load}></Button></Space>}
title={<Space><span></span><Button size="small" aria-label="复制通知规则Runbook" icon={<IconCopy />} onClick={() => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')}>Runbook</Button><Button size="small" aria-label="导出通知规则CSV" onClick={exportNotificationRules}> CSV</Button><Button size="small" loading={loading} onClick={load}></Button></Space>}
>
<div className="vp-alert-flow">
{[

View File

@@ -3360,6 +3360,8 @@ test('renders notification rules as a standalone operations page', async () => {
configurable: true,
value: { writeText }
});
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:notification-rules');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/ops/health')) {
@@ -3456,6 +3458,9 @@ test('renders notification rules as a standalone operations page', async () => {
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
fireEvent.click(screen.getByRole('button', { name: '导出通知规则CSV' }));
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:notification-rules');
fireEvent.click(screen.getByRole('button', { name: '复制待通知汇总' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【当前待通知告警】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A规则1 / VIN-RULES-001'));