feat(platform): add quality notification plan

This commit is contained in:
lingniu
2026-07-04 13:31:36 +08:00
parent 817590c104
commit ff833bc065
9 changed files with 586 additions and 9 deletions

View File

@@ -172,6 +172,30 @@ test('vehicleServiceSummary reads the vehicle service summary endpoint', async (
expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461);
});
test('qualityNotificationPlan reads alert rules and priority issues from backend', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
summary: { issueVehicleCount: 1, issueRecordCount: 1, errorCount: 1, warningCount: 0, protocols: [], issueTypes: [] },
rules: [{ issueType: 'NO_SOURCE', title: '无数据来源', level: 'P0', owner: '平台接入', trigger: '无来源', notify: '立即通知', sla: '30 分钟确认', count: 1 }],
policies: [{ name: 'P0 实时中断', target: '接入运维', channel: '邮件', condition: '无来源' }],
priorityIssues: [{ vin: 'VIN001', plate: '粤A001', phone: '', sourceEndpoint: '', protocol: 'VEHICLE_SERVICE', issueType: 'NO_SOURCE', severity: 'error', lastSeen: '', detail: '无来源', priority: 'P0', actionLabel: '确认平台转发', actionDetail: '确认平台转发', sla: '30 分钟确认', vehicleLabel: '粤A001 / VIN001', realtimeHash: '#/realtime?keyword=VIN001', historyHash: '#/history?keyword=VIN001', rawHash: '#/history?keyword=VIN001&tab=raw', vehicleHash: '#/detail?keyword=VIN001', notificationText: '【P0 告警通知】无数据来源' }],
activeRuleCount: 1,
p0RuleCount: 1
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response);
const result = await api.qualityNotificationPlan(new URLSearchParams({ issueType: 'NO_SOURCE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', undefined);
expect(result.rules[0].level).toBe('P0');
expect(result.priorityIssues[0].notificationText).toContain('告警通知');
});
test('api errors include backend message, detail, and trace id', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,

View File

@@ -5,6 +5,7 @@ import type {
HistoryLocationRow,
MileageSummary,
OpsHealth,
QualityNotificationPlan,
Page,
QualitySummary,
QualityIssueRow,
@@ -106,5 +107,6 @@ export const api = {
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
opsHealth: () => request<OpsHealth>('/api/ops/health')
};

View File

@@ -281,6 +281,46 @@ export interface QualityBucketStat {
count: number;
}
export interface QualityNotificationPlan {
summary: QualitySummary;
rules: QualityAlertRule[];
policies: QualityNotificationPolicy[];
priorityIssues: QualityPriorityIssue[];
activeRuleCount: number;
p0RuleCount: number;
}
export interface QualityAlertRule {
issueType: string;
title: string;
level: string;
owner: string;
trigger: string;
notify: string;
sla: string;
count: number;
}
export interface QualityNotificationPolicy {
name: string;
target: string;
channel: string;
condition: string;
}
export interface QualityPriorityIssue extends QualityIssueRow {
priority: 'P0' | 'P1';
actionLabel: string;
actionDetail: string;
sla: string;
vehicleLabel: string;
realtimeHash: string;
historyHash: string;
rawHash: string;
vehicleHash: string;
notificationText: string;
}
export interface OpsHealth {
linkHealth: LinkHealth[];
kafkaLag: number | null;

View File

@@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '
import { IconCopy } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualitySummary, QualityIssueRow, QualityPriorityIssue } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { buildAppHash } from '../domain/appRoute';
import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue';
@@ -95,6 +95,7 @@ const notificationPolicies = [
type AlertRuleRow = {
issueType: string;
title?: string;
level: string;
owner: string;
trigger: string;
@@ -106,8 +107,14 @@ type AlertRuleRow = {
type PriorityIssueRow = QualityIssueRow & {
priority: 'P0' | 'P1';
actionLabel: string;
actionDetail?: string;
sla: string;
vehicleLabel: string;
realtimeHash?: string;
historyHash?: string;
rawHash?: string;
vehicleHash?: string;
notificationText?: string;
};
function qualityParams(values: Record<string, string>) {
@@ -293,6 +300,21 @@ function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 're
return 'grey';
}
function normalizeAlertRules(rows?: QualityAlertRule[]): AlertRuleRow[] | null {
if (!Array.isArray(rows)) return null;
return rows;
}
function normalizeNotificationPolicies(rows?: QualityNotificationPolicy[]): QualityNotificationPolicy[] | null {
if (!Array.isArray(rows)) return null;
return rows;
}
function normalizePriorityIssues(rows?: QualityPriorityIssue[]): PriorityIssueRow[] | null {
if (!Array.isArray(rows)) return null;
return rows;
}
async function copyText(value: string, label: string) {
const text = value.trim();
if (!text) {
@@ -333,16 +355,18 @@ export function Quality({
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
const [health, setHealth] = useState<OpsHealth | null>(null);
const [notificationPlan, setNotificationPlan] = useState<QualityNotificationPlan | null>(null);
const [loadingIssues, setLoadingIssues] = useState(true);
const [loadingSummary, setLoadingSummary] = useState(true);
const [loadingHealth, setLoadingHealth] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const primaryIssueType = summary.issueTypes[0]?.name;
const rules = alertRuleRows(summary, health);
const activeRuleCount = rules.filter((item) => item.count > 0).length;
const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length;
const priorityRows = priorityIssueRows(issues);
const rules = normalizeAlertRules(notificationPlan?.rules) ?? alertRuleRows(summary, health);
const policies = normalizeNotificationPolicies(notificationPlan?.policies) ?? notificationPolicies;
const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length;
const p0RuleCount = notificationPlan?.p0RuleCount ?? rules.filter((item) => item.count > 0 && item.level === 'P0').length;
const priorityRows = normalizePriorityIssues(notificationPlan?.priorityIssues) ?? priorityIssueRows(issues);
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoadingIssues(true);
@@ -376,12 +400,26 @@ export function Quality({
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingHealth(false));
};
const loadNotificationPlan = (values: Record<string, string> = filters, pageSize = pagination.pageSize) => {
const params = qualityParams(values);
params.set('limit', String(pageSize));
api.qualityNotificationPlan(params)
.then((plan) => {
if (Array.isArray(plan.rules) && Array.isArray(plan.policies) && Array.isArray(plan.priorityIssues)) {
setNotificationPlan(plan);
}
})
.catch(() => {
setNotificationPlan(null);
});
};
useEffect(() => {
setFilters(initialFilters);
loadSummary(initialFilters);
loadIssues(initialFilters, 1, pagination.pageSize);
loadHealth();
loadNotificationPlan(initialFilters, pagination.pageSize);
}, [JSON.stringify(initialFilters)]);
const applyFilters = (nextFilters: Record<string, string>) => {
@@ -389,6 +427,7 @@ export function Quality({
onFiltersChange?.(nextFilters);
loadSummary(nextFilters);
loadIssues(nextFilters, 1, pagination.pageSize);
loadNotificationPlan(nextFilters, pagination.pageSize);
};
const drillPrimaryIssue = () => {
@@ -454,7 +493,7 @@ export function Quality({
Toast.warning('当前没有可复制的优先告警');
return;
}
copyText(priorityIssueDigestText(priorityRows, summary), '优先队列通知汇总');
copyText(priorityIssueDigestText(priorityRows, notificationPlan?.summary ?? summary), '优先队列通知汇总');
};
return (
@@ -551,7 +590,7 @@ export function Quality({
<Button size="small" disabled={!lookup.key || !onOpenRealtime} onClick={() => openIssueRealtime(row)}></Button>
<Button size="small" disabled={!lookup.key || !onOpenHistory} onClick={() => openIssueHistory(row)}></Button>
<Button size="small" disabled={!lookup.key || !onOpenRaw} onClick={() => openIssueRaw(row)}>RAW证据</Button>
<Button size="small" onClick={() => copyText(priorityIssueNotificationText(row), '告警通知')}></Button>
<Button size="small" onClick={() => copyText(row.notificationText || priorityIssueNotificationText(row), '告警通知')}></Button>
<Button size="small" disabled={!lookup.key} onClick={() => onOpenVehicle(lookup.key, row.protocol)}></Button>
</Space>
);
@@ -567,7 +606,7 @@ export function Quality({
dataSource={rules}
rowKey="issueType"
columns={[
{ title: '规则', render: (_: unknown, row: AlertRuleRow) => row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType) },
{ title: '规则', render: (_: unknown, row: AlertRuleRow) => row.title || (row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType)) },
{ title: '级别', width: 90, render: (_: unknown, row: AlertRuleRow) => <Tag color={ruleStatusColor(row.count, row.level)}>{row.level}</Tag> },
{ title: '当前命中', width: 110, render: (_: unknown, row: AlertRuleRow) => row.count.toLocaleString() },
{ title: '触发条件', dataIndex: 'trigger' },
@@ -577,7 +616,7 @@ export function Quality({
</Card>
<Card bordered title="通知策略">
<div className="vp-notification-policy-list">
{notificationPolicies.map((item) => (
{policies.map((item) => (
<div key={item.name} className="vp-notification-policy">
<div>
<Space>

View File

@@ -2543,6 +2543,97 @@ test('shows actionable priority queue on quality page', async () => {
});
});
test('uses backend quality notification plan on quality page', async () => {
window.history.replaceState(null, '', '/#/quality');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/ops/health')) {
return {
ok: true,
json: async () => ({
data: { linkHealth: [], kafkaLag: 0, activeConnections: 120000, capacityFindings: [], redisOnlineKeys: 368, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/quality/notification-plan')) {
return {
ok: true,
json: async () => ({
data: {
summary: { issueVehicleCount: 9, issueRecordCount: 11, errorCount: 2, warningCount: 9, protocols: [], issueTypes: [{ name: 'NO_SOURCE', count: 4 }] },
rules: [{ issueType: 'NO_SOURCE', title: '后端无来源规则', level: 'P0', owner: '平台接入', trigger: '后端统一触发', notify: '后端统一通知', sla: '10 分钟确认', count: 4 }],
policies: [{ name: '后端 P0 通知策略', target: '接入运维', channel: '邮件 / 企业微信', condition: '后端统一条件' }],
priorityIssues: [{
vin: 'VIN-PLAN-001',
plate: '粤A计划1',
phone: '',
sourceEndpoint: 'vehicle_identity_binding',
protocol: 'VEHICLE_SERVICE',
issueType: 'NO_SOURCE',
severity: 'error',
lastSeen: '2026-07-03 20:12:10',
detail: '后端计划生成的优先告警',
priority: 'P0',
actionLabel: '后端建议动作',
actionDetail: '后端建议说明',
sla: '10 分钟确认',
vehicleLabel: '后端车辆标签 / VIN-PLAN-001',
realtimeHash: '#/realtime?keyword=VIN-PLAN-001',
historyHash: '#/history?keyword=VIN-PLAN-001',
rawHash: '#/history?keyword=VIN-PLAN-001&tab=raw',
vehicleHash: '#/detail?keyword=VIN-PLAN-001',
notificationText: '【P0 告警通知】后端计划'
}],
activeRuleCount: 1,
p0RuleCount: 1
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/quality/summary')) {
return {
ok: true,
json: async () => ({
data: { issueVehicleCount: 1, issueRecordCount: 1, errorCount: 1, warningCount: 0, protocols: [], issueTypes: [] },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/quality/issues')) {
return {
ok: true,
json: async () => ({
data: { items: [], total: 0, limit: 20, offset: 0 },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
return {
ok: true,
json: async () => ({
data: { items: [], total: 0, limit: 20, offset: 0 },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
});
render(<App />);
expect(await screen.findByText('后端车辆标签 / VIN-PLAN-001')).toBeInTheDocument();
expect(screen.getByText('后端建议动作')).toBeInTheDocument();
expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0);
expect(screen.getByText('后端无来源规则')).toBeInTheDocument();
expect(screen.getByText('后端 P0 通知策略')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=20'), undefined);
});
test('copies notification text from quality priority queue', async () => {
window.history.replaceState(null, '', '/#/quality');
const writeText = vi.fn(() => Promise.resolve());