feat(platform): prioritize alert handling
This commit is contained in:
@@ -102,6 +102,13 @@ type AlertRuleRow = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PriorityIssueRow = QualityIssueRow & {
|
||||||
|
priority: 'P0' | 'P1';
|
||||||
|
actionLabel: string;
|
||||||
|
sla: string;
|
||||||
|
vehicleLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
function qualityParams(values: Record<string, string>) {
|
function qualityParams(values: Record<string, string>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (values?.keyword) params.set('keyword', values.keyword);
|
if (values?.keyword) params.set('keyword', values.keyword);
|
||||||
@@ -154,6 +161,13 @@ function issueCount(summary: QualitySummary, issueType: string) {
|
|||||||
return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0;
|
return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const issuePriorityWeight: Record<string, number> = {
|
||||||
|
NO_SOURCE: 10,
|
||||||
|
VIN_MISSING: 9,
|
||||||
|
LINK_GAP: 8,
|
||||||
|
FIELD_MISSING: 7
|
||||||
|
};
|
||||||
|
|
||||||
function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] {
|
function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] {
|
||||||
const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable);
|
const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable);
|
||||||
const capacityCount = health?.capacityFindings?.length ?? 0;
|
const capacityCount = health?.capacityFindings?.length ?? 0;
|
||||||
@@ -173,6 +187,43 @@ function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): Alert
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function issueSla(issueType: string) {
|
||||||
|
return alertRuleTemplates.find((item) => item.issueType === issueType)?.sla ?? '当日闭环';
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityIssueLevel(row: QualityIssueRow): 'P0' | 'P1' {
|
||||||
|
if (row.severity === 'error' || row.issueType === 'NO_SOURCE') {
|
||||||
|
return 'P0';
|
||||||
|
}
|
||||||
|
return 'P1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityVehicleLabel(row: QualityIssueRow) {
|
||||||
|
const identity = row.vin?.trim() || row.phone?.trim() || row.sourceEndpoint?.trim() || '-';
|
||||||
|
return [row.plate?.trim(), identity].filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityIssueRows(rows: QualityIssueRow[]): PriorityIssueRow[] {
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const action = qualityActionRecommendation(row);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
priority: priorityIssueLevel(row),
|
||||||
|
actionLabel: action.label,
|
||||||
|
sla: issueSla(row.issueType),
|
||||||
|
vehicleLabel: priorityVehicleLabel(row)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return a.priority === 'P0' ? -1 : 1;
|
||||||
|
const weightDelta = (issuePriorityWeight[b.issueType] ?? 0) - (issuePriorityWeight[a.issueType] ?? 0);
|
||||||
|
if (weightDelta !== 0) return weightDelta;
|
||||||
|
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' {
|
function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' {
|
||||||
if (count <= 0) return 'green';
|
if (count <= 0) return 'green';
|
||||||
if (level === 'P0') return 'red';
|
if (level === 'P0') return 'red';
|
||||||
@@ -221,6 +272,7 @@ export function Quality({
|
|||||||
const rules = alertRuleRows(summary, health);
|
const rules = alertRuleRows(summary, health);
|
||||||
const activeRuleCount = rules.filter((item) => item.count > 0).length;
|
const activeRuleCount = rules.filter((item) => item.count > 0).length;
|
||||||
const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length;
|
const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length;
|
||||||
|
const priorityRows = priorityIssueRows(issues);
|
||||||
|
|
||||||
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
setLoadingIssues(true);
|
setLoadingIssues(true);
|
||||||
@@ -348,6 +400,31 @@ export function Quality({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card bordered title="处置优先队列" style={{ marginTop: 16 }}>
|
||||||
|
<Table<PriorityIssueRow>
|
||||||
|
loading={loadingIssues}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(row?: PriorityIssueRow) => `${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`}
|
||||||
|
dataSource={priorityRows}
|
||||||
|
columns={[
|
||||||
|
{ title: '优先级', width: 90, render: (_: unknown, row: PriorityIssueRow) => <Tag color={row.priority === 'P0' ? 'red' : 'orange'}>{row.priority}</Tag> },
|
||||||
|
{ title: '车辆', width: 210, dataIndex: 'vehicleLabel' },
|
||||||
|
{ title: '问题', width: 140, render: (_: unknown, row: PriorityIssueRow) => qualityIssueLabel(row.issueType) },
|
||||||
|
{ title: '建议动作', width: 150, dataIndex: 'actionLabel' },
|
||||||
|
{ title: 'SLA', width: 130, dataIndex: 'sla' },
|
||||||
|
{ title: '最后时间', width: 170, dataIndex: 'lastSeen' },
|
||||||
|
{ title: '说明', dataIndex: 'detail' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 130,
|
||||||
|
render: (_: unknown, row: PriorityIssueRow) => {
|
||||||
|
const lookup = qualityIssueVehicleLookup(row);
|
||||||
|
return <Button size="small" disabled={!lookup.key} onClick={() => onOpenVehicle(lookup.key, row.protocol)}>进入车辆服务</Button>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
<div className="vp-alert-ops-grid">
|
<div className="vp-alert-ops-grid">
|
||||||
<Card bordered title="告警触发规则">
|
<Card bordered title="告警触发规则">
|
||||||
<Table<AlertRuleRow>
|
<Table<AlertRuleRow>
|
||||||
|
|||||||
@@ -2210,7 +2210,7 @@ test('renders quality issues as vehicle-service governance labels', async () =>
|
|||||||
expect(await screen.findByText('VIN-NO-SOURCE-001')).toBeInTheDocument();
|
expect(await screen.findByText('VIN-NO-SOURCE-001')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('暂无数据来源').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('暂无数据来源').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('确认平台转发')).toBeInTheDocument();
|
expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。')).toBeInTheDocument();
|
expect(screen.getByText('车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2296,6 +2296,123 @@ test('shows alert rule and notification policy workspace on quality page', async
|
|||||||
expect(screen.getByText('站内告警 / 邮件 / 企业微信')).toBeInTheDocument();
|
expect(screen.getByText('站内告警 / 邮件 / 企业微信')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows actionable priority queue on quality page', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/quality');
|
||||||
|
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/summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
issueVehicleCount: 2,
|
||||||
|
issueRecordCount: 2,
|
||||||
|
errorCount: 1,
|
||||||
|
warningCount: 1,
|
||||||
|
protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }, { name: 'JT808', count: 1 }],
|
||||||
|
issueTypes: [{ name: 'NO_SOURCE', count: 1 }, { name: 'VIN_MISSING', count: 1 }]
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/quality/issues')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
vin: 'VIN-P0-001',
|
||||||
|
plate: '粤A优先1',
|
||||||
|
phone: '13307795425',
|
||||||
|
sourceEndpoint: '115.231.168.135:43625',
|
||||||
|
protocol: 'VEHICLE_SERVICE',
|
||||||
|
issueType: 'NO_SOURCE',
|
||||||
|
severity: 'error',
|
||||||
|
lastSeen: '2026-07-03 20:12:10',
|
||||||
|
detail: '车辆无任何来源'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vin: '',
|
||||||
|
plate: '粤A优先2',
|
||||||
|
phone: '13307795426',
|
||||||
|
sourceEndpoint: '115.231.168.135:43626',
|
||||||
|
protocol: 'JT808',
|
||||||
|
issueType: 'VIN_MISSING',
|
||||||
|
severity: 'warning',
|
||||||
|
lastSeen: '2026-07-03 20:10:10',
|
||||||
|
detail: '手机号未映射 VIN'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/vehicle-service/overview')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
vin: 'VIN-P0-001',
|
||||||
|
plate: '粤A优先1',
|
||||||
|
sourceCount: 0,
|
||||||
|
onlineSourceCount: 0,
|
||||||
|
coverageStatus: 'no_data',
|
||||||
|
primaryProtocol: 'VEHICLE_SERVICE',
|
||||||
|
lastSeen: '2026-07-03 20:12:10',
|
||||||
|
historyCount: 0,
|
||||||
|
rawCount: 0,
|
||||||
|
mileageCount: 0,
|
||||||
|
qualityIssueCount: 1
|
||||||
|
},
|
||||||
|
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('处置优先队列')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('P0').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('粤A优先1 / VIN-P0-001')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('30 分钟确认').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('P1').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('粤A优先2 / 13307795426')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: '进入车辆服务' })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/detail?keyword=VIN-P0-001&protocol=VEHICLE_SERVICE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('opens vehicle service from quality issue with issue source evidence', async () => {
|
test('opens vehicle service from quality issue with issue source evidence', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/quality');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
@@ -2387,7 +2504,7 @@ test('opens vehicle service from quality issue with issue source evidence', asyn
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('13307795425')).toBeInTheDocument();
|
expect(await screen.findByText('13307795425')).toBeInTheDocument();
|
||||||
expect(screen.getByText('维护身份绑定')).toBeInTheDocument();
|
expect(screen.getAllByText('维护身份绑定').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。')).toBeInTheDocument();
|
expect(screen.getByText('数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '按车牌查车' }));
|
fireEvent.click(screen.getByRole('button', { name: '按车牌查车' }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user