feat(platform): add alert sla escalation board

This commit is contained in:
lingniu
2026-07-04 21:34:39 +08:00
parent da3a639dd8
commit 5d57efabea
3 changed files with 203 additions and 5 deletions

View File

@@ -16,6 +16,15 @@ function formatEscalationMinutes(minutes?: number) {
return rest > 0 ? `${hours} 小时 ${rest} 分钟升级` : `${hours} 小时升级`;
}
function formatDurationFromMinutes(minutes?: number | null) {
if (minutes == null || !Number.isFinite(minutes)) return '-';
const value = Math.max(0, Math.round(minutes));
if (value < 60) return `${value.toLocaleString()} 分钟`;
const hours = Math.floor(value / 60);
const rest = value % 60;
return rest > 0 ? `${hours.toLocaleString()} 小时 ${rest} 分钟` : `${hours.toLocaleString()} 小时`;
}
function ruleColor(rule: QualityAlertRule): 'green' | 'orange' | 'red' | 'grey' {
if ((rule.count ?? 0) <= 0) return 'green';
if (rule.level === 'P0') return 'red';
@@ -40,6 +49,16 @@ type NotificationExportRow = {
release: string;
};
type SlaEscalationRow = {
issue: QualityPriorityIssue;
policy?: QualityNotificationPolicy;
elapsedMinutes?: number;
remainingMinutes?: number;
status: 'overdue' | 'due_soon' | 'tracking' | 'unknown';
statusLabel: string;
statusColor: 'red' | 'orange' | 'green' | 'grey';
};
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
{ title: '类型', value: (row) => row.category },
{ title: '名称', value: (row) => row.name },
@@ -53,6 +72,97 @@ const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
{ title: '运行版本', value: (row) => row.release }
];
function parseIssueTime(value?: string) {
const normalized = String(value ?? '').trim().replace(' ', 'T');
if (!normalized) return undefined;
const ms = Date.parse(normalized);
return Number.isFinite(ms) ? ms : undefined;
}
function policyForPriority(issue: QualityPriorityIssue, policies: QualityNotificationPolicy[]) {
const prefix = issue.priority === 'P0' ? 'P0' : 'P1';
return policies.find((policy) => policy.name.startsWith(prefix)) ?? policies[0];
}
function slaEscalationRows(issues: QualityPriorityIssue[], policies: QualityNotificationPolicy[], nowMs = Date.now()): SlaEscalationRow[] {
return issues.map<SlaEscalationRow>((issue) => {
const policy = policyForPriority(issue, policies);
const lastSeenMs = parseIssueTime(issue.lastSeen);
if (lastSeenMs == null || !policy?.escalationMinutes) {
return {
issue,
policy,
status: 'unknown',
statusLabel: '待确认时间',
statusColor: 'grey'
};
}
const elapsedMinutes = Math.max(0, (nowMs - lastSeenMs) / 60000);
const remainingMinutes = policy.escalationMinutes - elapsedMinutes;
if (remainingMinutes <= 0) {
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'overdue',
statusLabel: `已超时 ${formatDurationFromMinutes(Math.abs(remainingMinutes))}`,
statusColor: 'red'
};
}
if (remainingMinutes <= 15) {
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'due_soon',
statusLabel: `${formatDurationFromMinutes(remainingMinutes)} 后升级`,
statusColor: 'orange'
};
}
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'tracking',
statusLabel: `${formatDurationFromMinutes(remainingMinutes)} 后升级`,
statusColor: 'green'
};
}).sort((left, right) => {
const statusWeight: Record<SlaEscalationRow['status'], number> = { overdue: 3, due_soon: 2, tracking: 1, unknown: 0 };
return statusWeight[right.status] - statusWeight[left.status]
|| (left.remainingMinutes ?? Number.POSITIVE_INFINITY) - (right.remainingMinutes ?? Number.POSITIVE_INFINITY)
|| left.issue.vehicleLabel.localeCompare(right.issue.vehicleLabel);
});
}
function slaEscalationReport(rows: SlaEscalationRow[], release?: string) {
const overdue = rows.filter((row) => row.status === 'overdue').length;
const dueSoon = rows.filter((row) => row.status === 'due_soon').length;
const tracking = rows.filter((row) => row.status === 'tracking').length;
const next = rows[0];
return [
'【告警SLA升级报告】',
`运行版本:${release?.trim() || '-'}`,
`升级态势:已超时 ${overdue.toLocaleString()} / 即将升级 ${dueSoon.toLocaleString()} / 正常跟进 ${tracking.toLocaleString()}`,
`下一优先:${next ? `[${next.issue.priority}] ${next.issue.vehicleLabel} / ${next.statusLabel}` : '暂无待通知告警'}`,
'',
'升级队列:',
...(rows.length > 0 ? rows.slice(0, 10).map((row, index) => [
`${index + 1}. [${row.issue.priority}] ${row.issue.vehicleLabel}`,
` 状态:${row.statusLabel}`,
` 策略:${row.policy ? `${row.policy.name} / ${row.policy.target} / ${formatEscalationMinutes(row.policy.escalationMinutes)}` : '-'}`,
` 已等待:${formatDurationFromMinutes(row.elapsedMinutes)}`,
` 问题:${qualityIssueLabel(row.issue.issueType)} / ${row.issue.protocol}`,
` 建议动作:${row.issue.actionLabel}`,
` 车辆服务:${window.location.origin}${window.location.pathname}${row.issue.vehicleHash}`,
` RAW证据${window.location.origin}${window.location.pathname}${row.issue.rawHash}`
].join('\n')) : ['暂无待升级告警'])
].join('\n');
}
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
@@ -277,6 +387,10 @@ 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 slaRows = slaEscalationRows(priorityIssues, policies);
const overdueCount = slaRows.filter((row) => row.status === 'overdue').length;
const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length;
const nextSlaRow = slaRows[0];
const exportNotificationRules = () => {
const rows = notificationExportRows(plan, release);
if (rows.length === 0) {
@@ -358,6 +472,52 @@ export function NotificationRules() {
</div>
</Card>
<Card
bordered
loading={loading}
title={<Space><span>SLA升级态势</span><Button size="small" aria-label="复制SLA升级报告" icon={<IconCopy />} disabled={slaRows.length === 0} onClick={() => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')}>SLA升级报告</Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-sla-board">
<div className="vp-sla-summary">
{[
{ label: '已超时', value: overdueCount.toLocaleString(), color: overdueCount > 0 ? 'red' as const : 'green' as const },
{ label: '即将升级', value: dueSoonCount.toLocaleString(), color: dueSoonCount > 0 ? 'orange' as const : 'green' as const },
{ label: '待通知', value: slaRows.length.toLocaleString(), color: slaRows.length > 0 ? 'blue' as const : 'green' as const },
{ label: '下一优先', value: nextSlaRow?.statusLabel || '-', color: nextSlaRow?.statusColor ?? 'grey' as const }
].map((item) => (
<div key={item.label} className="vp-sla-summary-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
</div>
))}
</div>
<Table<SlaEscalationRow>
pagination={false}
dataSource={slaRows.slice(0, 8)}
rowKey={(row?: SlaEscalationRow) => `${row?.issue.priority ?? ''}-${row?.issue.vehicleLabel ?? ''}-${row?.issue.lastSeen ?? ''}`}
columns={[
{ title: '状态', width: 150, render: (_: unknown, row: SlaEscalationRow) => <Tag color={row.statusColor}>{row.statusLabel}</Tag> },
{ title: '车辆', width: 220, render: (_: unknown, row: SlaEscalationRow) => row.issue.vehicleLabel },
{ title: '优先级', width: 90, render: (_: unknown, row: SlaEscalationRow) => <Tag color={row.issue.priority === 'P0' ? 'red' : 'orange'}>{row.issue.priority}</Tag> },
{ title: '策略', width: 210, render: (_: unknown, row: SlaEscalationRow) => row.policy?.name || '-' },
{ title: '已等待', width: 120, render: (_: unknown, row: SlaEscalationRow) => formatDurationFromMinutes(row.elapsedMinutes) },
{ title: '责任方', width: 190, render: (_: unknown, row: SlaEscalationRow) => row.policy?.target || '-' },
{
title: '处置',
render: (_: unknown, row: SlaEscalationRow) => (
<Space wrap>
<Button size="small" disabled={!row.issue.vehicleHash} onClick={() => navigateHash(row.issue.vehicleHash)}></Button>
<Button size="small" disabled={!row.issue.rawHash} onClick={() => navigateHash(row.issue.rawHash)}>RAW证据</Button>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.issue.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</div>
</Card>
<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" aria-label="导出通知规则CSV" onClick={exportNotificationRules}> CSV</Button><Button size="small" loading={loading} onClick={load}></Button></Space>}

View File

@@ -818,6 +818,34 @@ button.vp-realtime-command-item:focus-visible {
gap: 10px;
}
.vp-sla-board {
display: grid;
gap: 14px;
}
.vp-sla-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.vp-sla-summary-item {
min-height: 72px;
display: grid;
gap: 8px;
align-content: start;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
}
.vp-sla-summary-item strong {
color: var(--vp-text);
font-size: 18px;
line-height: 24px;
}
.vp-risk-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1561,6 +1589,7 @@ button.vp-realtime-command-item:focus-visible {
.vp-alert-dispatch-grid,
.vp-alert-command-board,
.vp-alert-command-grid,
.vp-sla-summary,
.vp-risk-grid,
.vp-alert-ops-grid,
.vp-stat-workspace,

View File

@@ -3177,8 +3177,8 @@ test('shows alert rule and notification policy workspace on quality page', async
expect(screen.getByRole('button', { name: '分派团队 车辆档案' })).toHaveTextContent('主问题VIN 缺失');
expect(screen.getByRole('button', { name: '分派团队 基础设施' })).toHaveTextContent('SLA15 分钟恢复');
expect(screen.getByText('通知策略')).toBeInTheDocument();
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument();
expect(screen.getAllByText('P0 实时中断').length).toBeGreaterThan(0);
expect(screen.getAllByText('接入运维 + 业务责任人').length).toBeGreaterThan(0);
expect(screen.getByText('站内告警 / 邮件 / 企业微信')).toBeInTheDocument();
expect(screen.getByText('30 分钟升级')).toBeInTheDocument();
expect(screen.getByText('验收:来源恢复并持续 10 分钟,车辆服务可查到实时与历史证据')).toBeInTheDocument();
@@ -3489,13 +3489,17 @@ test('renders notification rules as a standalone operations page', async () => {
expect(await screen.findByRole('heading', { name: '通知规则' })).toBeInTheDocument();
expect(screen.getByText('无来源规则')).toBeInTheDocument();
expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument();
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument();
expect(screen.getAllByText('P0 实时中断').length).toBeGreaterThan(0);
expect(screen.getAllByText('接入运维 + 业务责任人').length).toBeGreaterThan(0);
expect(screen.getByText('告警处置队列')).toBeInTheDocument();
expect(screen.getByText('下一步处置')).toBeInTheDocument();
expect(screen.getByText('主责任方')).toBeInTheDocument();
expect(screen.getAllByText('证据完整').length).toBeGreaterThan(0);
expect(screen.getByText('核对平台转发配置')).toBeInTheDocument();
expect(screen.getByText('SLA升级态势')).toBeInTheDocument();
expect(screen.getAllByText('已超时').length).toBeGreaterThan(0);
expect(screen.getByText('即将升级')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '复制SLA升级报告' })).toBeInTheDocument();
expect(screen.getByText('当前待通知告警')).toBeInTheDocument();
expect(screen.getAllByText('粤A规则1 / VIN-RULES-001').length).toBeGreaterThan(0);
expect(screen.getByText('确认平台转发')).toBeInTheDocument();
@@ -3530,9 +3534,14 @@ test('renders notification rules as a standalone operations page', async () => {
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 对第一条待通知告警打开车辆服务、实时、轨迹、RAW 四类证据'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知文本【P0 告警通知】通知规则页待通知'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹证据http://localhost:3000/#/history?keyword=VIN-RULES-001'));
fireEvent.click(screen.getByRole('button', { name: '复制SLA升级报告' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('[P0] 粤A规则1 / VIN-RULES-001'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('策略P0 实时中断 / 接入运维 + 业务责任人 / 30 分钟升级'));
fireEvent.click(screen.getByRole('button', { name: '复制单条告警通知' }));
expect(writeText).toHaveBeenCalledWith('【P0 告警通知】通知规则页待通知');
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
fireEvent.click(screen.getAllByRole('button', { name: '车辆服务' })[0]);
expect(window.location.hash).toBe('#/detail?keyword=VIN-RULES-001');
});