mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user