perf(monitor): aggregate realtime workspace

This commit is contained in:
lingniu
2026-07-16 03:26:14 +08:00
parent 01150844df
commit cad39d6326
12 changed files with 147 additions and 28 deletions

View File

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

View File

@@ -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()

View File

@@ -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"`

View File

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

View File

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

View File

@@ -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();

View File

@@ -28,6 +28,7 @@ import type {
MileageStatistics,
MapReverseGeocode,
MonitorMapResponse,
MonitorWorkspaceResponse,
MonitorSummary,
OnlineStatisticsSummary,
OnlineVehicleStatusRow,
@@ -131,6 +132,7 @@ export const api = {
session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)),
monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorWorkspaceResponse>(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined),
trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined),
metricCatalog: (signal?: AbortSignal) => request<MetricCatalog>('/api/v2/metrics', withSignal(undefined, signal)),
historyMetricCatalog: (signal?: AbortSignal) => request<HistoryMetricCatalog>('/api/v2/history/metrics', withSignal(undefined, signal)),

View File

@@ -61,6 +61,12 @@ export interface MonitorMapResponse {
asOf: string;
}
export interface MonitorWorkspaceResponse {
summary: MonitorSummary;
vehicles: Page<VehicleRealtimeRow>;
map: MonitorMapResponse;
}
export interface TrackPlaybackResponse {
vin: string;
plate: string;

View File

@@ -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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
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();

View File

@@ -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<T>(query: UseQueryResult<MonitorWorkspaceResponse>, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult<T> {
return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult<T>;
}
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<MonitorSummary>(workspace, (data) => data.summary) : summaryQuery;
const vehicles = mapEnabled ? projectWorkspaceQuery<Page<VehicleRealtimeRow>>(workspace, (data) => data.vehicles) : vehiclesQuery;
const map = projectWorkspaceQuery<MonitorMapResponse>(workspace, (data) => data.map);
const selectedVehicle = useQuery({
queryKey: ['monitor', 'selected-vehicle', selectedVin],