From 14525887eac05be6544bb6000515621fe9fd50d9 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 04:44:21 +0800 Subject: [PATCH] fix(web): isolate paginated filter scopes --- .../apps/web/src/v2/pages/AccessPage.test.tsx | 57 +++++++++++++++++++ .../apps/web/src/v2/pages/AccessPage.tsx | 7 ++- .../apps/web/src/v2/pages/AlertsPage.test.tsx | 41 ++++++++++++- .../apps/web/src/v2/pages/AlertsPage.tsx | 12 ++-- .../apps/web/src/v2/queryPolicy.test.ts | 8 ++- .../apps/web/src/v2/queryPolicy.ts | 8 +++ .../docs/frontend-production-readiness.md | 14 +++++ 7 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx new file mode 100644 index 00000000..ec8ac8c4 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -0,0 +1,57 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import type { AccessVehicleRow, Page } from '../../api/types'; +import { ROUTER_FUTURE } from '../routing/routerConfig'; +import AccessPage from './AccessPage'; + +const mocks = vi.hoisted(() => ({ + accessSummary: vi.fn(), accessVehicles: vi.fn(), accessUnresolvedIdentities: vi.fn(), + accessThresholds: vi.fn(), updateAccessThresholds: vi.fn() +})); +vi.mock('../../api/client', () => ({ api: mocks })); +vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) })); + +afterEach(() => { + cleanup(); + Object.values(mocks).forEach((mock) => mock.mockReset()); +}); + +function accessRow(vin: string, plate: string): AccessVehicleRow { + return { + vin, plate, oem: '测试品牌', model: '测试车型', company: '测试公司', protocol: 'JT808', provider: '测试厂家', source: 'production', + firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10, + dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, latestMessageType: 'location', latestEventId: `${vin}-event`, latestError: '', delayAbnormal: false, + firstSeenEvidence: 'test first seen', firstSeenSource: 'test', reportIntervalEvidence: 'test interval', reportSampleCount: 10, + expectedProtocols: ['JT808'], actualProtocols: ['JT808'], missingProtocols: [], connectionState: 'healthy', expectationEvidence: 'test expectation', + protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: '测试厂家', firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10, dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, delayAbnormal: false, firstSeenEvidence: 'test first seen', reportIntervalEvidence: 'test interval' }] + }; +} + +function prepareBaseData() { + mocks.accessSummary.mockResolvedValue({ totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, longOfflineVehicles: 0, neverReported: 0, unknownVehicles: 0, delayAbnormal: 0, reportedToday: 2, onlineRate: 100, healthyVehicles: 2, incompleteVehicles: 0, degradedVehicles: 0, protocols: [], oems: [{ name: '测试品牌', total: 2, online: 2, onlineRate: 100 }], asOf: '2026-07-16T04:00:00Z', thresholdVersion: 1 }); + mocks.accessUnresolvedIdentities.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); + mocks.accessThresholds.mockResolvedValue({ version: 1, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: 'test', updatedAt: '2026-07-16T04:00:00Z', audit: [] }); +} + +test('removes old access rows immediately when the vehicle filter scope changes', async () => { + prepareBaseData(); + let resolveNew!: (value: Page) => void; + mocks.accessVehicles.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN' + ? Promise.resolve({ items: [accessRow('OLDVIN', '旧接入车牌')], total: 1, limit: 50, offset: 0 }) + : new Promise>((resolve) => { resolveNew = resolve; })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect(await screen.findByText('旧接入车牌')).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + expect(await screen.findByText('正在更新车辆接入状态…')).toBeInTheDocument(); + expect(screen.queryByText('旧接入车牌')).not.toBeInTheDocument(); + + await act(async () => resolveNew({ items: [accessRow('NEWVIN', '新接入车牌')], total: 1, limit: 50, offset: 0 })); + expect(await screen.findByText('新接入车牌')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('正在更新车辆接入状态…')).not.toBeInTheDocument()); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index d63fd3f1..1fa5a371 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -3,12 +3,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types'; +import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; -import { QUERY_MEMORY } from '../queryPolicy'; +import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; @@ -100,8 +100,9 @@ export default function AccessPage() { const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState(''); const [thresholdDraft, setThresholdDraft] = useState(); const queryClient = useQueryClient(); const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]); + const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]); const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); - const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); + const vehiclesQuery = useQuery>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope>(vehicleScope), gcTime: QUERY_MEMORY.highVolumeGcTime }); const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime }); useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx index 95aebfe1..73b24ee2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; +import type { AlertEvent, Page } from '../../api/types'; import AlertsPage from './AlertsPage'; import { ROUTER_FUTURE } from '../routing/routerConfig'; @@ -18,6 +19,14 @@ afterEach(() => { Object.values(mocks).forEach((mock) => mock.mockReset()); }); +function alertEvent(id: string, vin: string, plate: string): AlertEvent { + return { + id, ruleId: 'speed-rule', ruleName: `${plate}速度告警`, ruleVersion: 1, severity: 'major', status: 'unprocessed', + vin, plate, protocol: 'JT808', metric: 'speed_kmh', operator: 'gt', triggerValue: 90, threshold: 80, thresholdHigh: 0, + unit: 'km/h', durationSec: 60, location: '测试位置', sourceEventId: `${id}-source`, eventAt: '2026-07-16T04:00:00Z', receivedAt: '2026-07-16T04:00:01Z', triggeredAt: '2026-07-16T04:00:00Z', recoveredAt: '', handler: '', version: 1, actions: [] + }; +} + test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => { mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); @@ -62,3 +71,33 @@ test('loads one full notification query on a direct notifications entry', async expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1); expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100'); }); + +test('clears old alert rows and inspector evidence when the event scope changes', async () => { + const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌'); + const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌'); + let resolveNew!: (value: Page) => void; + mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); + mocks.alertEventsV2.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN' + ? Promise.resolve({ items: [oldEvent], total: 1, limit: 20, offset: 0 }) + : new Promise>((resolve) => { resolveNew = resolve; })); + mocks.alertEventV2.mockImplementation((id: string) => Promise.resolve(id === oldEvent.id ? oldEvent : newEvent)); + mocks.alertRulesV2.mockResolvedValue([]); + mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0); + expect(await screen.findByText('old-event')).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('车牌 / VIN / 规则名称'), { target: { value: 'NEWVIN' } }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + expect(await screen.findByText('正在更新事件…')).toBeInTheDocument(); + expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument(); + expect(screen.queryByText('old-event')).not.toBeInTheDocument(); + expect(screen.getByText('选择告警事件')).toBeInTheDocument(); + + await act(async () => resolveNew({ items: [newEvent], total: 1, limit: 20, offset: 0 })); + expect((await screen.findAllByText('新告警车牌')).length).toBeGreaterThan(0); + expect(await screen.findByText('new-event')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('正在更新事件…')).not.toBeInTheDocument()); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 543a33f9..2fd26bfd 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -8,7 +8,7 @@ import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, rule import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister, canOperate } from '../auth/session'; -import { QUERY_MEMORY } from '../queryPolicy'; +import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; type Tab = 'events' | 'rules' | 'notifications'; type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string }; @@ -40,13 +40,15 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on } function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) { - const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient(); + const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selection, setSelection] = useState<{ scope: string; id: string }>(); const [note, setNote] = useState(''); const queryClient = useQueryClient(); const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]); const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]); + const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]); const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: ({ signal }) => api.alertSummaryV2(baseQuery, signal), staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime }); - const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: (previous) => previous, staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); + const events = useQuery>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); const rows = events.data?.items ?? []; - useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]); + const selectedID = selection?.scope === eventScope ? selection.id : ''; + useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, rows, selectedID]); const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime }); const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } }); const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); }; @@ -56,7 +58,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e {summary.isError ? summary.refetch()} /> : null}
{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => )}
{events.isError ? events.refetch()} /> : null} -
告警事件
共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条
严重程度车牌 / VIN规则协议触发时间恢复时间状态触发值阈值位置处理人
{events.isFetching ?
正在更新事件…
: null}{!events.isFetching && !rows.length ?
当前筛选条件没有告警事件
: null}
第 {page} / {totalPages} 页
row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} />
; +
告警事件
共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条
setSelection({ scope: eventScope, id })} />
严重程度车牌 / VIN规则协议触发时间恢复时间状态触发值阈值位置处理人
{events.isFetching ?
正在更新事件…
: null}{!events.isFetching && !rows.length ?
当前筛选条件没有告警事件
: null}
第 {page} / {totalPages} 页
row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} />
; } function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; } diff --git a/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts b/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts index 144b24d6..2c9fcfb8 100644 --- a/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/queryPolicy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { QUERY_MEMORY, retainPreviousPageWithinScope } from './queryPolicy'; +import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from './queryPolicy'; describe('query memory policy', () => { it('drops high-volume route results immediately and bounds reusable small results', () => { @@ -16,4 +16,10 @@ describe('query memory policy', () => { expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=B&date=2026-07-16', 50, 0] })).toBeUndefined(); expect(placeholder(previous)).toBeUndefined(); }); + + it('builds a stable scope key independent of object order and empty values', () => { + expect(queryScopeKey({ status: 'offline', keyword: '粤A 1', limit: undefined, protocol: '' })) + .toBe(queryScopeKey({ protocol: '', keyword: '粤A 1', status: 'offline' })); + expect(queryScopeKey({ status: 'offline', keyword: '粤A 1' })).toBe('keyword=%E7%B2%A4A%201&status=offline'); + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts b/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts index 0c92812f..1cad0dc5 100644 --- a/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts +++ b/vehicle-data-platform/apps/web/src/v2/queryPolicy.ts @@ -7,3 +7,11 @@ export const QUERY_MEMORY = { export function retainPreviousPageWithinScope(scope: string) { return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[1] === scope ? previous : undefined; } + +export function queryScopeKey(query: object) { + return Object.entries(query) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&'); +} diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 0b6f31f0..8377c46a 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -2,6 +2,20 @@ This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. +## 2026-07-16: scope-safe operational tables + +Access Management and Alert Center repeated the history-page placeholder mistake. A new vehicle/protocol/status filter continued rendering the preceding page until the replacement request completed. Alert Center also kept its previous event selection alive, so the inspector could show an old event ID, vehicle and action evidence beside a newly submitted filter. + +Both workspaces now derive a stable, order-independent scope key from their applied filters. Previous rows are retained only when limit or offset changes inside that same scope. A different scope enters the existing loading state with no old rows; the alert selection is bound to the scope as well, so its detail query and inspector disengage immediately. Manual alert refresh also skips the detail request when no event is selected instead of requesting an empty event ID. + +This preserves the smooth pagination behavior described by TanStack Query without extending it across a semantic filter boundary. It also aligns the visible result with the active query, time range and filter controls used by mature Elastic and Grafana dashboards: + +- +- +- + +Delayed-response component tests prove that the old access vehicle, alert row and alert inspector evidence disappear before the new responses resolve, then prove the replacement data renders. Query-policy tests protect stable scope serialization and same-scope-only placeholder reuse. + ## 2026-07-16: scope-safe history transitions The history page previously used every successful detail and trend response as placeholder data for the next query key. Changing the vehicle, time window, protocol or data category could therefore leave the old table, chart legend and selected-row evidence visible under the new filters until both replacement requests completed. Besides retaining the old high-cardinality payload during the transition, this could make an operator attribute evidence to the wrong search scope.