diff --git a/src/components/tables/DeploymentTable.vue b/src/components/tables/DeploymentTable.vue index b3c97316af..9237e012b0 100644 --- a/src/components/tables/DeploymentTable.vue +++ b/src/components/tables/DeploymentTable.vue @@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { toast } from 'vue-sonner' import { formatDate } from '~/services/date' +import { getTimeWindowPageRange } from '~/services/dateRange' import { getLogDocUrl } from '~/services/logDocLinks' import { defaultApiHost, useSupabase } from '~/services/supabase' @@ -46,15 +47,8 @@ const paginatedRange = computed(() => { const rangeStart = range.value ? range.value[0].getTime() : undefined const rangeEnd = range.value ? range.value[1].getTime() : undefined - if (rangeStart && rangeEnd) { - const timeDifference = rangeEnd - rangeStart - const pageTimeOffset = timeDifference * (currentPage.value - 1) - - return { - rangeStart: rangeStart + pageTimeOffset, - rangeEnd: rangeEnd + pageTimeOffset, - } - } + if (rangeStart !== undefined && rangeEnd !== undefined) + return getTimeWindowPageRange(rangeStart, rangeEnd, currentPage.value) return { rangeStart, @@ -169,10 +163,9 @@ columns.value = [ }, ] -async function reload() { +// TableLog emits `reload` only from "Load older" after decrementing currentPage. +async function loadOlder() { try { - currentPage.value = 1 - elements.value.length = 0 await getData() } catch (error) { @@ -230,7 +223,7 @@ watch(range, async () => { :auto-reload="false" :app-id="props.appId ?? ''" :search-placeholder="t('search-by-version')" - @reload="reload()" @reset="refreshData()" + @reload="loadOlder()" @reset="refreshData()" /> diff --git a/src/components/tables/DeviceTable.vue b/src/components/tables/DeviceTable.vue index 447c72d7a6..62e37e9460 100644 --- a/src/components/tables/DeviceTable.vue +++ b/src/components/tables/DeviceTable.vue @@ -9,7 +9,12 @@ import { toast } from 'vue-sonner' import IconSmartphone from '~icons/lucide/smartphone' import DateRangePicker from '~/components/DateRangePicker.vue' import { formatDate } from '~/services/date' -import { getDateRangeForPreset, TABLE_DATE_RANGE_DEFAULT } from '~/services/dateRange' +import { + getDateRangeForPreset, + getTableDateRangeSignature, + shouldRecountOnTableReload, + TABLE_DATE_RANGE_DEFAULT, +} from '~/services/dateRange' import { defaultApiHost, useSupabase } from '~/services/supabase' const props = defineProps<{ @@ -38,6 +43,7 @@ const search = ref('') const elements = ref([]) const isLoading = ref(true) const currentPage = ref(1) +const previousPage = ref(1) const nextCursor = ref(undefined) const hasMore = ref(false) const pageStartCursor = ref>(new Map([[1, undefined]])) @@ -82,6 +88,13 @@ function openDateRangePicker(event: MouseEvent) { dateRangePickerRef.value?.togglePicker(event.currentTarget as HTMLElement) } +function onDateRangeApply(payload: { start: Date, end: Date, mode: DateRangePreset }) { + // Apply payload first so refresh does not race v-model flush and keep the old window. + dateRangeMode.value = payload.mode + dateRange.value = [payload.start, payload.end] + void refreshData() +} + function clearDeviceViewFilters(clearFilters: () => void) { cancelScheduledReload() clearFilters() @@ -143,13 +156,8 @@ function getSearchTerm() { } function getDateRangePayload() { - if (dateRangeMode.value !== 'custom') { - const rolling = getDateRangeForPreset(dateRangeMode.value) - return { - updated_at_gt: rolling.start.toISOString(), - updated_at_lte: rolling.end.toISOString(), - } - } + // Always use the frozen session bounds. Recomputing rolling presets from + // `now` on each page fetch desyncs API filters from cached cursors. if (!dateRange.value) return {} return { @@ -158,6 +166,13 @@ function getDateRangePayload() { } } +function snapRollingDateRangeBounds() { + if (dateRangeMode.value === 'custom') + return + const rolling = getDateRangeForPreset(dateRangeMode.value) + dateRange.value = [rolling.start, rolling.end] +} + function getVersionNameFilter() { const selected = selectedVersionName.value.trim() return selected || undefined @@ -177,7 +192,8 @@ function getQuerySignature() { override: filters.value.Override, customIdMode: filters.value.CustomId, ids: props.ids ? [...props.ids].sort().join(',') : '', - dateRange: getDateRangePayload(), + // Stable mode identity — not rolling ISO bounds that move every millisecond. + dateRange: getTableDateRangeSignature(dateRangeMode.value, dateRange.value), }) } @@ -288,24 +304,47 @@ function clearPaginationState() { hasMore.value = false } +function resetTablePagination(options: { snapRolling?: boolean } = {}) { + if (options.snapRolling) + snapRollingDateRangeBounds() + currentPage.value = 1 + previousPage.value = 1 + clearPaginationState() + elements.value.length = 0 + lastQuerySignature.value = getQuerySignature() +} + async function reload() { const loadId = ++activeLoadId.value isLoading.value = true try { + const requestedPage = currentPage.value const querySignature = getQuerySignature() - if (lastQuerySignature.value !== querySignature) { - lastQuerySignature.value = querySignature - currentPage.value = 1 - clearPaginationState() - elements.value.length = 0 + const filtersChanged = lastQuerySignature.value !== querySignature + const shouldRecount = shouldRecountOnTableReload({ + filtersChanged, + previousPage: previousPage.value, + requestedPage, + }) + if (filtersChanged) { + // Keep frozen date bounds; only drop cursors / page for the new filters. + resetTablePagination() } - const newTotal = await countDevices() - if (loadId !== activeLoadId.value) - return + if (shouldRecount) { + // Toolbar reload (not a page change): snap rolling bounds and drop + // cursors so the new window cannot reuse stale page offsets. + if (!filtersChanged) + resetTablePagination({ snapRolling: true }) + const newTotal = await countDevices() + if (loadId !== activeLoadId.value) + return + total.value = newTotal + } - total.value = newTotal await getData(loadId) + if (loadId === activeLoadId.value) + previousPage.value = currentPage.value } catch (error) { console.error(error) @@ -321,16 +360,15 @@ async function refreshData() { const loadId = ++activeLoadId.value isLoading.value = true try { - currentPage.value = 1 - lastQuerySignature.value = getQuerySignature() - clearPaginationState() - elements.value.length = 0 + resetTablePagination({ snapRolling: true }) const newTotal = await countDevices() if (loadId !== activeLoadId.value) return total.value = newTotal await getData(loadId) + if (loadId === activeLoadId.value) + previousPage.value = currentPage.value } catch (error) { console.error(error) @@ -587,7 +625,7 @@ watch([selectedPlatform, selectedVersionName], () => { v-model="dateRange" v-model:mode="dateRangeMode" compact - @apply="refreshData()" + @apply="onDateRangeApply" /> diff --git a/src/services/dateRange.ts b/src/services/dateRange.ts index 2895c64586..2878100900 100644 --- a/src/services/dateRange.ts +++ b/src/services/dateRange.ts @@ -193,3 +193,54 @@ export function serializeDateRangeQuery( return { range: DEFAULT_DATE_RANGE_PRESET } return { range: mode } } + +/** + * Stable filter identity for table reloads. + * Rolling presets must NOT embed `now`-based timestamps — those change every + * call and would reset cursor pagination back to page 1 on each next click. + */ +export function getTableDateRangeSignature( + mode: DateRangePreset, + customRange?: [Date, Date] | null, +): { mode: DateRangePreset, start?: string, end?: string } { + if (mode !== 'custom') + return { mode } + if (!customRange?.[0] || !customRange?.[1]) + return { mode: 'custom' } + return { + mode: 'custom', + start: customRange[0].toISOString(), + end: customRange[1].toISOString(), + } +} + +/** + * Whether a devices/logs table reload should re-run the expensive count query. + * Page-only navigation keeps the cached total so 100k+ device apps stay usable. + */ +export function shouldRecountOnTableReload(options: { + filtersChanged: boolean + previousPage: number + requestedPage: number +}): boolean { + if (options.filtersChanged) + return true + return options.previousPage === options.requestedPage +} + +/** + * Time-window pagination used by logs/deployments "Load older". + * Page 1 is the selected range; page 0 / -1 / … shift one full window backward. + */ +export function getTimeWindowPageRange( + rangeStartMs: number, + rangeEndMs: number, + page: number, +): { rangeStart: number, rangeEnd: number } { + const timeDifference = rangeEndMs - rangeStartMs + const pageTimeOffset = timeDifference * (page - 1) + return { + rangeStart: rangeStartMs + pageTimeOffset, + rangeEnd: rangeEndMs + pageTimeOffset, + } +} diff --git a/tests/date-range-query.unit.test.ts b/tests/date-range-query.unit.test.ts index c3418f82f7..d70db00ad2 100644 --- a/tests/date-range-query.unit.test.ts +++ b/tests/date-range-query.unit.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { + getTableDateRangeSignature, + getTimeWindowPageRange, parseDateRangeQuery, serializeDateRangeQuery, + shouldRecountOnTableReload, } from '../src/services/dateRange' describe('date range query parse/serialize', () => { @@ -48,4 +51,59 @@ describe('date range query parse/serialize', () => { end: '2026-03-15T00:00:00.000Z', }) }) + + it.concurrent('keeps rolling table signatures stable across wall-clock ticks', () => { + expect(getTableDateRangeSignature('30min')).toEqual({ mode: '30min' }) + expect(getTableDateRangeSignature('30day')).toEqual({ mode: '30day' }) + expect(getTableDateRangeSignature('30min')).toEqual(getTableDateRangeSignature('30min')) + }) + + it.concurrent('includes custom bounds in the table signature', () => { + const start = new Date('2026-03-01T00:00:00.000Z') + const end = new Date('2026-03-15T00:00:00.000Z') + expect(getTableDateRangeSignature('custom', [start, end])).toEqual({ + mode: 'custom', + start: '2026-03-01T00:00:00.000Z', + end: '2026-03-15T00:00:00.000Z', + }) + expect(getTableDateRangeSignature('custom', null)).toEqual({ mode: 'custom' }) + }) + + it.concurrent('skips recount on page-only navigation and recounts on filter or reload', () => { + expect(shouldRecountOnTableReload({ + filtersChanged: false, + previousPage: 1, + requestedPage: 2, + })).toBe(false) + expect(shouldRecountOnTableReload({ + filtersChanged: true, + previousPage: 2, + requestedPage: 2, + })).toBe(true) + expect(shouldRecountOnTableReload({ + filtersChanged: false, + previousPage: 3, + requestedPage: 3, + })).toBe(true) + }) + + it.concurrent('shifts logs Load older windows backward without resetting page 1', () => { + const start = Date.parse('2026-08-08T12:00:00.000Z') + const end = Date.parse('2026-08-08T12:30:00.000Z') + expect(getTimeWindowPageRange(start, end, 1)).toEqual({ + rangeStart: start, + rangeEnd: end, + }) + expect(getTimeWindowPageRange(start, end, 0)).toEqual({ + rangeStart: Date.parse('2026-08-08T11:30:00.000Z'), + rangeEnd: Date.parse('2026-08-08T12:00:00.000Z'), + }) + }) + + it.concurrent('keeps epoch zero as a valid older-page window bound', () => { + expect(getTimeWindowPageRange(0, 60_000, 0)).toEqual({ + rangeStart: -60_000, + rangeEnd: 0, + }) + }) })