diff --git a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx index 9270cc06..e0db1cd7 100644 --- a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx +++ b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx @@ -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[] = [ { title: '类型', value: (row) => row.category }, { title: '名称', value: (row) => row.name }, @@ -53,6 +72,97 @@ const notificationExportColumns: CsvColumn[] = [ { 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((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 = { 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() { + SLA升级态势} + style={{ marginTop: 16 }} + > +
+
+ {[ + { 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) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ + 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) => {row.statusLabel} }, + { title: '车辆', width: 220, render: (_: unknown, row: SlaEscalationRow) => row.issue.vehicleLabel }, + { title: '优先级', width: 90, render: (_: unknown, row: SlaEscalationRow) => {row.issue.priority} }, + { 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) => ( + + + + + + ) + } + ]} + /> +
+
+ 规则运行手册} diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index de3ace5c..f545245a 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -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, diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index e093cc7b..cafc0dab 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -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'); });