fix(platform): harden queries and batch vehicle search
This commit is contained in:
@@ -1113,6 +1113,9 @@ func TestHandlerSourceReadiness(t *testing.T) {
|
|||||||
if body.Data.PlatformRelease != "platform-source-test" {
|
if body.Data.PlatformRelease != "platform-source-test" {
|
||||||
t.Fatalf("source readiness should expose runtime release, got %+v", body.Data)
|
t.Fatalf("source readiness should expose runtime release, got %+v", body.Data)
|
||||||
}
|
}
|
||||||
|
if body.Data.TotalVehicles != body.Data.BoundVehicles+body.Data.IdentityRequiredVehicles {
|
||||||
|
t.Fatalf("source readiness vehicle identity population should reconcile, got %+v", body.Data)
|
||||||
|
}
|
||||||
if len(body.Data.Sources) < len(canonicalVehicleProtocols) {
|
if len(body.Data.Sources) < len(canonicalVehicleProtocols) {
|
||||||
t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources)
|
t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -942,13 +942,15 @@ type MissingSourceStat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SourceReadinessPlan struct {
|
type SourceReadinessPlan struct {
|
||||||
TotalVehicles int `json:"totalVehicles"`
|
TotalVehicles int `json:"totalVehicles"`
|
||||||
OnlineVehicles int `json:"onlineVehicles"`
|
BoundVehicles int `json:"boundVehicles"`
|
||||||
KafkaLag *int `json:"kafkaLag"`
|
IdentityRequiredVehicles int `json:"identityRequiredVehicles"`
|
||||||
ActiveConnections *int `json:"activeConnections"`
|
OnlineVehicles int `json:"onlineVehicles"`
|
||||||
RedisOnlineKeys *int `json:"redisOnlineKeys"`
|
KafkaLag *int `json:"kafkaLag"`
|
||||||
PlatformRelease string `json:"platformRelease"`
|
ActiveConnections *int `json:"activeConnections"`
|
||||||
Sources []SourceReadinessRow `json:"sources"`
|
RedisOnlineKeys *int `json:"redisOnlineKeys"`
|
||||||
|
PlatformRelease string `json:"platformRelease"`
|
||||||
|
Sources []SourceReadinessRow `json:"sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceReadinessRow struct {
|
type SourceReadinessRow struct {
|
||||||
|
|||||||
@@ -262,13 +262,15 @@ func buildSourceReadinessPlan(summary VehicleServiceSummary, health OpsHealth, q
|
|||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
}
|
}
|
||||||
return SourceReadinessPlan{
|
return SourceReadinessPlan{
|
||||||
TotalVehicles: summary.TotalVehicles,
|
TotalVehicles: summary.TotalVehicles,
|
||||||
OnlineVehicles: summary.OnlineVehicles,
|
BoundVehicles: summary.BoundVehicles,
|
||||||
KafkaLag: health.KafkaLag,
|
IdentityRequiredVehicles: summary.IdentityRequiredVehicles,
|
||||||
ActiveConnections: health.ActiveConnections,
|
OnlineVehicles: summary.OnlineVehicles,
|
||||||
RedisOnlineKeys: health.RedisOnlineKeys,
|
KafkaLag: health.KafkaLag,
|
||||||
PlatformRelease: health.Runtime.PlatformRelease,
|
ActiveConnections: health.ActiveConnections,
|
||||||
Sources: rows,
|
RedisOnlineKeys: health.RedisOnlineKeys,
|
||||||
|
PlatformRelease: health.Runtime.PlatformRelease,
|
||||||
|
Sources: rows,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ 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 });
|
expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { 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();
|
||||||
|
const params = new URLSearchParams({ keyword: 'VIN001' });
|
||||||
|
|
||||||
|
await api.trackPlayback(params, controller.signal);
|
||||||
|
await api.historyData(params, controller.signal);
|
||||||
|
await api.historySeries(params, controller.signal);
|
||||||
|
await api.dailyMileage(params, controller.signal);
|
||||||
|
await api.mileageStatistics(params, controller.signal);
|
||||||
|
|
||||||
|
for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal });
|
||||||
|
});
|
||||||
|
|
||||||
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
|
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -127,11 +127,11 @@ export const api = {
|
|||||||
session: () => request<SessionInfo>('/api/v2/session'),
|
session: () => request<SessionInfo>('/api/v2/session'),
|
||||||
monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
|
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),
|
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`),
|
trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
|
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
|
||||||
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
|
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
|
||||||
historyData: (params = new URLSearchParams()) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`),
|
historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
historySeries: (params = new URLSearchParams()) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`),
|
historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
|
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
|
||||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||||
@@ -171,9 +171,9 @@ export const api = {
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
||||||
}),
|
}),
|
||||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||||
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||||
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
|
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
|
||||||
@@ -201,8 +201,8 @@ export const api = {
|
|||||||
body: JSON.stringify(query)
|
body: JSON.stringify(query)
|
||||||
}),
|
}),
|
||||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||||
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
dailyMileage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
mileageStatistics: (params = new URLSearchParams()) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`),
|
mileageStatistics: (params = new URLSearchParams(), signal?: AbortSignal) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
||||||
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
|
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
|
||||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ export interface OpsHealth {
|
|||||||
|
|
||||||
export interface SourceReadinessPlan {
|
export interface SourceReadinessPlan {
|
||||||
totalVehicles: number;
|
totalVehicles: number;
|
||||||
|
boundVehicles: number;
|
||||||
|
identityRequiredVehicles: number;
|
||||||
onlineVehicles: number;
|
onlineVehicles: number;
|
||||||
kafkaLag: number | null;
|
kafkaLag: number | null;
|
||||||
activeConnections: number | null;
|
activeConnections: number | null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../../api/client';
|
|||||||
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
|
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
|
||||||
import { InlineError } from '../shared/AsyncState';
|
import { InlineError } from '../shared/AsyncState';
|
||||||
|
import { QUERY_MEMORY } from '../queryPolicy';
|
||||||
|
|
||||||
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
|
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
|
||||||
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
|
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
|
||||||
@@ -79,8 +80,8 @@ export default function HistoryPage() {
|
|||||||
return next;
|
return next;
|
||||||
}, [criteria, keywords]);
|
}, [criteria, keywords]);
|
||||||
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
|
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
|
||||||
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous });
|
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: ({ signal }) => api.historyData(params, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||||
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous });
|
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||||
const result = dataQuery.data;
|
const result = dataQuery.data;
|
||||||
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
|
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
|
||||||
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
|
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const vehicles = [{
|
|||||||
}] as VehicleRealtimeRow[];
|
}] as VehicleRealtimeRow[];
|
||||||
const monitorMap = { clusters: [], points: [], total: 2 };
|
const monitorMap = { clusters: [], points: [], total: 2 };
|
||||||
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
|
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
|
||||||
|
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
|
||||||
|
|
||||||
vi.mock('../map/FleetMap', () => ({
|
vi.mock('../map/FleetMap', () => ({
|
||||||
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
|
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
|
||||||
@@ -42,14 +43,19 @@ vi.mock('../hooks/useMonitorData', () => ({
|
|||||||
},
|
},
|
||||||
useMonitorData: () => ({
|
useMonitorData: () => ({
|
||||||
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
||||||
vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false },
|
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
|
||||||
map: { data: monitorMap },
|
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
|
||||||
selectedVehicle: { data: { items: [] } }
|
selectedVehicle: { data: { items: [] } }
|
||||||
}),
|
}),
|
||||||
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} })
|
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
monitorQueryFlags.isLoading = false;
|
||||||
|
monitorQueryFlags.isFetching = false;
|
||||||
|
monitorQueryFlags.isPlaceholderData = false;
|
||||||
|
});
|
||||||
|
|
||||||
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
@@ -63,6 +69,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
|||||||
expect(firstVehicle).not.toHaveClass('is-selected');
|
expect(firstVehicle).not.toHaveClass('is-selected');
|
||||||
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||||
|
expect(screen.getByText('有实时位置')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(firstVehicle);
|
fireEvent.click(firstVehicle);
|
||||||
expect(workspace).toHaveClass('is-detail-open');
|
expect(workspace).toHaveClass('is-detail-open');
|
||||||
@@ -124,8 +132,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
|
|||||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||||
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } });
|
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } });
|
||||||
expect(input).toHaveValue('粤A12345,粤B67890');
|
expect(input).toHaveValue('粤A12345,粤B67890');
|
||||||
expect(screen.getByText('已识别 2 辆')).toBeInTheDocument();
|
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
|
||||||
expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -134,3 +141,24 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
|
|||||||
expect(params?.has('keyword')).toBe(false);
|
expect(params?.has('keyword')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hides stale fleet rows while a pasted batch is still loading', () => {
|
||||||
|
monitorQueryFlags.isPlaceholderData = true;
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
|
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } });
|
||||||
|
expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('查询中')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /粤A12345 LTEST000000000001/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports pasted plates that have no exact realtime match', () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
|
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } });
|
||||||
|
expect(screen.getByText('已找到 1/2')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('已找到 1/2 · 未找到 1 辆')).toHaveLength(2);
|
||||||
|
expect(screen.getAllByTitle('未找到:粤Z99999')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMon
|
|||||||
|
|
||||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||||
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
||||||
|
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
|
||||||
|
|
||||||
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
||||||
|
|
||||||
@@ -194,6 +195,30 @@ export default function MonitorPage() {
|
|||||||
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
|
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
|
||||||
return data;
|
return data;
|
||||||
}, [status, vehicles.data?.items]);
|
}, [status, vehicles.data?.items]);
|
||||||
|
const listRows = realtimeListQuery.data?.items ?? EMPTY_VEHICLES;
|
||||||
|
const activeBatchRows = mode === 'map' ? rows : listRows;
|
||||||
|
const activeBatchQuery = mode === 'map' ? vehicles : realtimeListQuery;
|
||||||
|
const batchSearchPending = searchTerms.length > 1 && (
|
||||||
|
keyword !== deferredKeyword || activeBatchQuery.isLoading || activeBatchQuery.isPlaceholderData
|
||||||
|
);
|
||||||
|
const batchMatch = useMemo(() => {
|
||||||
|
const identities = new Set<string>();
|
||||||
|
for (const vehicle of activeBatchRows) {
|
||||||
|
identities.add(vehicle.vin.toLocaleUpperCase());
|
||||||
|
if (vehicle.plate) identities.add(vehicle.plate.toLocaleUpperCase());
|
||||||
|
}
|
||||||
|
const missing = searchTerms.filter((term) => !identities.has(term));
|
||||||
|
return { matched: searchTerms.length - missing.length, missing };
|
||||||
|
}, [activeBatchRows, searchTerms]);
|
||||||
|
const visibleRows = batchSearchPending ? [] : rows;
|
||||||
|
const batchStatus = batchSearchPending
|
||||||
|
? `正在查找 ${searchTerms.length} 辆`
|
||||||
|
: batchMatch.missing.length
|
||||||
|
? `已找到 ${batchMatch.matched}/${searchTerms.length} · 未找到 ${batchMatch.missing.length} 辆`
|
||||||
|
: `已找到 ${batchMatch.matched}/${searchTerms.length}`;
|
||||||
|
const batchStatusTitle = batchMatch.missing.length
|
||||||
|
? `未找到:${batchMatch.missing.join('、')}`
|
||||||
|
: searchTerms.join('、');
|
||||||
const selected = selectedVin
|
const selected = selectedVin
|
||||||
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
|
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -232,7 +257,7 @@ export default function MonitorPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder="车牌 / VIN;可批量粘贴车牌"
|
placeholder="车牌 / VIN;可批量粘贴车牌"
|
||||||
/>
|
/>
|
||||||
{searchTerms.length > 1 ? <span className="v2-search-batch-count" aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${searchTerms.join('、')}` : searchTerms.join('、')}>已识别 {searchTerms.length} 辆</span> : null}
|
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
|
||||||
</label>
|
</label>
|
||||||
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
|
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
|
||||||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||||||
@@ -248,7 +273,7 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
<section className="v2-kpis" aria-label="车辆整体统计">
|
<section className="v2-kpis" aria-label="车辆整体统计">
|
||||||
{[
|
{[
|
||||||
['接入车辆', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||||||
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
||||||
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
||||||
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
||||||
@@ -262,18 +287,18 @@ export default function MonitorPage() {
|
|||||||
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
|
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
|
||||||
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
|
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
|
||||||
<div className="v2-vehicle-rail">
|
<div className="v2-vehicle-rail">
|
||||||
<header><strong>车辆列表</strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} 辆</span></header>
|
<header><strong>车辆列表</strong><span>{batchSearchPending ? '查询中' : `${formatNumber(vehicles.data?.total ?? visibleRows.length)} 辆`}</span></header>
|
||||||
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length} 辆` : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}</span></div>
|
<div className={`v2-rail-search${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} title={searchTerms.length > 1 ? batchStatusTitle : undefined}><IconSearch /><span>{searchTerms.length > 1 ? batchStatus : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}</span></div>
|
||||||
<div className="v2-vehicle-scroll">
|
<div className="v2-vehicle-scroll">
|
||||||
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" />加载车辆</div> : null}
|
{vehicles.isLoading || batchSearchPending ? <div className="v2-list-loading"><span className="v2-spinner" />{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}</div> : null}
|
||||||
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
|
{!vehicles.isLoading && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
|
||||||
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
|
{visibleRows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
|
||||||
</div>
|
</div>
|
||||||
<footer>当前载入 {rows.length} / {vehicles.data?.total ?? rows.length} 辆</footer>
|
<footer>{searchTerms.length > 1 && !batchSearchPending ? batchStatus : `当前载入 ${visibleRows.length} / ${vehicles.data?.total ?? visibleRows.length} 辆`}</footer>
|
||||||
</div>
|
</div>
|
||||||
<MemoFleetMap
|
<MemoFleetMap
|
||||||
vehicles={rows}
|
vehicles={visibleRows}
|
||||||
monitorMap={map.data}
|
monitorMap={searchTerms.length > 1 && (batchSearchPending || map.isPlaceholderData) ? undefined : map.data}
|
||||||
selectedVin={selectedVin || undefined}
|
selectedVin={selectedVin || undefined}
|
||||||
onSelect={selectMapVehicle}
|
onSelect={selectMapVehicle}
|
||||||
onSelectVin={selectVehicle}
|
onSelectVin={selectVehicle}
|
||||||
@@ -303,7 +328,7 @@ export default function MonitorPage() {
|
|||||||
<section className="v2-event-strip">
|
<section className="v2-event-strip">
|
||||||
<strong>实时数据状态</strong>
|
<strong>实时数据状态</strong>
|
||||||
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
|
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
|
||||||
<span>{mode === 'map' ? `列表 ${rows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`}</span>
|
<span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`}</span>
|
||||||
<span className="v2-refresh-cadence"><b>智能刷新</b> 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
<span className="v2-refresh-cadence"><b>智能刷新</b> 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
||||||
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
|
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
import OperationsPage from './OperationsPage';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ opsHealth: vi.fn(), sourceReadiness: vi.fn() }));
|
||||||
|
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||||
|
|
||||||
|
afterEach(() => { cleanup(); mocks.opsHealth.mockReset(); mocks.sourceReadiness.mockReset(); });
|
||||||
|
|
||||||
|
test('reconciles service identities with bound and identity-required vehicles', async () => {
|
||||||
|
mocks.opsHealth.mockResolvedValue({
|
||||||
|
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||||
|
tdengineWritable: true, mysqlWritable: true,
|
||||||
|
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||||
|
});
|
||||||
|
mocks.sourceReadiness.mockResolvedValue({
|
||||||
|
totalVehicles: 1035, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
|
||||||
|
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
|
||||||
|
});
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||||
|
|
||||||
|
expect(await screen.findByText('1035 / 234')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -13,14 +13,14 @@ export default function OperationsPage() {
|
|||||||
const data = health.data; const sources = readiness.data;
|
const data = health.data; const sources = readiness.data;
|
||||||
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
||||||
return <div className="v2-ops-page">
|
return <div className="v2-ops-page">
|
||||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>所有状态均来自当前 ECS 健康接口和生产投影,不用前端估算。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新证据</button></header>
|
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>服务身份、主车辆档案和实时位置采用不同可核对口径,不用前端估算。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新证据</button></header>
|
||||||
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
||||||
<section className="v2-ops-kpis">
|
<section className="v2-ops-kpis">
|
||||||
<article><small>运行版本</small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
|
<article><small>运行版本</small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
|
||||||
<article><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
|
<article><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
|
||||||
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
|
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
|
||||||
<article><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>实时探针</span></article>
|
<article><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>实时探针</span></article>
|
||||||
<article><small>车辆 / 在线</small><strong>{sources ? `${sources.onlineVehicles} / ${sources.totalVehicles}` : '—'}</strong><span>统一车辆视角</span></article>
|
<article><small>服务身份 / 在线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></article>
|
||||||
</section>
|
</section>
|
||||||
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong>数据链路</strong><span>15 秒自动刷新</span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
|
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong>数据链路</strong><span>15 秒自动刷新</span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
|
||||||
<section className="v2-ops-runtime"><header><strong>运行时安全</strong></header><dl><div><dt>生产数据模式</dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL 写探针</dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine 写探针</dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt>请求超时</dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt>高德安全代理</dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear">容量检查无待处理项</p>}</section></div>
|
<section className="v2-ops-runtime"><header><strong>运行时安全</strong></header><dl><div><dt>生产数据模式</dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL 写探针</dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine 写探针</dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt>请求超时</dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt>高德安全代理</dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear">容量检查无待处理项</p>}</section></div>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ test('renders one vehicle per row with dates as columns and a period total', asy
|
|||||||
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
|
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
|
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
|
||||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||||
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
|
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
|
||||||
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
|
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
|
||||||
@@ -100,6 +102,7 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
|||||||
|
|
||||||
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
|
||||||
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
||||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../../api/client';
|
|||||||
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
||||||
import { downloadMileageWorkbook } from '../domain/mileageExport';
|
import { downloadMileageWorkbook } from '../domain/mileageExport';
|
||||||
import { InlineError } from '../shared/AsyncState';
|
import { InlineError } from '../shared/AsyncState';
|
||||||
|
import { QUERY_MEMORY } from '../queryPolicy';
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
const DETAIL_LIMIT = 10_000;
|
const DETAIL_LIMIT = 10_000;
|
||||||
@@ -134,9 +135,10 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
|||||||
}, [debounced]);
|
}, [debounced]);
|
||||||
const candidates = useQuery({
|
const candidates = useQuery({
|
||||||
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
|
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
|
||||||
queryFn: () => api.vehicles(candidateParams),
|
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 60_000
|
staleTime: 30_000,
|
||||||
|
gcTime: QUERY_MEMORY.optionGcTime
|
||||||
});
|
});
|
||||||
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
|
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
|
||||||
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
|
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
|
||||||
@@ -174,7 +176,7 @@ function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics;
|
|||||||
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||||
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
|
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
|
||||||
const items = [
|
const items = [
|
||||||
['查询车辆', `${vehicleCount} 辆`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `全部车辆 · ${data?.vehicleCount ?? 0} 辆有里程`],
|
['已绑定主车辆', `${vehicleCount} 辆`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`],
|
||||||
['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`],
|
['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`],
|
||||||
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
|
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
|
||||||
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
|
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
|
||||||
@@ -225,9 +227,10 @@ export default function StatisticsPage() {
|
|||||||
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
|
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
|
||||||
const fleetVehicles = useQuery({
|
const fleetVehicles = useQuery({
|
||||||
queryKey: ['mileage-fleet-page', fleetParams.toString()],
|
queryKey: ['mileage-fleet-page', fleetParams.toString()],
|
||||||
queryFn: () => api.vehicleCoverage(fleetParams),
|
queryFn: ({ signal }) => api.vehicleCoverage(fleetParams, signal),
|
||||||
enabled: !hasVehicles,
|
enabled: !hasVehicles,
|
||||||
staleTime: 60_000
|
staleTime: 60_000,
|
||||||
|
gcTime: QUERY_MEMORY.summaryGcTime
|
||||||
});
|
});
|
||||||
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
|
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
|
||||||
? criteria.vehicles
|
? criteria.vehicles
|
||||||
@@ -235,8 +238,8 @@ export default function StatisticsPage() {
|
|||||||
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
|
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
|
||||||
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
|
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
|
||||||
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
|
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
|
||||||
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: () => api.mileageStatistics(statisticsParams), staleTime: 60_000, placeholderData: (previous) => previous });
|
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime, placeholderData: (previous) => previous });
|
||||||
const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, staleTime: 60_000, placeholderData: (previous) => previous });
|
const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: (previous) => previous });
|
||||||
const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]);
|
const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]);
|
||||||
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
|
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
|
||||||
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
|
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
|
||||||
@@ -304,7 +307,7 @@ export default function StatisticsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <div className="v2-mileage-page">
|
return <div className="v2-mileage-page">
|
||||||
<header className="v2-mileage-heading"><div><h2>里程查询</h2><p>未选择车牌时分页展示全部车辆;选择车牌后查询指定车辆。</p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
|
<header className="v2-mileage-heading"><div><h2>里程查询</h2><p>未选择车牌时分页展示全部已绑定主车辆;选择车牌后查询指定车辆。</p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
|
||||||
<section className="v2-mileage-query-panel">
|
<section className="v2-mileage-query-panel">
|
||||||
<form className="v2-mileage-filter" onSubmit={submit}>
|
<form className="v2-mileage-filter" onSubmit={submit}>
|
||||||
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
|
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
|
||||||
@@ -323,6 +326,6 @@ export default function StatisticsPage() {
|
|||||||
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
||||||
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>下一页</button></div> : null}</footer>
|
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>下一页</button></div> : null}</footer>
|
||||||
</section>
|
</section>
|
||||||
<footer className="v2-mileage-evidence"><span>数据更新时间:{statistics.data?.asOf || '—'}</span><span>来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}</span><span>查询结果缓存 1 分钟</span></footer>
|
<footer className="v2-mileage-evidence"><span>数据更新时间:{statistics.data?.asOf || '—'}</span><span>来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}</span><span>当前筛选复用 1 分钟 · 离开后释放明细</span></footer>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
|
|||||||
|
|
||||||
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
|
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
|
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
|
||||||
@@ -57,4 +57,8 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
|||||||
|
|
||||||
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
|
||||||
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
|
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
|
||||||
|
expect(mocks.trackPlayback.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(1);
|
||||||
|
view.unmount();
|
||||||
|
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(0));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../.
|
|||||||
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
|
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
|
||||||
import { TrackMap } from '../map/TrackMap';
|
import { TrackMap } from '../map/TrackMap';
|
||||||
import { InlineError } from '../shared/AsyncState';
|
import { InlineError } from '../shared/AsyncState';
|
||||||
|
import { QUERY_MEMORY } from '../queryPolicy';
|
||||||
|
|
||||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||||
type PlaybackSpeed = (typeof speedOptions)[number];
|
type PlaybackSpeed = (typeof speedOptions)[number];
|
||||||
@@ -80,7 +81,7 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
|
|||||||
if (debounced) next.set('keyword', debounced);
|
if (debounced) next.set('keyword', debounced);
|
||||||
return next;
|
return next;
|
||||||
}, [debounced]);
|
}, [debounced]);
|
||||||
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 });
|
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
|
||||||
|
|
||||||
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
|
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
|
||||||
<IconSearch />
|
<IconSearch />
|
||||||
@@ -192,7 +193,7 @@ export default function TrackPage() {
|
|||||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||||
return next;
|
return next;
|
||||||
}, [criteria]);
|
}, [criteria]);
|
||||||
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
|
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||||
const track = query.data;
|
const track = query.data;
|
||||||
const points = track?.points ?? [];
|
const points = track?.points ?? [];
|
||||||
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
|
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
|
||||||
|
|||||||
10
vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts
Normal file
10
vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { QUERY_MEMORY } from './queryPolicy';
|
||||||
|
|
||||||
|
describe('query memory policy', () => {
|
||||||
|
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);
|
||||||
|
expect(QUERY_MEMORY.summaryGcTime).toBeLessThanOrEqual(60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
vehicle-data-platform/apps/web/src/v2/queryPolicy.ts
Normal file
5
vehicle-data-platform/apps/web/src/v2/queryPolicy.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const QUERY_MEMORY = {
|
||||||
|
highVolumeGcTime: 0,
|
||||||
|
optionGcTime: 30_000,
|
||||||
|
summaryGcTime: 60_000
|
||||||
|
} as const;
|
||||||
@@ -61,6 +61,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
|||||||
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
|
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
|
||||||
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
|
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
|
||||||
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
|
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
|
||||||
|
.v2-search-batch-count.has-missing { background: #fff4e5; color: #b45309; }
|
||||||
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
|
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
|
||||||
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
|
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
|
||||||
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
|
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
|
||||||
@@ -88,6 +89,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
|||||||
.v2-vehicle-rail > header strong { font-size: 13px; }
|
.v2-vehicle-rail > header strong { font-size: 13px; }
|
||||||
.v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; }
|
.v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; }
|
||||||
.v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; }
|
.v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; }
|
||||||
|
.v2-rail-search.has-missing { border-color: #fed7aa; background: #fffaf3; color: #b45309; }
|
||||||
.v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; }
|
.v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; }
|
||||||
.v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; }
|
.v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; }
|
||||||
.v2-vehicle-row:hover { background: #f8fbff; }
|
.v2-vehicle-row:hover { background: #f8fbff; }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ This document records verified risks, the production controls that address them,
|
|||||||
| Risk | User-visible failure | Control | Evidence gate |
|
| Risk | User-visible failure | Control | Evidence gate |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell |
|
| A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell |
|
||||||
|
| A pasted batch search temporarily shows the previous fleet | Operators may mistake stale vehicles for batch matches | Hide placeholder rows while `keywords` is changing; report matched and missing plates after the request resolves | Paste newline/comma-separated plates with a duplicate and an unknown plate; verify exact results and missing-count feedback |
|
||||||
| A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop |
|
| A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop |
|
||||||
| Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes |
|
| Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes |
|
||||||
| Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |
|
| Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |
|
||||||
@@ -26,6 +27,12 @@ The navigation and progressive-loading direction follows mature observability sy
|
|||||||
4. Reduce the shared CSS and Semi UI payload; preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
|
4. Reduce the shared CSS and Semi UI payload; preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
|
||||||
5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200.
|
5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200.
|
||||||
|
|
||||||
|
## 2026-07-15: query memory and vehicle-count semantics
|
||||||
|
|
||||||
|
Production reconciliation proved that the three visible vehicle counts represent different populations rather than a data loss: 1,035 service identities equal 1,024 bound master vehicles plus 11 realtime identities awaiting binding; 738 vehicles currently have a realtime location row and can enter the global-monitor map. The UI must name these populations explicitly instead of presenting them as interchangeable totals.
|
||||||
|
|
||||||
|
High-volume history rows, aggregated series, track playback and daily-mileage matrices consume the request `AbortSignal` and use zero inactive-cache retention. Small vehicle-option and summary results retain only a bounded 30–60 second cache. This preserves responsive repeated lookups without retaining multiple large arbitrary date-window payloads after their observer is gone.
|
||||||
|
|
||||||
## Release evidence template
|
## Release evidence template
|
||||||
|
|
||||||
- Commit and release identifier
|
- Commit and release identifier
|
||||||
|
|||||||
Reference in New Issue
Block a user