feat(platform): add quality escalation clock
This commit is contained in:
@@ -355,6 +355,53 @@ function priorityIssueDigestText(rows: PriorityIssueRow[], summary: QualitySumma
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slaMinutes(row: PriorityIssueRow) {
|
||||||
|
if (row.issueType === 'NO_SOURCE') return 30;
|
||||||
|
if (row.issueType === 'VIN_MISSING') return 120;
|
||||||
|
if (row.issueType === 'LINK_GAP') return 60;
|
||||||
|
if (row.issueType === 'FIELD_MISSING') return 24 * 60;
|
||||||
|
if (row.sla.includes('15 分钟')) return 15;
|
||||||
|
if (row.sla.includes('30 分钟')) return 30;
|
||||||
|
if (row.sla.includes('1 小时')) return 60;
|
||||||
|
if (row.sla.includes('2 小时')) return 120;
|
||||||
|
return 24 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(minutes: number) {
|
||||||
|
const value = Math.max(0, Math.round(minutes));
|
||||||
|
if (value < 60) return `${value} 分钟`;
|
||||||
|
const hours = Math.floor(value / 60);
|
||||||
|
const rest = value % 60;
|
||||||
|
return rest > 0 ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIssueTime(value?: string) {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
if (!raw) return Number.NaN;
|
||||||
|
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
||||||
|
const parsed = Date.parse(normalized);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
return Date.parse(`${raw.replace(' ', 'T')}+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escalationClock(row: PriorityIssueRow, nowMs = Date.now()) {
|
||||||
|
const lastSeenMs = parseIssueTime(row.lastSeen);
|
||||||
|
if (!Number.isFinite(lastSeenMs)) {
|
||||||
|
return { status: 'SLA 待确认', color: 'grey' as const, detail: '缺少告警时间', deadline: '-' };
|
||||||
|
}
|
||||||
|
const minutes = slaMinutes(row);
|
||||||
|
const deadlineMs = lastSeenMs + minutes * 60 * 1000;
|
||||||
|
const diffMinutes = (deadlineMs - nowMs) / 60000;
|
||||||
|
const deadline = new Date(deadlineMs).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
if (diffMinutes < 0) {
|
||||||
|
return { status: '超 SLA', color: 'red' as const, detail: `已超 ${formatDuration(Math.abs(diffMinutes))}`, deadline };
|
||||||
|
}
|
||||||
|
if (diffMinutes <= Math.max(15, minutes * 0.2)) {
|
||||||
|
return { status: '即将升级', color: 'orange' as const, detail: `剩余 ${formatDuration(diffMinutes)}`, deadline };
|
||||||
|
}
|
||||||
|
return { status: 'SLA 正常', color: 'green' as const, detail: `剩余 ${formatDuration(diffMinutes)}`, deadline };
|
||||||
|
}
|
||||||
|
|
||||||
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';
|
||||||
@@ -662,6 +709,39 @@ export function Quality({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card bordered title="告警升级时钟" style={{ marginTop: 16 }}>
|
||||||
|
<Table<PriorityIssueRow>
|
||||||
|
loading={loadingIssues}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(row?: PriorityIssueRow) => `clock-${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`}
|
||||||
|
dataSource={priorityRows}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '升级状态',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: PriorityIssueRow) => {
|
||||||
|
const clock = escalationClock(row);
|
||||||
|
return <Tag color={clock.color}>{clock.status}</Tag>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ title: '车辆', width: 210, dataIndex: 'vehicleLabel' },
|
||||||
|
{ title: '优先级', width: 90, render: (_: unknown, row: PriorityIssueRow) => <Tag color={row.priority === 'P0' ? 'red' : 'orange'}>{row.priority}</Tag> },
|
||||||
|
{ title: '问题', width: 140, render: (_: unknown, row: PriorityIssueRow) => qualityIssueLabel(row.issueType) },
|
||||||
|
{ title: 'SLA', width: 130, dataIndex: 'sla' },
|
||||||
|
{
|
||||||
|
title: '剩余/超时',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, row: PriorityIssueRow) => escalationClock(row).detail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '升级截止',
|
||||||
|
width: 210,
|
||||||
|
render: (_: unknown, row: PriorityIssueRow) => escalationClock(row).deadline
|
||||||
|
},
|
||||||
|
{ title: '建议动作', dataIndex: 'actionLabel' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
<div className="vp-alert-ops-grid">
|
<div className="vp-alert-ops-grid">
|
||||||
<Card bordered title="告警触发规则">
|
<Card bordered title="告警触发规则">
|
||||||
<Table<AlertRuleRow>
|
<Table<AlertRuleRow>
|
||||||
|
|||||||
@@ -2705,11 +2705,11 @@ test('shows actionable priority queue on quality page', async () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('处置优先队列')).toBeInTheDocument();
|
expect(await screen.findByText('处置优先队列')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('P0').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('P0').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('粤A优先1 / VIN-P0-001')).toBeInTheDocument();
|
expect(screen.getAllByText('粤A优先1 / VIN-P0-001').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('30 分钟确认').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('30 分钟确认').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('P1').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('P1').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('粤A优先2 / 13307795426')).toBeInTheDocument();
|
expect(screen.getAllByText('粤A优先2 / 13307795426').length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '进入车辆服务' })[0]);
|
fireEvent.click(screen.getAllByRole('button', { name: '进入车辆服务' })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -2800,8 +2800,8 @@ test('uses backend quality notification plan on quality page', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('后端车辆标签 / VIN-PLAN-001')).toBeInTheDocument();
|
expect((await screen.findAllByText('后端车辆标签 / VIN-PLAN-001')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('后端建议动作')).toBeInTheDocument();
|
expect(screen.getAllByText('后端建议动作').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('后端无来源规则')).toBeInTheDocument();
|
expect(screen.getByText('后端无来源规则')).toBeInTheDocument();
|
||||||
expect(screen.getByText('后端 P0 通知策略')).toBeInTheDocument();
|
expect(screen.getByText('后端 P0 通知策略')).toBeInTheDocument();
|
||||||
@@ -3638,6 +3638,93 @@ test('opens same-day history evidence from quality issue row', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('quality page surfaces escalation clock for priority issues', 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, redisOnlineKeys: 0, 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: 'LINK_GAP', count: 1 }]
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/quality/issues')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
vin: 'VIN-QUALITY-OVERDUE',
|
||||||
|
plate: '粤A超时',
|
||||||
|
phone: '13307790001',
|
||||||
|
sourceEndpoint: 'vehicle_identity_binding',
|
||||||
|
protocol: 'VEHICLE_SERVICE',
|
||||||
|
issueType: 'NO_SOURCE',
|
||||||
|
severity: 'error',
|
||||||
|
lastSeen: '2026-07-03 08:00:00',
|
||||||
|
detail: '绑定车辆无来源'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vin: 'VIN-QUALITY-RECENT',
|
||||||
|
plate: '粤A正常',
|
||||||
|
phone: '13307790002',
|
||||||
|
sourceEndpoint: '115.231.168.135:43625',
|
||||||
|
protocol: 'JT808',
|
||||||
|
issueType: 'LINK_GAP',
|
||||||
|
severity: 'warning',
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
detail: '最近链路间断'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
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('告警升级时钟')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('超 SLA')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SLA 正常')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('VIN-QUALITY-OVERDUE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('opens same-day raw evidence from quality issue row', async () => {
|
test('opens same-day raw evidence from quality issue row', 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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user