perf(history): reduce export polling load
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, parseHistoryKeywords } from './history';
|
||||
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, HISTORY_EXPORT_POLL_MS, historyExportPollInterval, parseHistoryKeywords } from './history';
|
||||
|
||||
describe('history domain', () => {
|
||||
it('parses and bounds multi-vehicle input', () => {
|
||||
@@ -41,4 +41,12 @@ describe('history domain', () => {
|
||||
expect(formatExportFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatExportFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
});
|
||||
|
||||
it('polls active export jobs conservatively and stops after terminal states', () => {
|
||||
expect(historyExportPollInterval([{ status: 'running' }])).toBe(HISTORY_EXPORT_POLL_MS.running);
|
||||
expect(historyExportPollInterval([{ status: 'queued' }])).toBe(HISTORY_EXPORT_POLL_MS.queued);
|
||||
expect(historyExportPollInterval([{ status: 'queued' }, { status: 'running' }])).toBe(HISTORY_EXPORT_POLL_MS.running);
|
||||
expect(historyExportPollInterval([{ status: 'completed' }, { status: 'failed' }])).toBe(false);
|
||||
expect(historyExportPollInterval()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { HistoryDataRow, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types';
|
||||
import type { HistoryDataRow, HistoryExportJob, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types';
|
||||
|
||||
export const HISTORY_EXPORT_POLL_MS = {
|
||||
running: 3_000,
|
||||
queued: 5_000
|
||||
} as const;
|
||||
|
||||
export function historyExportPollInterval(jobs?: Array<Pick<HistoryExportJob, 'status'>>) {
|
||||
if (jobs?.some((job) => job.status === 'running')) return HISTORY_EXPORT_POLL_MS.running;
|
||||
if (jobs?.some((job) => job.status === 'queued')) return HISTORY_EXPORT_POLL_MS.queued;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseHistoryKeywords(value: string) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -41,7 +41,8 @@ function historySeries(vin: string, plate: string, asOf: string): HistorySeriesR
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00']}><HistoryPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00']}><HistoryPage /></MemoryRouter></QueryClientProvider>);
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => {
|
||||
@@ -76,3 +77,17 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
|
||||
expect(await screen.findByText('NEWVIN-evidence')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('releases export job state immediately after leaving the history page', async () => {
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historyExports.mockResolvedValue([{ id: 'export-running', name: '运行中导出', status: 'running', progress: 30, format: 'csv', category: 'location', keywords: ['OLDVIN'], rowCount: 0, totalRows: 100, processedRows: 30, fileSizeBytes: 0, createdAt: '2026-07-16T04:00:00Z', updatedAt: '2026-07-16T04:00:01Z', evidence: 'test export' }]);
|
||||
|
||||
const { client, unmount } = renderPage();
|
||||
expect(await screen.findByText('运行中导出')).toBeInTheDocument();
|
||||
expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeDefined();
|
||||
|
||||
unmount();
|
||||
await waitFor(() => expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeUndefined());
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
@@ -42,7 +42,13 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
|
||||
}
|
||||
|
||||
function ExportJobsPanel() {
|
||||
const query = useQuery({ queryKey: ['history-exports'], queryFn: ({ signal }) => api.historyExports(signal), refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
|
||||
const query = useQuery({
|
||||
queryKey: ['history-exports'],
|
||||
queryFn: ({ signal }) => api.historyExports(signal),
|
||||
refetchInterval: (current) => historyExportPollInterval(current.state.data),
|
||||
refetchIntervalInBackground: false,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const jobs = query.data ?? [];
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload />下载</a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">尚未创建导出任务</div> : null}</div></section>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user