perf(web): pause hidden live polling

This commit is contained in:
lingniu
2026-07-16 08:35:37 +08:00
parent e06778e38c
commit 4c54cfa6ff
9 changed files with 46 additions and 15 deletions

View File

@@ -28,6 +28,10 @@ test('rapid viewport changes retain one workspace payload and avoid duplicate fl
});
await waitFor(() => expect(monitorWorkspace).toHaveBeenCalledTimes(1));
const workspaceQuery = client.getQueryCache().findAll({ queryKey: ['monitor', 'workspace'] })[0];
const liveOptions = workspaceQuery?.options as { refetchIntervalInBackground?: boolean; refetchOnWindowFocus?: boolean };
expect(liveOptions.refetchIntervalInBackground).toBe(false);
expect(liveOptions.refetchOnWindowFocus).toBe(true);
expect(monitorSummary).not.toHaveBeenCalled();
expect(vehicleRealtime).not.toHaveBeenCalled();
for (let index = 1; index <= 8; index += 1) {

View File

@@ -2,7 +2,7 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { api } from '../../api/client';
import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
export type MonitorFilters = {
keyword: string;
@@ -143,7 +143,8 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
placeholderData: retainPreviousPageWithinScope<MonitorWorkspaceResponse>(filterScope, 2),
staleTime: 5_000,
gcTime: MONITOR_CACHE.mapGcTime,
refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false
refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false,
...LIVE_QUERY_POLICY
});
const summaryQuery = useQuery({
queryKey: ['monitor', 'summary', params.toString()],
@@ -151,7 +152,8 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
enabled: !mapEnabled,
placeholderData: workspace.data?.summary,
refetchInterval: !mapEnabled ? MONITOR_REFRESH.summary : false,
gcTime: QUERY_MEMORY.summaryGcTime
gcTime: QUERY_MEMORY.summaryGcTime,
...LIVE_QUERY_POLICY
});
const vehiclesQuery = useQuery({
queryKey: ['monitor', 'vehicles', params.toString()],
@@ -159,7 +161,8 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
enabled: vehicleEnabled && !mapEnabled,
placeholderData: workspace.data?.vehicles,
refetchInterval: vehicleEnabled && !mapEnabled ? MONITOR_REFRESH.fleet : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
const summary = mapEnabled ? projectWorkspaceQuery<MonitorSummary>(workspace, (data) => data.summary) : summaryQuery;
const vehicles = mapEnabled ? projectWorkspaceQuery<Page<VehicleRealtimeRow>>(workspace, (data) => data.vehicles) : vehiclesQuery;
@@ -171,7 +174,8 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
enabled: Boolean(selectedVin),
staleTime: 5_000,
refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
return { summary, vehicles, map, selectedVehicle };
@@ -196,7 +200,8 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
enabled,
staleTime: 10_000,
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
const address = useQuery({
queryKey: ['monitor', 'vehicle-card', 'address', addressPoint?.vin, addressPoint?.key],

View File

@@ -6,7 +6,7 @@ import { api } from '../../api/client';
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { usePlatformSession } from '../auth/AuthGate';
import { canOperate } from '../auth/session';
@@ -50,7 +50,7 @@ function ExportJobsPanel() {
queryKey: ['history-exports'],
queryFn: ({ signal }) => api.historyExports(signal),
refetchInterval: (current) => historyExportPollInterval(current.state.data),
refetchIntervalInBackground: false,
...LIVE_QUERY_POLICY,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const jobs = query.data ?? [];

View File

@@ -8,7 +8,7 @@ import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
@@ -251,7 +251,8 @@ export default function MonitorPage() {
queryKey: ['monitor', 'vehicle-list', listScope, listLimit, listOffset],
queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal),
enabled: mode === 'list', placeholderData: retainPreviousPageWithinScope<Page<VehicleRealtimeRow>>(listScope, 2), refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false,
gcTime: QUERY_MEMORY.highVolumeGcTime
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
const rows = useMemo(() => {
const data = vehicles.data?.items ?? [];

View File

@@ -22,6 +22,12 @@ test('reconciles service identities with bound and identity-required vehicles',
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('1035 / 234')).toBeInTheDocument();
for (const key of [['ops-health-v2'], ['ops-source-readiness-v2']]) {
const query = client.getQueryCache().find({ queryKey: key });
const liveOptions = query?.options as { refetchIntervalInBackground?: boolean; refetchOnWindowFocus?: boolean };
expect(liveOptions.refetchIntervalInBackground).toBe(false);
expect(liveOptions.refetchOnWindowFocus).toBe(true);
}
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();

View File

@@ -2,14 +2,15 @@ import { IconRefresh } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import { InlineError } from '../shared/AsyncState';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
}
export default function OperationsPage() {
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000 });
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000 });
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page">

View File

@@ -9,7 +9,7 @@ import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { QUERY_MEMORY } from '../queryPolicy';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
@@ -198,7 +198,7 @@ function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, on
export default function VehiclePage() {
const { vin } = useParams();
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, refetchIntervalInBackground: false });
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
if (!vin) return <VehicleSearch />;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from './queryPolicy';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from './queryPolicy';
describe('query memory policy', () => {
it('pauses live polling in hidden tabs and refreshes immediately on return', () => {
expect(LIVE_QUERY_POLICY).toEqual({
refetchIntervalInBackground: false,
refetchOnWindowFocus: true
});
});
it('drops high-volume route results immediately and bounds reusable small results', () => {
expect(QUERY_MEMORY.highVolumeGcTime).toBe(0);
expect(QUERY_MEMORY.optionGcTime).toBeLessThanOrEqual(30_000);

View File

@@ -4,6 +4,14 @@ export const QUERY_MEMORY = {
summaryGcTime: 60_000
} as const;
// Live views must not spend traffic while the browser tab is hidden, but they
// should reconcile immediately when an operator returns instead of waiting for
// the next interval tick.
export const LIVE_QUERY_POLICY = {
refetchIntervalInBackground: false,
refetchOnWindowFocus: true
} as const;
export function retainPreviousPageWithinScope<T>(scope: string, scopeIndex = 1) {
return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[scopeIndex] === scope ? previous : undefined;
}