From bb1b2f1c463e19fbe1ba392a2c7f4d1e38e6fab2 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 10:43:32 +0800 Subject: [PATCH] feat(platform): add alert notification operations workspace --- .../apps/web/src/pages/Quality.tsx | 138 +++++++++++++++++- .../apps/web/src/styles/global.css | 37 +++++ .../apps/web/src/test/App.test.tsx | 82 +++++++++++ 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 77448c10..1cf116df 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -36,6 +36,72 @@ const emptySummary: QualitySummary = { issueTypes: [] }; +const alertRuleTemplates = [ + { + issueType: 'NO_SOURCE', + level: 'P0', + owner: '平台接入', + trigger: '绑定车辆连续无 GB32960、JT808、MQTT 来源证据', + notify: '立即通知接入运维,30 分钟未恢复升级给业务责任人', + sla: '30 分钟确认' + }, + { + issueType: 'VIN_MISSING', + level: 'P0', + owner: '车辆档案', + trigger: '来源有数据但无法归并到 VIN', + notify: '通知档案维护人补齐车牌、手机号和 VIN 映射', + sla: '2 小时修复' + }, + { + issueType: 'LINK_GAP', + level: 'P1', + owner: '链路运维', + trigger: '车辆或平台来源上报间断、Redis 在线状态过期', + notify: '通知平台转发和网关值班人,持续异常进入日报', + sla: '1 小时恢复' + }, + { + issueType: 'FIELD_MISSING', + level: 'P1', + owner: '协议解析', + trigger: '核心字段缺失导致定位、里程或统计不可用', + notify: '通知解析负责人核对字段映射和 RAW 样本', + sla: '当日闭环' + } +]; + +const notificationPolicies = [ + { + name: 'P0 实时中断', + target: '接入运维 + 业务责任人', + channel: '站内告警 / 邮件 / 企业微信', + condition: '无来源、VIN 缺失、存储不可写' + }, + { + name: 'P1 数据质量', + target: '协议解析 + 数据治理', + channel: '站内告警 / 每日汇总邮件', + condition: '字段缺失、链路间断、容量风险' + }, + { + name: 'P2 趋势关注', + target: '数智中心', + channel: '周报 / 趋势看板', + condition: '单源车辆、档案缺项、来源覆盖下降' + } +]; + +type AlertRuleRow = { + issueType: string; + level: string; + owner: string; + trigger: string; + notify: string; + sla: string; + count: number; +}; + function qualityParams(values: Record) { const params = new URLSearchParams(); if (values?.keyword) params.set('keyword', values.keyword); @@ -84,6 +150,36 @@ function qualityActionRecommendation(row: QualityIssueRow) { }; } +function issueCount(summary: QualitySummary, issueType: string) { + return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0; +} + +function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] { + const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable); + const capacityCount = health?.capacityFindings?.length ?? 0; + const rows = alertRuleTemplates.map((item) => ({ + ...item, + count: issueCount(summary, item.issueType) + })); + rows.push({ + issueType: 'CAPACITY_RISK', + level: storageWritable && capacityCount === 0 ? 'P2' : 'P0', + owner: '基础设施', + trigger: 'Kafka Lag、连接容量、Redis 在线 Key 或存储读写异常', + notify: storageWritable ? '容量风险进入运维日报,超过阈值升级' : '立即通知基础设施值班人处理存储不可写', + sla: storageWritable ? '当日评估' : '15 分钟恢复', + count: capacityCount + (storageWritable ? 0 : 1) + }); + return rows; +} + +function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' { + if (count <= 0) return 'green'; + if (level === 'P0') return 'red'; + if (level === 'P1') return 'orange'; + return 'grey'; +} + async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { @@ -122,6 +218,9 @@ export function Quality({ const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const primaryIssueType = summary.issueTypes[0]?.name; + const rules = alertRuleRows(summary, health); + const activeRuleCount = rules.filter((item) => item.count > 0).length; + const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length; const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); @@ -237,17 +336,50 @@ export function Quality({
{[ - { label: '事件触发', detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' }, - { label: '分级通知', detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' }, - { label: '处置回执', detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } + { label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' }, + { label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' }, + { label: '处置回执', value: `${summary.issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } ].map((item) => (
{item.label} +
{item.value}
{item.detail}
))}
+
+ + + pagination={false} + dataSource={rules} + rowKey="issueType" + columns={[ + { title: '规则', render: (_: unknown, row: AlertRuleRow) => row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType) }, + { title: '级别', width: 90, render: (_: unknown, row: AlertRuleRow) => {row.level} }, + { title: '当前命中', width: 110, render: (_: unknown, row: AlertRuleRow) => row.count.toLocaleString() }, + { title: '触发条件', dataIndex: 'trigger' }, + { title: 'SLA', dataIndex: 'sla', width: 110 } + ]} + /> + + +
+ {notificationPolicies.map((item) => ( +
+
+ + {item.name} + {item.target} + +
{item.condition}
+
+ {item.channel} +
+ ))} +
+
+
{health?.capacityFindings?.length ? ( diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 148f4903..4c38ccb0 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -350,6 +350,42 @@ body { align-content: start; } +.vp-alert-flow-value { + color: var(--vp-text); + font-size: 22px; + font-weight: 700; + line-height: 28px; +} + +.vp-alert-ops-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.6fr); + gap: 16px; + margin-top: 16px; +} + +.vp-notification-policy-list { + display: grid; + gap: 12px; +} + +.vp-notification-policy { + min-height: 86px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; +} + +.vp-alert-policy-detail { + margin-top: 8px; + color: var(--vp-text-muted); + font-size: 13px; + line-height: 20px; +} + .vp-playback-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; @@ -522,6 +558,7 @@ body { .vp-conclusion-grid, .vp-monitor-layout, .vp-alert-flow, + .vp-alert-ops-grid, .vp-playback-layout, .vp-playback-timeline { grid-template-columns: 1fr; 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 93c9a594..57620e5d 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -1987,6 +1987,88 @@ test('renders quality issues as vehicle-service governance labels', async () => expect(screen.getByText('车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。')).toBeInTheDocument(); }); +test('shows alert rule and notification policy workspace on quality page', async () => { + window.history.replaceState(null, '', '/#/quality'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { + linkHealth: [{ name: 'tdengine', status: 'error', detail: 'write unavailable' }], + kafkaLag: 42, + activeConnections: 120000, + capacityFindings: ['kafka lag 42'], + redisOnlineKeys: 368, + tdengineWritable: false, + mysqlWritable: true, + runtime: { requestTimeoutMs: 5000 } + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/summary')) { + return { + ok: true, + json: async () => ({ + data: { + issueVehicleCount: 3, + issueRecordCount: 5, + errorCount: 1, + warningCount: 4, + protocols: [{ name: 'VEHICLE_SERVICE', count: 3 }], + issueTypes: [ + { name: 'NO_SOURCE', count: 2 }, + { name: 'VIN_MISSING', count: 1 }, + { name: 'FIELD_MISSING', count: 2 } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { + items: [], + total: 0, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('告警触发规则')).toBeInTheDocument(); + expect(screen.getByText('4 类活跃')).toBeInTheDocument(); + expect(screen.getByText('3 类 P0')).toBeInTheDocument(); + expect(screen.getByText('容量与存储风险')).toBeInTheDocument(); + expect(screen.getByText('15 分钟恢复')).toBeInTheDocument(); + expect(screen.getByText('通知策略')).toBeInTheDocument(); + expect(screen.getByText('P0 实时中断')).toBeInTheDocument(); + expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument(); + expect(screen.getByText('站内告警 / 邮件 / 企业微信')).toBeInTheDocument(); +}); + test('opens vehicle service from quality issue with issue source evidence', async () => { window.history.replaceState(null, '', '/#/quality'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {