feat(platform): surface source readiness on dashboard

This commit is contained in:
lingniu
2026-07-04 23:38:48 +08:00
parent 44c7748958
commit 1780039722
3 changed files with 413 additions and 2 deletions

View File

@@ -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<VehicleRealtimeRow[]>([]);
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
const [sourceReadiness, setSourceReadiness] = useState<SourceReadinessPlan | null>(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({
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}> CSV</Button>
</Space>
</Card>
<Card bordered title="数据源到车辆服务就绪度" style={{ marginBottom: 16 }}>
<div className="vp-source-readiness-grid">
{(sourceReadiness?.sources ?? []).map((item) => (
<div key={item.protocol} className="vp-source-readiness-item">
<div className="vp-source-readiness-head">
<Space wrap>
<Tag color={sourceReadinessColor(item)}>{item.protocol}</Tag>
<Tag color={sourceReadinessColor(item)}>{item.status}</Tag>
</Space>
<Typography.Text type="secondary">{item.role}</Typography.Text>
</div>
<div className="vp-source-readiness-metrics">
<div>
<span>线</span>
<strong>{formatCount(item.online)} / {formatCount(item.total)}</strong>
</div>
<div>
<span>线</span>
<strong>{item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}%</strong>
</div>
<div>
<span></span>
<strong>{formatCount(item.missingVehicles)}</strong>
</div>
</div>
<Typography.Text type="secondary">{item.evidence}</Typography.Text>
<div className="vp-source-readiness-actions">
<Button size="small" theme="light" onClick={() => { window.location.hash = item.realtimeHash; }}></Button>
<Button size="small" theme="light" onClick={() => { window.location.hash = item.vehiclesHash; }}></Button>
<Button size="small" theme="light" onClick={() => { window.location.hash = item.historyHash; }}>RAW</Button>
<Button size="small" theme="light" type={item.severity === 'ok' ? 'tertiary' : 'warning'} onClick={() => { window.location.hash = item.alertHash; }}></Button>
</div>
</div>
))}
{sourceReadiness?.sources?.length ? null : (
<div className="vp-source-readiness-empty">
<Tag color="grey"></Tag>
<Typography.Text type="secondary"> 32960808 MQTT </Typography.Text>
</div>
)}
</div>
</Card>
<Card bordered title="地图运营能力" style={{ marginBottom: 16 }}>
<div className="vp-map-ops-board">
<div className="vp-map-ops-readiness">

View File

@@ -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,

View File

@@ -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缺失车辆 693Kafka 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缺失车辆 609Kafka 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缺失车辆 983Kafka 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缺失车辆 693Kafka 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缺失车辆 609Kafka 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缺失车辆 983Kafka 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缺失车辆 693Kafka 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缺失车辆 609Kafka 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缺失车辆 983Kafka 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缺失车辆 693Kafka 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缺失车辆 609Kafka 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缺失车辆 983Kafka 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缺失车辆 693Kafka 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();