feat(platform): export notification rules
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types';
|
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||||
import { qualityIssueLabel } from '../domain/qualityIssue';
|
import { qualityIssueLabel } from '../domain/qualityIssue';
|
||||||
|
|
||||||
function formatEscalationMinutes(minutes?: number) {
|
function formatEscalationMinutes(minutes?: number) {
|
||||||
@@ -26,6 +27,32 @@ function ruleTitle(rule: QualityAlertRule) {
|
|||||||
return rule.title || qualityIssueLabel(rule.issueType);
|
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) {
|
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
|
||||||
const rules = plan?.rules ?? [];
|
const rules = plan?.rules ?? [];
|
||||||
const policies = plan?.policies ?? [];
|
const policies = plan?.policies ?? [];
|
||||||
@@ -129,6 +156,35 @@ function evidenceColor(issue: QualityPriorityIssue): 'green' | 'orange' {
|
|||||||
return countEvidenceLinks(issue) >= 4 ? '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) {
|
function navigateHash(hash: string) {
|
||||||
if (!hash) return;
|
if (!hash) return;
|
||||||
window.location.hash = hash;
|
window.location.hash = hash;
|
||||||
@@ -176,6 +232,15 @@ export function NotificationRules() {
|
|||||||
const evidenceCompleteCount = priorityIssues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
|
const evidenceCompleteCount = priorityIssues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
|
||||||
const primaryOwner = rules.find((rule) => rule.count > 0)?.owner || policies[0]?.target || '-';
|
const primaryOwner = rules.find((rule) => rule.count > 0)?.owner || policies[0]?.target || '-';
|
||||||
const nextPriorityIssue = priorityIssues[0];
|
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 (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
@@ -250,7 +315,7 @@ export function NotificationRules() {
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
bordered
|
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">
|
<div className="vp-alert-flow">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -3360,6 +3360,8 @@ test('renders notification rules as a standalone operations page', async () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: { writeText }
|
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 fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
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' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
|
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: '复制待通知汇总' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制待通知汇总' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【当前待通知告警】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【当前待通知告警】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A规则1 / VIN-RULES-001'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A规则1 / VIN-RULES-001'));
|
||||||
|
|||||||
Reference in New Issue
Block a user