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>}
|
||||
|
||||
Reference in New Issue
Block a user