From 5d401a8f0beeb9b837f587c841806ffc50e3cb3f Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 01:59:05 +0800 Subject: [PATCH] feat(platform): summarize service status on dashboard --- .../api/internal/platform/handler_test.go | 3 ++ .../apps/api/internal/platform/mock_store.go | 6 +++ .../apps/api/internal/platform/model.go | 21 ++++++--- .../api/internal/platform/production_store.go | 26 +++++++++++ .../apps/web/src/api/types.ts | 7 +++ .../apps/web/src/pages/Dashboard.tsx | 28 ++++++++++-- .../apps/web/src/test/App.test.tsx | 43 +++++++++++++++++++ vehicle-data-platform/docs/api-contract.md | 8 ++++ 8 files changed, 132 insertions(+), 10 deletions(-) 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 fbeebf79..1ed8da13 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -19,6 +19,9 @@ func TestHandlerDashboardSummary(t *testing.T) { if !strings.Contains(rec.Body.String(), "onlineVehicles") { t.Fatalf("response missing onlineVehicles: %s", rec.Body.String()) } + if !strings.Contains(rec.Body.String(), "serviceStatuses") || !strings.Contains(rec.Body.String(), "degraded") { + t.Fatalf("response missing vehicle service status distribution: %s", rec.Body.String()) + } } func TestHandlerVehicles(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 66c3fa3d..aa095ad1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -44,6 +44,12 @@ func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error) {Protocol: "JT808", Online: 73, Total: 318}, {Protocol: "YUTONG_MQTT", Online: 1, Total: 8}, }, + ServiceStatuses: []ServiceStatusStat{ + {Status: "healthy", Title: "服务正常", Count: 2}, + {Status: "degraded", Title: "部分来源离线", Count: 1}, + {Status: "offline", Title: "车辆离线", Count: 1}, + {Status: "identity_required", Title: "身份未绑定", Count: 0}, + }, LinkHealth: []LinkHealth{ {Name: "Redis realtime", Status: "ok", Detail: "在线状态 1 分钟 TTL 正常"}, {Name: "TDengine raw_frames", Status: "ok", Detail: "最近 5 分钟有写入"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index ee20f9f9..f9f261f2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -8,13 +8,14 @@ type Page[T any] struct { } type DashboardSummary struct { - OnlineVehicles int `json:"onlineVehicles"` - ActiveToday int `json:"activeToday"` - FrameToday int `json:"frameToday"` - IssueVehicles int `json:"issueVehicles"` - KafkaLag *int `json:"kafkaLag"` - Protocols []ProtocolStat `json:"protocols"` - LinkHealth []LinkHealth `json:"linkHealth"` + OnlineVehicles int `json:"onlineVehicles"` + ActiveToday int `json:"activeToday"` + FrameToday int `json:"frameToday"` + IssueVehicles int `json:"issueVehicles"` + KafkaLag *int `json:"kafkaLag"` + Protocols []ProtocolStat `json:"protocols"` + ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` + LinkHealth []LinkHealth `json:"linkHealth"` } type ProtocolStat struct { @@ -23,6 +24,12 @@ type ProtocolStat struct { Total int `json:"total"` } +type ServiceStatusStat struct { + Status string `json:"status"` + Title string `json:"title"` + Count int `json:"count"` +} + type LinkHealth struct { Name string `json:"name"` Status string `json:"status"` 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 3962a6e7..34e7f530 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -60,6 +60,11 @@ func (s *ProductionStore) DashboardSummary(ctx context.Context) (DashboardSummar return DashboardSummary{}, err } summary.Protocols = protocols + serviceStatuses, err := s.serviceStatusStats(ctx) + if err != nil { + return DashboardSummary{}, err + } + summary.ServiceStatuses = serviceStatuses health, err := s.OpsHealth(ctx) if err != nil { return DashboardSummary{}, err @@ -69,6 +74,27 @@ func (s *ProductionStore) DashboardSummary(ctx context.Context) (DashboardSummar return summary, nil } +func (s *ProductionStore) serviceStatusStats(ctx context.Context) ([]ServiceStatusStat, error) { + definitions := []ServiceStatusStat{ + {Status: "healthy", Title: "服务正常"}, + {Status: "degraded", Title: "部分来源离线"}, + {Status: "offline", Title: "车辆离线"}, + {Status: "identity_required", Title: "身份未绑定"}, + } + out := make([]ServiceStatusStat, 0, len(definitions)) + for _, definition := range definitions { + query := url.Values{"serviceStatus": {definition.Status}, "limit": {"1"}} + built := buildVehicleCoverageSQL(query) + count := 0 + if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&count); err != nil { + return nil, err + } + definition.Count = count + out = append(out, definition) + } + 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 e16b794e..0306fcbc 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -23,9 +23,16 @@ export interface DashboardSummary { issueVehicles: number; kafkaLag: number | null; protocols: ProtocolStat[]; + serviceStatuses: ServiceStatusStat[]; linkHealth: LinkHealth[]; } +export interface ServiceStatusStat { + status: string; + title: string; + count: number; +} + export interface VehicleRow { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 1dd8c831..293e8e7e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, VehicleCoverageRow, VehicleRealtimeRow } from '../api/types'; +import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; @@ -12,6 +12,13 @@ const statusColor: Record = { error: 'red' }; +const serviceStatusColor: Record = { + healthy: 'green', + degraded: 'orange', + offline: 'red', + identity_required: 'orange' +}; + function formatLag(value?: number | null) { return value == null ? '未接入' : value.toLocaleString(); } @@ -74,7 +81,22 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi ))} - + + + {row.title} + }, + { title: '车辆数', dataIndex: 'count' } + ]} + /> + + +
- +
{ expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1); }); +test('shows vehicle service status distribution on dashboard', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + 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: [ + { status: 'healthy', title: '服务正常', count: 12 }, + { status: 'degraded', title: '部分来源离线', count: 39 } + ], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('车辆服务状态')).toBeInTheDocument(); + expect(screen.getByText('部分来源离线')).toBeInTheDocument(); + expect(screen.getByText('39')).toBeInTheDocument(); +}); + test('opens vehicle detail from shareable hash', async () => { window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312'); vi.spyOn(globalThis, 'fetch').mockResolvedValue({ diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 8970356a..54b3d6dd 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -39,6 +39,14 @@ If a keyword cannot be resolved to a VIN, data APIs must not fabricate a VIN. Th ## Core Query APIs +### Dashboard Summary + +```http +GET /api/dashboard/summary +``` + +Returns vehicle service KPIs, source distribution, vehicle service status distribution, link health, and realtime backlog. + ### Vehicle Service ```http