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>}