Merge pull request #7841 from ouchihao/codex/bug-alert-events-pagination-race

fix(ops): discard stale alert event pages after filter changes
This commit is contained in:
Wesley Liddick
2026-10-08 11:36:07 +08:00
committed by GitHub
2 changed files with 84 additions and 3 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -22,6 +22,8 @@ const loading = ref(false)
const loadingMore = ref(false)
const events = ref<AlertEvent[]>([])
const hasMore = ref(true)
let listRequestId = 0
onBeforeUnmount(() => { listRequestId++ })
// Detail modal
const showDetail = ref(false)
@@ -92,18 +94,22 @@ function buildQuery(overrides: Partial<AlertEventsQuery> = {}): AlertEventsQuery
}
async function loadFirstPage() {
const requestId = ++listRequestId
loadingMore.value = false
loading.value = true
try {
const data = await opsAPI.listAlertEvents(buildQuery())
if (requestId !== listRequestId) return
events.value = data
hasMore.value = data.length === PAGE_SIZE
} catch (err: any) {
if (requestId !== listRequestId) return
console.error('[OpsAlertEventsCard] Failed to load alert events', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
events.value = []
hasMore.value = false
} finally {
loading.value = false
if (requestId === listRequestId) loading.value = false
}
}
@@ -113,11 +119,13 @@ async function loadMore() {
const last = events.value[events.value.length - 1]
if (!last) return
const requestId = listRequestId
loadingMore.value = true
try {
const data = await opsAPI.listAlertEvents(
buildQuery({ before_fired_at: last.fired_at || last.created_at, before_id: last.id })
)
if (requestId !== listRequestId) return
if (!data.length) {
hasMore.value = false
return
@@ -125,10 +133,11 @@ async function loadMore() {
events.value = [...events.value, ...data]
if (data.length < PAGE_SIZE) hasMore.value = false
} catch (err: any) {
if (requestId !== listRequestId) return
console.error('[OpsAlertEventsCard] Failed to load more alert events', err)
hasMore.value = false
} finally {
loadingMore.value = false
if (requestId === listRequestId) loadingMore.value = false
}
}
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils'
vi.mock('vue-i18n', async (importOriginal) => ({
...await importOriginal<typeof import('vue-i18n')>(),
useI18n: () => ({ t: (key: string) => key }),
}))
enableAutoUnmount(afterEach)
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
import OpsAlertEventsCard from '../OpsAlertEventsCard.vue'
import { ref } from 'vue'
const { listAlertEvents, showError } = vi.hoisted(() => ({ listAlertEvents: vi.fn(), showError: vi.fn() }))
vi.mock('@vueuse/core', () => ({ useMediaQuery: () => ref(true) }))
vi.mock('@/api/admin/ops', () => ({ opsAPI: { listAlertEvents } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError }) }))
const rows = (first: number, count = 10) => Array.from({ length: count }, (_, i) => ({
id: first + i, severity: 'P1', status: 'firing', created_at: '2026-10-03T00:00:00Z',
}))
type View = { events: ReturnType<typeof rows>; loading: boolean; loadingMore: boolean; hasMore: boolean; loadMore: () => Promise<void> }
function mountCard() {
const wrapper = shallowMount(OpsAlertEventsCard)
return { wrapper, vm: wrapper.vm as unknown as View }
}
describe('alert events filtered pagination', () => {
beforeEach(() => { vi.resetAllMocks() })
it.each(['resolve', 'reject'] as const)('ignores a superseded first page that %ss', async outcome => {
const old = deferred<ReturnType<typeof rows>>()
listAlertEvents.mockReturnValueOnce(old.promise).mockResolvedValueOnce(rows(100))
const { wrapper, vm } = mountCard()
wrapper.findAllComponents({ name: 'Select' })[1].vm.$emit('change', 'P2')
await flushPromises()
if (outcome === 'resolve') old.resolve(rows(1))
else old.reject(new Error('obsolete request'))
await flushPromises()
expect(vm.events.map(row => row.id)).toEqual(rows(100).map(row => row.id))
expect(vm.hasMore).toBe(true)
expect(showError).not.toHaveBeenCalled()
})
it.each(['resolve', 'reject'] as const)('ignores an old next page that %ss after changing filters', async outcome => {
const old = deferred<ReturnType<typeof rows>>()
const current = deferred<ReturnType<typeof rows>>()
listAlertEvents.mockResolvedValueOnce(rows(1)).mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(rows(100)).mockReturnValueOnce(current.promise)
const { wrapper, vm } = mountCard()
await flushPromises()
const oldLoad = vm.loadMore()
wrapper.findAllComponents({ name: 'Select' })[1].vm.$emit('change', 'P2')
await flushPromises()
expect(vm.loadingMore).toBe(false)
const nextLoad = vm.loadMore()
if (outcome === 'resolve') old.resolve(rows(11, 1))
else old.reject(new Error('obsolete request'))
await oldLoad
expect(vm.loadingMore).toBe(true)
expect(vm.events.map(row => row.id)).toEqual(rows(100).map(row => row.id))
expect(vm.hasMore).toBe(true)
current.resolve(rows(110, 1))
await nextLoad
expect(vm.events.map(row => row.id)).toEqual([...rows(100), ...rows(110, 1)].map(row => row.id))
expect(vm.hasMore).toBe(false)
})
})