diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index dc797f7d..b36c42ee 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -384,6 +384,24 @@ func TestHandlerVehicleServiceSummaryEndpoint(t *testing.T) { if !noDataStatusFound { t.Fatalf("summary service status distribution should include no_data, got %+v", body.Data.ServiceStatuses) } + var rawBody struct { + Data struct { + MissingSources []struct { + Protocol string `json:"protocol"` + Count int `json:"count"` + } `json:"missingSources"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &rawBody); err != nil { + t.Fatalf("response JSON should decode raw summary: %v body=%s", err, rec.Body.String()) + } + missing := map[string]int{} + for _, source := range rawBody.Data.MissingSources { + missing[source.Protocol] = source.Count + } + if missing["GB32960"] != 2 || missing["JT808"] != 2 || missing["YUTONG_MQTT"] != 3 { + t.Fatalf("summary should expose canonical missing source counts, got %+v body=%s", missing, rec.Body.String()) + } } func TestHandlerVehicleServiceOverviewsEndpoint(t *testing.T) { diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 060cb9e9..901091f5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -179,6 +179,7 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu TotalVehicles: coverage.Total, ServiceStatuses: []ServiceStatusStat{}, Protocols: []ProtocolStat{}, + MissingSources: []MissingSourceStat{}, } statusCounts := map[string]*ServiceStatusStat{ "healthy": {Status: "healthy", Title: "服务正常"}, @@ -238,6 +239,15 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu for _, protocol := range protocolCounts { summary.Protocols = append(summary.Protocols, *protocol) } + for _, protocol := range canonicalVehicleProtocols { + missing := 0 + for _, row := range coverage.Items { + if !containsString(row.Protocols, protocol) { + missing++ + } + } + summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) + } sort.Slice(summary.ServiceStatuses, func(i, j int) bool { return summary.ServiceStatuses[i].Status < summary.ServiceStatuses[j].Status }) sort.Slice(summary.Protocols, func(i, j int) bool { return summary.Protocols[i].Protocol < summary.Protocols[j].Protocol }) return summary, nil diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 7a11303a..deecb28d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -147,6 +147,7 @@ type VehicleServiceSummary struct { IdentityRequiredVehicles int `json:"identityRequiredVehicles"` ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` Protocols []ProtocolStat `json:"protocols"` + MissingSources []MissingSourceStat `json:"missingSources"` } type VehicleServiceStatus struct { @@ -158,6 +159,11 @@ type VehicleServiceStatus struct { OnlineSourceCount int `json:"onlineSourceCount"` } +type MissingSourceStat struct { + Protocol string `json:"protocol"` + Count int `json:"count"` +} + type VehicleSourceStatus struct { Protocol string `json:"protocol"` Online bool `json:"online"` diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 21a11e23..94f69ca5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -158,9 +158,34 @@ GROUP BY v.vin return VehicleServiceSummary{}, err } summary.Protocols = protocols + missingSources, err := s.missingSourceStats(ctx) + if err != nil { + return VehicleServiceSummary{}, err + } + summary.MissingSources = missingSources return summary, nil } +func (s *ProductionStore) missingSourceStats(ctx context.Context) ([]MissingSourceStat, error) { + const vehicleSetSQL = `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' +UNION +SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''` + out := make([]MissingSourceStat, 0, len(canonicalVehicleProtocols)) + for _, protocol := range canonicalVehicleProtocols { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM (`+vehicleSetSQL+`) v +WHERE NOT EXISTS ( +SELECT 1 FROM vehicle_realtime_snapshot s +WHERE s.vin = v.vin AND s.protocol = ? +)`, protocol).Scan(&count) + if err != nil { + return nil, err + } + out = append(out, MissingSourceStat{Protocol: protocol, Count: count}) + } + return out, nil +} + func (s *ProductionStore) frameToday(ctx context.Context, now time.Time) (int, error) { if s.tdengine == nil { return 0, nil diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index c9269c3f..81aafd88 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -144,6 +144,12 @@ export interface VehicleServiceSummary { identityRequiredVehicles: number; serviceStatuses: ServiceStatusStat[]; protocols: ProtocolStat[]; + missingSources: MissingSourceStat[]; +} + +export interface MissingSourceStat { + protocol: string; + count: number; } export interface VehicleServiceStatus { diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 5bcf0e39..9939e90e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -120,6 +120,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on { label: '暂无来源车辆', value: formatCount(serviceSummary?.noDataVehicles), filters: { serviceStatus: 'no_data' } }, { label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } } ]; + const missingSourceCounts = new Map((serviceSummary?.missingSources ?? []).map((item) => [item.protocol, item.count])); return (
@@ -175,6 +176,24 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on { title: '在线率', render: (_: unknown, row: ProtocolStat) => `${Math.round((row.online / row.total) * 100)}%` + }, + { + title: '缺失车辆', + render: (_: unknown, row: ProtocolStat) => { + const missingCount = missingSourceCounts.get(row.protocol); + if (missingCount == null) { + return '-'; + } + return ( + + ); + } } ]} /> 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 018cc219..fae83056 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -128,6 +128,72 @@ test('dashboard renders vehicle service summary metrics', async () => { expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined); }); +test('opens dashboard coverage from missing source distribution', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/dashboard/summary')) { + return { + ok: true, + json: async () => ({ + data: { + onlineVehicles: 3, + activeToday: 4, + frameToday: 1286320, + issueVehicles: 7, + kafkaLag: 0, + protocols: [], + serviceStatuses: [], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + boundVehicles: 1024, + onlineVehicles: 208, + singleSourceVehicles: 391, + multiSourceVehicles: 181, + noDataVehicles: 461, + identityRequiredVehicles: 9, + serviceStatuses: [], + protocols: [{ protocol: 'YUTONG_MQTT', online: 1, total: 8 }], + missingSources: [{ protocol: 'YUTONG_MQTT', count: 983 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 8, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('缺失车辆')).toBeInTheDocument(); + expect(screen.getByText('983')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '查看缺 YUTONG_MQTT' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined); + }); + expect(screen.getByText('当前筛选:缺 YUTONG_MQTT')).toBeInTheDocument(); +}); + test('opens vehicle list filtered by service summary KPI', async () => { window.history.replaceState(null, '', '/#/dashboard'); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {