feat(platform): add alert sla escalation board
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('SLA:15 分钟恢复');
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user