From 17800397225b054e2a1fe9d02644b204e308d52f Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 23:38:48 +0800 Subject: [PATCH] feat(platform): surface source readiness on dashboard --- .../apps/web/src/pages/Dashboard.tsx | 57 +++- .../apps/web/src/styles/global.css | 60 ++++ .../apps/web/src/test/App.test.tsx | 298 ++++++++++++++++++ 3 files changed, 413 insertions(+), 2 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 823279e1..8c700359 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, T import { IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { DashboardSummary, LinkHealth, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types'; +import type { DashboardSummary, LinkHealth, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, SourceReadinessPlan, SourceReadinessRow, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; @@ -93,6 +93,15 @@ function formatProtocolRate(row: ProtocolStat) { return `${Math.round((row.online / row.total) * 100)}%`; } +function normalizeSourceReadiness(plan: SourceReadinessPlan | null): SourceReadinessPlan | null { + return plan && Array.isArray(plan.sources) ? plan : null; +} + +function sourceReadinessColor(row: SourceReadinessRow): 'green' | 'orange' | 'red' { + if (row.severity === 'error') return 'red'; + return row.severity === 'warning' ? 'orange' : 'green'; +} + function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) { if (row.serviceStatus) { return { @@ -259,6 +268,7 @@ export function Dashboard({ const [locations, setLocations] = useState([]); const [qualityIssues, setQualityIssues] = useState([]); const [opsHealth, setOpsHealth] = useState(null); + const [sourceReadiness, setSourceReadiness] = useState(null); const [loading, setLoading] = useState(true); const [coverageLoading, setCoverageLoading] = useState(false); const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState(''); @@ -298,7 +308,8 @@ export function Dashboard({ api.vehicleCoverage(new URLSearchParams({ limit: '8' })).then((page) => setCoverage(page.items)), api.vehicleRealtime(new URLSearchParams({ limit: '8' })).then((page) => setLocations(page.items)), api.alertEvents(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items)), - api.opsHealth().then(setOpsHealth) + api.opsHealth().then(setOpsHealth), + api.sourceReadiness().then((plan) => setSourceReadiness(normalizeSourceReadiness(plan))) ]; Promise.allSettled(tasks) .then((results) => { @@ -954,6 +965,48 @@ export function Dashboard({ + +
+ {(sourceReadiness?.sources ?? []).map((item) => ( +
+
+ + {item.protocol} + {item.status} + + {item.role} +
+
+
+ 在线 + {formatCount(item.online)} / {formatCount(item.total)} +
+
+ 在线率 + {item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}% +
+
+ 缺失车辆 + {formatCount(item.missingVehicles)} +
+
+ {item.evidence} +
+ + + + +
+
+ ))} + {sourceReadiness?.sources?.length ? null : ( +
+ 待接入 + 数据源就绪度加载后会展示 32960、808 和宇通 MQTT 对车辆服务的支撑状态。 +
+ )} +
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index d8521657..5109e76f 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1755,6 +1755,64 @@ button.vp-realtime-command-item:focus-visible { min-width: 0; } +.vp-source-readiness-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.vp-source-readiness-item, +.vp-source-readiness-empty { + min-height: 188px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-source-readiness-head { + display: grid; + gap: 8px; +} + +.vp-source-readiness-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.vp-source-readiness-metrics > div { + min-width: 0; + padding: 8px; + border-radius: var(--vp-radius-sm); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.vp-source-readiness-metrics span { + display: block; + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-source-readiness-metrics strong { + display: block; + margin-top: 2px; + color: var(--vp-text); + font-size: 15px; + line-height: 22px; +} + +.vp-source-readiness-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .vp-map-ops-board { display: grid; grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr); @@ -2293,6 +2351,8 @@ button.vp-realtime-command-item:focus-visible { .vp-unified-service-grid, .vp-service-dossier, .vp-service-dossier-assets, + .vp-source-readiness-grid, + .vp-source-readiness-metrics, .vp-map-ops-board, .vp-map-ops-readiness, .vp-map-ops-work, 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 4fc79381..120319f9 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -174,6 +174,146 @@ test('exposes AMap operations shortcuts when map key is configured', async () => }) } as Response; } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 0, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-dashboard-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 0', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 174, + total: 424, + onlineRate: 41.04, + missingVehicles: 609, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 174/424,缺失车辆 609,Kafka Lag 0', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + }, + { + protocol: 'YUTONG_MQTT', + role: '宇通派生工况与燃料电池补充来源', + online: 11, + total: 50, + onlineRate: 22, + missingVehicles: 983, + severity: 'ok', + status: '生产可用', + evidence: '在线 11/50,缺失车辆 983,Kafka Lag 0', + action: '保持实时、轨迹、RAW 和统计链路巡检。', + acceptance: '车辆服务可回查实时、轨迹和 RAW 证据。', + vehiclesHash: '#/vehicles?protocol=YUTONG_MQTT&missingProtocol=YUTONG_MQTT', + realtimeHash: '#/realtime?protocol=YUTONG_MQTT&online=online', + historyHash: '#/history-query?protocol=YUTONG_MQTT&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=YUTONG_MQTT' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 0, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-dashboard-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 0', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 174, + total: 424, + onlineRate: 41.04, + missingVehicles: 609, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 174/424,缺失车辆 609,Kafka Lag 0', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + }, + { + protocol: 'YUTONG_MQTT', + role: '宇通派生工况与燃料电池补充来源', + online: 11, + total: 50, + onlineRate: 22, + missingVehicles: 983, + severity: 'ok', + status: '生产可用', + evidence: '在线 11/50,缺失车辆 983,Kafka Lag 0', + action: '保持实时、轨迹、RAW 和统计链路巡检。', + acceptance: '车辆服务可回查实时、轨迹和 RAW 证据。', + vehiclesHash: '#/vehicles?protocol=YUTONG_MQTT&missingProtocol=YUTONG_MQTT', + realtimeHash: '#/realtime?protocol=YUTONG_MQTT&online=online', + historyHash: '#/history-query?protocol=YUTONG_MQTT&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=YUTONG_MQTT' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -297,6 +437,76 @@ test('shows global alert pressure from notification plan in topbar', async () => }) } as Response; } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 0, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-dashboard-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 0', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 174, + total: 424, + onlineRate: 41.04, + missingVehicles: 609, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 174/424,缺失车辆 609,Kafka Lag 0', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + }, + { + protocol: 'YUTONG_MQTT', + role: '宇通派生工况与燃料电池补充来源', + online: 11, + total: 50, + onlineRate: 22, + missingVehicles: 983, + severity: 'ok', + status: '生产可用', + evidence: '在线 11/50,缺失车辆 983,Kafka Lag 0', + action: '保持实时、轨迹、RAW 和统计链路巡检。', + acceptance: '车辆服务可回查实时、轨迹和 RAW 证据。', + vehiclesHash: '#/vehicles?protocol=YUTONG_MQTT&missingProtocol=YUTONG_MQTT', + realtimeHash: '#/realtime?protocol=YUTONG_MQTT&online=online', + historyHash: '#/history-query?protocol=YUTONG_MQTT&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=YUTONG_MQTT' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -537,6 +747,76 @@ test('dashboard exposes vehicle data center capability matrix', async () => { }); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 0, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-dashboard-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 0', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 174, + total: 424, + onlineRate: 41.04, + missingVehicles: 609, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 174/424,缺失车辆 609,Kafka Lag 0', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + }, + { + protocol: 'YUTONG_MQTT', + role: '宇通派生工况与燃料电池补充来源', + online: 11, + total: 50, + onlineRate: 22, + missingVehicles: 983, + severity: 'ok', + status: '生产可用', + evidence: '在线 11/50,缺失车辆 983,Kafka Lag 0', + action: '保持实时、轨迹、RAW 和统计链路巡检。', + acceptance: '车辆服务可回查实时、轨迹和 RAW 证据。', + vehiclesHash: '#/vehicles?protocol=YUTONG_MQTT&missingProtocol=YUTONG_MQTT', + realtimeHash: '#/realtime?protocol=YUTONG_MQTT&online=online', + historyHash: '#/history-query?protocol=YUTONG_MQTT&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=YUTONG_MQTT' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } if (path.includes('/api/ops/health')) { return { ok: true, @@ -651,6 +931,7 @@ test('dashboard exposes vehicle data center capability matrix', async () => { await renderDashboard(); expect(screen.getByText('车辆服务作业台')).toBeInTheDocument(); + expect(screen.getByText('数据源到车辆服务就绪度')).toBeInTheDocument(); expect(screen.getByText('地图运营能力')).toBeInTheDocument(); expect(screen.getByText('数据流转作业链')).toBeInTheDocument(); expect(screen.getByText('车联网场景导航')).toBeInTheDocument(); @@ -658,6 +939,11 @@ test('dashboard exposes vehicle data center capability matrix', async () => { expect(screen.getByText('服务端 API')).toBeInTheDocument(); expect(screen.getByText('安全代理')).toBeInTheDocument(); expect(screen.getByText('安全码暴露')).toBeInTheDocument(); + expect(screen.getByText('GB32960')).toBeInTheDocument(); + expect(screen.getByText('JT808')).toBeInTheDocument(); + expect(screen.getByText('YUTONG_MQTT')).toBeInTheDocument(); + expect(screen.getByText('整车与氢能实时数据主来源')).toBeInTheDocument(); + expect(screen.getByText('在线 73/340,缺失车辆 693,Kafka Lag 0')).toBeInTheDocument(); expect(screen.getByText('协议接入')).toBeInTheDocument(); expect(screen.getByText('统一解析')).toBeInTheDocument(); expect(screen.getByText('实时投影')).toBeInTheDocument(); @@ -698,6 +984,18 @@ test('dashboard exposes vehicle data center capability matrix', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台数据流转图】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('03. 实时投影 / Redis KV')); + fireEvent.click(screen.getAllByRole('button', { name: '实时' })[0]); + expect(window.location.hash).toBe('#/realtime?protocol=GB32960&online=online'); + cleanup(); + + await renderDashboard(); + fireEvent.click(screen.getAllByRole('button', { name: 'RAW' })[0]); + expect(window.location.hash.startsWith('#/history-query')).toBe(true); + expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('protocol')).toBe('GB32960'); + expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true'); + cleanup(); + + await renderDashboard(); fireEvent.click(screen.getByRole('button', { name: '地图运营 实时监控' })); expect(window.location.hash).toBe('#/map?online=online'); cleanup();