diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 5c3359ee..82ba8896 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -55,6 +55,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/ops/source-readiness", h.handleSourceReadiness) h.mux.HandleFunc("GET /api/v2/monitor/summary", h.handleMonitorSummary) h.mux.HandleFunc("GET /api/v2/monitor/map", h.handleMonitorMap) + h.mux.HandleFunc("GET /api/v2/monitor/workspace", h.handleMonitorWorkspace) h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/profile", h.handleVehicleProfile) h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile) h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry) @@ -307,6 +308,11 @@ func (h *Handler) handleMonitorMap(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleMonitorWorkspace(w http.ResponseWriter, r *http.Request) { + data, err := h.service.MonitorWorkspace(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleDashboardSummary(w http.ResponseWriter, r *http.Request) { data, err := h.service.DashboardSummary(r.Context()) h.write(w, r, data, err) 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 bbf01b88..66e098ea 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -834,6 +834,25 @@ func TestHandlerVehicleRealtime(t *testing.T) { } } +func TestHandlerMonitorWorkspaceReturnsCoherentPayload(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/monitor/workspace?zoom=13&railLimit=2", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data MonitorWorkspaceResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if body.Data.Summary.TotalVehicles == 0 || body.Data.Map.Total == 0 || len(body.Data.Vehicles.Items) != 2 { + t.Fatalf("workspace response is incomplete: %+v body=%s", body.Data, rec.Body.String()) + } +} + func TestHandlerVehicleRealtimeFiltersServiceStatus(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index ff6cad55..51e91556 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -32,6 +32,12 @@ type MonitorMapResponse struct { AsOf string `json:"asOf"` } +type MonitorWorkspaceResponse struct { + Summary MonitorSummary `json:"summary"` + Vehicles Page[VehicleRealtimeRow] `json:"vehicles"` + Map MonitorMapResponse `json:"map"` +} + type MonitorMapPoint struct { VIN string `json:"vin"` Plate string `json:"plate"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 03f66f30..00bfe273 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -488,6 +488,10 @@ func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (Monitor if err != nil { return MonitorSummary{}, err } + return s.buildMonitorSummary(ctx, query, vehicles) +} + +func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, vehicles Page[VehicleRealtimeRow]) (MonitorSummary, error) { dashboard, err := s.store.DashboardSummary(ctx) if err != nil { return MonitorSummary{}, err @@ -533,6 +537,34 @@ func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (Monitor return result, nil } +func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (MonitorWorkspaceResponse, error) { + vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query)) + if err != nil { + return MonitorWorkspaceResponse{}, err + } + summary, err := s.buildMonitorSummary(ctx, query, vehicles) + if err != nil { + return MonitorWorkspaceResponse{}, err + } + monitorMap, err := buildMonitorMapResponse(vehicles, query) + if err != nil { + return MonitorWorkspaceResponse{}, err + } + railLimit := parsePositive(query.Get("railLimit"), 200) + if railLimit > 200 { + railLimit = 200 + } + if railLimit > len(vehicles.Items) { + railLimit = len(vehicles.Items) + } + railItems := append([]VehicleRealtimeRow(nil), vehicles.Items[:railLimit]...) + return MonitorWorkspaceResponse{ + Summary: summary, + Vehicles: Page[VehicleRealtimeRow]{Items: railItems, Total: vehicles.Total, Limit: railLimit, Offset: 0}, + Map: monitorMap, + }, nil +} + func (s *Service) MonitorMap(ctx context.Context, query url.Values) (MonitorMapResponse, error) { vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query)) if err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index f7b0b395..e62b17c0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -291,6 +291,26 @@ func TestMonitorMapTenThousandVehiclesNeverReturnsTenThousandPoints(t *testing.T } } +func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) { + store := newCountingStore() + service := NewService(store) + workspace, err := service.MonitorWorkspace(context.Background(), url.Values{ + "zoom": {"13"}, "railLimit": {"2"}, + }) + if err != nil { + t.Fatalf("MonitorWorkspace returned error: %v", err) + } + if store.vehicleRealtimeCalls != 1 { + t.Fatalf("workspace should read one realtime snapshot, calls=%d", store.vehicleRealtimeCalls) + } + if workspace.Summary.TotalVehicles == 0 || workspace.Map.Total == 0 { + t.Fatalf("workspace should derive summary and map from the shared snapshot: %+v", workspace) + } + if len(workspace.Vehicles.Items) != 2 || workspace.Vehicles.Limit != 2 || workspace.Vehicles.Total < 2 { + t.Fatalf("workspace rail page should honor its bounded limit: %+v", workspace.Vehicles) + } +} + func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) { vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130}, diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 956d58b1..5a6934d0 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -91,6 +91,13 @@ test('monitor viewport requests forward AbortSignal so obsolete pans can be canc expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal }); }); +test('monitor workspace aggregates the map screen behind one abortable request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { summary: {}, vehicles: { items: [] }, map: { points: [], clusters: [] } } }) } as Response); + const controller = new AbortController(); + await api.monitorWorkspace(new URLSearchParams({ zoom: '13', railLimit: '200' }), controller.signal); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/workspace?zoom=13&railLimit=200', { signal: controller.signal }); +}); + test('high-volume history, track, and mileage requests forward AbortSignal', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response); const controller = new AbortController(); diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 63eb8f7e..dd6af06d 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -28,6 +28,7 @@ import type { MileageStatistics, MapReverseGeocode, MonitorMapResponse, + MonitorWorkspaceResponse, MonitorSummary, OnlineStatisticsSummary, OnlineVehicleStatusRow, @@ -131,6 +132,7 @@ export const api = { session: (signal?: AbortSignal) => request('/api/v2/session', withSignal(undefined, signal)), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), + monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined), trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined), metricCatalog: (signal?: AbortSignal) => request('/api/v2/metrics', withSignal(undefined, signal)), historyMetricCatalog: (signal?: AbortSignal) => request('/api/v2/history/metrics', withSignal(undefined, signal)), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 49f51ba5..ce4c0335 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -61,6 +61,12 @@ export interface MonitorMapResponse { asOf: string; } +export interface MonitorWorkspaceResponse { + summary: MonitorSummary; + vehicles: Page; + map: MonitorMapResponse; +} + export interface TrackPlaybackResponse { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx index 5d64b4e3..83613ca4 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.cache.test.tsx @@ -10,9 +10,14 @@ afterEach(() => { vi.restoreAllMocks(); }); -test('rapid viewport changes retain at most one monitor map payload', async () => { - vi.spyOn(api, 'monitorSummary').mockResolvedValue({ totalVehicles: 0, onlineVehicles: 0, offlineVehicles: 0, drivingVehicles: 0, idleVehicles: 0, unknownVehicles: 0, alertVehicles: 0, activeToday: 0, frameToday: 0, alertDataAvailable: false, truncated: false, asOf: '' }); - const monitorMap = vi.spyOn(api, 'monitorMap').mockResolvedValue({ mode: 'points', zoom: 13, total: 0, truncated: false, points: [], clusters: [], asOf: '' }); +test('rapid viewport changes retain one workspace payload and avoid duplicate fleet requests', async () => { + const monitorSummary = vi.spyOn(api, 'monitorSummary').mockResolvedValue({ totalVehicles: 0, onlineVehicles: 0, offlineVehicles: 0, drivingVehicles: 0, idleVehicles: 0, unknownVehicles: 0, alertVehicles: 0, activeToday: 0, frameToday: 0, alertDataAvailable: false, truncated: false, asOf: '' }); + const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 }); + const monitorWorkspace = vi.spyOn(api, 'monitorWorkspace').mockResolvedValue({ + summary: { totalVehicles: 0, onlineVehicles: 0, offlineVehicles: 0, drivingVehicles: 0, idleVehicles: 0, unknownVehicles: 0, alertVehicles: 0, activeToday: 0, frameToday: 0, alertDataAvailable: false, truncated: false, asOf: '' }, + vehicles: { items: [], total: 0, limit: 200, offset: 0 }, + map: { mode: 'points', zoom: 13, total: 0, truncated: false, points: [], clusters: [], asOf: '' } + }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => {children}; const filters = { keyword: '', protocol: '', status: '' }; @@ -21,14 +26,16 @@ test('rapid viewport changes retain at most one monitor map payload', async () = initialProps: { bounds: '113.0000,22.0000,114.0000,23.0000' } }); - await waitFor(() => expect(monitorMap).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(monitorWorkspace).toHaveBeenCalledTimes(1)); + expect(monitorSummary).not.toHaveBeenCalled(); + expect(vehicleRealtime).not.toHaveBeenCalled(); for (let index = 1; index <= 8; index += 1) { view.rerender({ bounds: `${113 + index / 1000},22,${114 + index / 1000},23` }); - await waitFor(() => expect(monitorMap.mock.calls.length).toBeGreaterThanOrEqual(index + 1)); + await waitFor(() => expect(monitorWorkspace.mock.calls.length).toBeGreaterThanOrEqual(index + 1)); } await waitFor(() => { - const cachedMaps = client.getQueryCache().findAll({ queryKey: ['monitor', 'map'] }); - expect(cachedMaps).toHaveLength(1); + const cachedWorkspaces = client.getQueryCache().findAll({ queryKey: ['monitor', 'workspace'] }); + expect(cachedWorkspaces).toHaveLength(1); }); view.unmount(); client.clear(); diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts index f8a10840..695365a9 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -1,6 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { api } from '../../api/client'; -import type { VehicleRealtimeRow } from '../../api/types'; +import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types'; import { QUERY_MEMORY } from '../queryPolicy'; export type MonitorFilters = { @@ -58,34 +58,43 @@ export function monitorMapQueryParams(filters: MonitorFilters, viewport: Monitor return params; } +function projectWorkspaceQuery(query: UseQueryResult, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult { + return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult; +} + export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string, mapEnabled = true, vehicleEnabled = true) { const params = monitorQueryParams(filters, 200); const mapParams = monitorMapQueryParams(filters, viewport); + mapParams.set('railLimit', '200'); - const summary = useQuery({ - queryKey: ['monitor', 'summary', params.toString()], - queryFn: ({ signal }) => api.monitorSummary(params, signal), - refetchInterval: MONITOR_REFRESH.summary, - gcTime: QUERY_MEMORY.summaryGcTime - }); - const vehicles = useQuery({ - queryKey: ['monitor', 'vehicles', params.toString()], - queryFn: ({ signal }) => api.vehicleRealtime(params, signal), - enabled: vehicleEnabled, - placeholderData: (previous) => previous, - refetchInterval: MONITOR_REFRESH.fleet, - gcTime: QUERY_MEMORY.highVolumeGcTime - }); - - const map = useQuery({ - queryKey: ['monitor', 'map', mapParams.toString()], - queryFn: ({ signal }) => api.monitorMap(mapParams, signal), + const workspace = useQuery({ + queryKey: ['monitor', 'workspace', mapParams.toString()], + queryFn: ({ signal }) => api.monitorWorkspace(mapParams, signal), enabled: mapEnabled, placeholderData: (previous) => previous, staleTime: 5_000, gcTime: MONITOR_CACHE.mapGcTime, - refetchInterval: MONITOR_REFRESH.fleet + refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false }); + const summaryQuery = useQuery({ + queryKey: ['monitor', 'summary', params.toString()], + queryFn: ({ signal }) => api.monitorSummary(params, signal), + enabled: !mapEnabled, + placeholderData: workspace.data?.summary, + refetchInterval: !mapEnabled ? MONITOR_REFRESH.summary : false, + gcTime: QUERY_MEMORY.summaryGcTime + }); + const vehiclesQuery = useQuery({ + queryKey: ['monitor', 'vehicles', params.toString()], + queryFn: ({ signal }) => api.vehicleRealtime(params, signal), + enabled: vehicleEnabled && !mapEnabled, + placeholderData: workspace.data?.vehicles, + refetchInterval: vehicleEnabled && !mapEnabled ? MONITOR_REFRESH.fleet : false, + gcTime: QUERY_MEMORY.highVolumeGcTime + }); + const summary = mapEnabled ? projectWorkspaceQuery(workspace, (data) => data.summary) : summaryQuery; + const vehicles = mapEnabled ? projectWorkspaceQuery>(workspace, (data) => data.vehicles) : vehiclesQuery; + const map = projectWorkspaceQuery(workspace, (data) => data.map); const selectedVehicle = useQuery({ queryKey: ['monitor', 'selected-vehicle', selectedVin], diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 8b37c1d0..e7908dee 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -67,10 +67,13 @@ GET /api/dashboard/summary GET /api/v2/monitor/summary?keyword=粤A&protocol=JT808&status=driving GET /api/v2/monitor/map?zoom=5 GET /api/v2/monitor/map?zoom=13&bounds=113,22,114,24&status=online +GET /api/v2/monitor/workspace?zoom=13&bounds=113,22,114,24&railLimit=200 ``` `summary` and `map` share keyword, protocol and `online|offline|driving|idle` status semantics. `map` accepts `bounds=minLongitude,minLatitude,maxLongitude,maxLatitude`; malformed, non-finite, reversed or out-of-world bounds return `MONITOR_BOUNDS_INVALID`. Below zoom 11 the response remains clustered or mixed. At zoom 11 and above it returns lightweight MassMarks only when the visible point set is at most 2,000; otherwise it adaptively coarsens cells until no more than 2,000 clusters remain. It never returns full telemetry JSON in a point. The browser keeps the normal list at 200 rows, requests map data independently, waits 300ms after `moveend/zoomend`, and drops stale viewport bounds when a direct vehicle search must locate and focus its unique result. +`workspace` is the production map-screen BFF response. It executes one bounded realtime snapshot read, then derives `summary`, the first `railLimit` realtime vehicle rows (hard-capped at 200), and the viewport-aware `map` payload from that same snapshot. The standalone `summary`, `map`, and realtime-vehicle APIs remain available for list mode and external compatibility, but the browser must not fan them out concurrently for one map refresh. + Returns vehicle service KPIs, source distribution, vehicle service status distribution, link health, and realtime backlog. ### Vehicle Service diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index ae24b962..07e71207 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -96,6 +96,8 @@ The Alert Center previously mounted every tab's data dependencies together: the Vite previously copied the ignored developer-local `public/app-config.js` into `dist` unchanged. The API correctly intercepted production `/app-config.js` requests, but a local AMap security code still entered release archives and would become exposed if that interception ever regressed. The production build now overwrites the copied file with the credential-free example and verifies an exact byte match. The ECS release smoke gate independently requires the server-side `/_AMapService` host and rejects the security-code property, so both the artifact and the served runtime surface fail closed. This matches AMap's production guidance, which strongly recommends server proxy forwarding and explicitly classifies browser plaintext configuration as unsafe: . +The monitor map screen previously fanned one refresh into three independent requests: summary, a 200-row rail and a map snapshot of up to 10,000 vehicles. Each server path repeated the realtime snapshot count and row query, and the browser created three overlapping payload lifecycles. `/api/v2/monitor/workspace` now reads the bounded snapshot once and derives all three coherent views from it; the client owns one abortable, zero-retention high-volume query. This applies the gateway aggregation/BFF pattern to a single data-heavy screen and follows Grafana's recommendation to share results instead of repeating data-source queries: , . + The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: and . ### Remaining audit queue