feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
@@ -1,60 +1,50 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, onMounted, onUnmounted } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AdminOperationsDashboard from '../AdminOperationsDashboard.vue'
const source = readFileSync(
resolve(process.cwd(), 'src/views/admin/AdminOperationsDashboard.vue'),
'utf8',
)
describe('AdminOperationsDashboard refresh behavior', () => {
it('refreshes on entry and supports toggling automatic refresh', () => {
expect(source).not.toContain('手动刷新')
expect(source).toContain('@click="toggleAutoRefresh"')
expect(source).toContain("autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'")
expect(source).toContain('onMounted(() => {')
expect(source).toContain('void refreshAll()')
expect(source).toContain('const AUTO_REFRESH_INTERVAL = 10_000')
expect(source).toContain('autoRefreshTimer = setInterval(')
expect(source).toContain('clearInterval(autoRefreshTimer)')
const rangeWatcher = source
.split('watch(timeRange, () => {')[1]
?.split('}, { deep: true })')[0]
expect(rangeWatcher).toBeTruthy()
expect(rangeWatcher).toContain('if (autoRefresh.value)')
expect(rangeWatcher).toContain('refreshAll()')
vi.mock('@/features/overview/components/OverviewToolbar.vue', () => ({ default: { render: () => null } }))
const lifecycle = vi.hoisted(() => ({ mounted: [] as string[], unmounted: [] as string[] }))
function view(name: string) {
return defineComponent({
setup(_, { slots }) {
onMounted(() => lifecycle.mounted.push(name))
onUnmounted(() => lifecycle.unmounted.push(name))
return () => h('div', { 'data-view': name }, [name, slots.default?.({ snapshot: { node_id: 'live-node' } }), slots.details?.(), slots.diagnostics?.()])
},
})
}
vi.mock('@/features/overview/operations/RuntimeView.vue', () => ({ default: view('runtime') }))
vi.mock('@/features/overview/operations/PerformanceView.vue', () => ({ default: view('performance') }))
vi.mock('@/features/overview/operations/LiveMetrics.vue', () => ({ default: view('details') }))
vi.mock('@/features/overview/operations/RuntimeFocus.vue', () => ({ default: { render: () => null } }))
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
it('does not request the heavyweight system-status fallback', () => {
expect(source).not.toContain('monitoringApi.getSystemStatus()')
})
describe('operations view lifecycle', () => {
afterEach(() => vi.useRealTimers())
it('forces fresh analytics and renders each result as soon as it settles', () => {
expect(source).toContain('adminApi.getTimeSeries(params, { skipCache: true })')
expect(source).toContain('adminApi.getPercentiles(params, { skipCache: true })')
expect(source).toContain('}, { skipCache: true })')
expect(source).toContain('adminApi.getErrorDistribution(params, { skipCache: true })')
expect(source).toContain('include_timeline: false')
const progressiveSetup = source.split('const results = await Promise.allSettled([')[0]
expect(progressiveSetup).toContain('timeSeries.value = value')
expect(progressiveSetup).toContain('percentiles.value = value')
expect(progressiveSetup).toContain('providerPerformance.value = value')
expect(progressiveSetup).toContain('errorDistribution.value = value.distribution')
expect(progressiveSetup).toContain('gatewayMetrics.value = value')
})
it('reuses analytics responses instead of requesting a duplicate usage summary', () => {
expect(source).not.toContain('usageApi.getUsageStats(')
expect(source).not.toContain('summaryStats')
expect(source).toContain('function seriesTokenTotal(')
expect(source).toContain('numeric(item.input_tokens)')
expect(source).toContain('numeric(item.output_tokens)')
expect(source).toContain('numeric(item.cache_creation_tokens)')
expect(source).toContain('numeric(item.cache_read_tokens)')
expect(source).toContain('timeSeries.value.map(seriesTokenTotal)')
expect(source).toContain('label="已分类错误"')
expect(source).toContain('errorDistribution.value.reduce(')
it('keeps every section mounted regardless of legacy view query values or browser history', async () => {
lifecycle.mounted.length = 0
lifecycle.unmounted.length = 0
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/operations', component: AdminOperationsDashboard }] })
await router.push('/admin/operations?view=performance&from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
const root = document.createElement('div')
const app = createApp(AdminOperationsDashboard).use(router)
app.mount(root)
expect([...lifecycle.mounted].sort()).toEqual(['details', 'details', 'performance', 'runtime'])
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('[role="tab"]')).toBeNull()
await router.push({ query: { ...router.currentRoute.value.query, view: 'resources' } })
await settle()
expect(lifecycle.mounted).toHaveLength(4)
expect(lifecycle.unmounted).toEqual([])
expect(router.currentRoute.value.query.from).toBe('2026-09-10T23:30:00Z')
router.back()
await settle()
for (const name of ['runtime', 'performance']) expect(root.querySelector(`[data-view="${name}"]`)).not.toBeNull()
expect(root.querySelectorAll('[data-view="details"]')).toHaveLength(2)
expect(lifecycle.unmounted).toEqual([])
app.unmount()
expect([...lifecycle.unmounted].sort()).toEqual(['details', 'details', 'performance', 'runtime'])
})
})
@@ -0,0 +1,319 @@
import { createApp, nextTick, type App } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AdminOperationsDashboard from '../AdminOperationsDashboard.vue'
import { setI18nLocale } from '@/i18n'
const api = vi.hoisted(() => ({ summary: vi.fn(), timeseries: vi.fn(), live: vi.fn(), performance: vi.fn(), resources: vi.fn() }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/components/common/TimeRangePicker.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
props: {
modelValue: { type: Object, required: true },
presetOnly: Boolean,
showGranularity: Boolean,
presetOptions: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup: (props, { emit }) => () => h('select', {
'aria-label': '时间范围',
'data-preset-only': String(props.presetOnly),
'data-show-granularity': String(props.showGranularity),
value: props.modelValue.preset || 'custom',
onChange: (event: Event) => emit('update:modelValue', { preset: (event.target as HTMLSelectElement).value, granularity: 'day' }),
}, [
h('option', { value: 'custom', disabled: true }, '已选时段'),
...props.presetOptions.map(preset => h('option', { value: String(preset) }, String(preset))),
]),
}) }
})
vi.mock('@/features/overview/components/OverviewMetrics.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { metrics: { type: Object, required: true } }, setup: props => () => h('p', { 'data-request-count': '' }, String(props.metrics.request_count)) }) }
})
vi.mock('@/features/overview/components/OverviewTrend.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/operations/LiveMetrics.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { snapshot: { type: Object, required: true }, resources: Boolean }, setup: props => () => h('p', { 'data-live-node': props.resources ? 'resources' : 'runtime' }, props.snapshot.node_id) }) }
})
vi.mock('@/features/overview/components/OverviewStatus.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { error: String }, setup: props => () => props.error ? h('p', { role: 'alert' }, props.error) : null }) }
})
const mounted: App[] = []
async function settle() { await vi.advanceTimersByTimeAsync(0); await nextTick() }
async function mount(url = '/admin/operations?timezone=UTC') {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/operations', component: AdminOperationsDashboard }, { path: '/admin/usage', component: { render: () => null } }] })
await router.push(url)
const root = document.createElement('div')
const app = createApp(AdminOperationsDashboard).use(router)
app.mount(root)
mounted.push(app)
await settle()
return { root, router, app }
}
function change(root: HTMLElement, selector: string, value: string) {
const input = root.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
input.value = value
input.dispatchEvent(new Event(input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
}
function liveNodes(root: HTMLElement) { return Array.from(root.querySelectorAll('[data-live-node]'), node => node.textContent) }
function enableAutoRefresh(root: HTMLElement) {
const button = root.querySelector<HTMLButtonElement>('button[aria-label="开启自动刷新(每 10 秒)"]')!
expect(button.getAttribute('aria-pressed')).toBe('false')
button.click()
}
function disableAutoRefresh(root: HTMLElement) {
const button = root.querySelector<HTMLButtonElement>('button[aria-label="关闭自动刷新(每 10 秒)"]')!
expect(button.getAttribute('aria-pressed')).toBe('true')
button.click()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-18T08:00:00Z'))
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
setI18nLocale('zh-CN')
vi.clearAllMocks()
api.summary.mockResolvedValue({ data: { request_count: 42 } })
api.timeseries.mockResolvedValue({ data: { items: [] } })
api.live.mockResolvedValue({ data: { node_id: 'live-node' } })
api.performance.mockResolvedValue({ data: { summary: { request_count: 42 }, timeseries: [], providers: null, errors: [] } })
api.resources.mockResolvedValue({ data: { node_id: 'resource-node' } })
})
afterEach(() => { for (const app of mounted.splice(0)) app.unmount(); vi.restoreAllMocks(); vi.useRealTimers() })
describe('operations refresh behavior', () => {
it('passes grouped live load into both analysis tables and updates it using the existing two refresh queries', async () => {
const executionActivity = {
observed_at: '2026-09-18T08:00:00Z', observed_from: '2026-09-18T07:59:00Z',
window_seconds: 60, observed_window_seconds: 60, scope: { kind: 'node' }, coverage: 'complete',
providers: [{ provider_id: 'provider-a', provider: 'Provider A', requests_per_minute: 12, current_concurrency: 2 }],
models: [{ model: 'model-a', requests_per_minute: 10, current_concurrency: 1 }],
}
api.live.mockResolvedValue({ data: { node_id: 'live-node', execution_activity: executionActivity } })
api.performance.mockResolvedValue({ data: {
summary: { request_count: 42 }, timeseries: [], providers: { providers: [] }, models: [], errors: [],
} })
const { root } = await mount()
const values = (dimension: string) => {
const row = root.querySelector(`[data-performance-analysis="${dimension}"] [data-analysis-row]`)!
return [row.querySelector('[data-analysis-concurrency]')?.textContent?.trim(), row.querySelector('[data-analysis-rpm]')?.textContent?.trim()]
}
expect(values('provider')).toEqual(['2', '12'])
expect(values('model')).toEqual(['1', '10'])
api.live.mockResolvedValue({ data: { node_id: 'live-node', execution_activity: {
...executionActivity,
providers: [{ ...executionActivity.providers[0], requests_per_minute: 21, current_concurrency: 4 }],
models: [{ ...executionActivity.models[0], requests_per_minute: 18, current_concurrency: 3 }],
} } })
enableAutoRefresh(root)
await settle()
expect(values('provider')).toEqual(['4', '21'])
expect(values('model')).toEqual(['3', '18'])
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
for (const method of [api.resources, api.summary, api.timeseries]) expect(method).not.toHaveBeenCalled()
})
it('starts disabled, refreshes immediately when enabled and polls every ten seconds from that click without duplicate queries', async () => {
const { root } = await mount('/admin/operations?view=resources&timezone=UTC')
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('[role="tab"]')).toBeNull()
expect(root.querySelector('input[type="checkbox"]')).toBeNull()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(root.querySelector('[data-live-node="runtime"]')?.textContent).toBe('live-node')
expect(root.querySelector('[data-live-node="resources"]')?.textContent).toBe('live-node')
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(12_345)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
enableAutoRefresh(root)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
expect(root.querySelector('button[aria-label="关闭自动刷新(每 10 秒)"]')?.getAttribute('aria-pressed')).toBe('true')
await vi.advanceTimersByTimeAsync(9_999)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(3)
expect(api.resources).not.toHaveBeenCalled()
expect(api.summary).not.toHaveBeenCalled()
expect(api.timeseries).not.toHaveBeenCalled()
})
it('stops polling without a request when disabled and does not accumulate timers when toggled repeatedly', async () => {
const interval = vi.spyOn(globalThis, 'setInterval')
const clear = vi.spyOn(globalThis, 'clearInterval')
const { root } = await mount()
enableAutoRefresh(root)
await settle()
const firstTimer = interval.mock.results[interval.mock.results.length - 1]?.value
disableAutoRefresh(root)
await settle()
expect(clear).toHaveBeenCalledWith(firstTimer)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(20_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
for (let index = 0; index < 3; index += 1) {
enableAutoRefresh(root)
await settle()
await vi.advanceTimersByTimeAsync(1_000)
disableAutoRefresh(root)
await settle()
}
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(5)
enableAutoRefresh(root)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(9_999)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(1)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(7)
})
it('clears the active interval when leaving the page', async () => {
const interval = vi.spyOn(globalThis, 'setInterval')
const clear = vi.spyOn(globalThis, 'clearInterval')
const { root, app } = await mount()
enableAutoRefresh(root)
await settle()
const activeTimer = interval.mock.results[interval.mock.results.length - 1]?.value
app.unmount()
mounted.splice(mounted.indexOf(app), 1)
expect(clear).toHaveBeenCalledWith(activeTimer)
await vi.advanceTimersByTimeAsync(30_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
})
it('skips polling while the page is hidden and resumes at the next visible interval', async () => {
const { root } = await mount()
enableAutoRefresh(root)
await settle()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
await vi.advanceTimersByTimeAsync(20_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
await vi.advanceTimersByTimeAsync(10_000)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(3)
})
it('loads performance even when the initial live snapshot fails', async () => {
api.live.mockRejectedValue(new Error('live unavailable'))
const { root } = await mount()
expect(root.textContent).toContain('live unavailable')
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual([])
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
expect(api.resources).not.toHaveBeenCalled()
})
it('advances relative ranges when refreshing and changing the time preset while ignoring legacy analysis filters', async () => {
const { root, router } = await mount()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-18T00:00:00.000Z', to: '2026-09-18T08:00:00.000Z' })
vi.setSystemTime(new Date('2026-09-18T08:30:00Z'))
enableAutoRefresh(root)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T08:30:00.000Z')
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T08:30:10.000Z')
const callsBeforeLegacyFilters = api.performance.mock.calls.length
await router.replace({ query: {
...router.currentRoute.value.query,
provider_id: 'legacy-provider', model: 'legacy-model', is_stream: 'false',
api_format: 'openai', slow_threshold_ms: '7500',
} })
await settle()
expect(api.performance).toHaveBeenCalledTimes(callsBeforeLegacyFilters)
expect(root.querySelector('form, input')).toBeNull()
vi.setSystemTime(new Date('2026-09-18T08:59:50Z'))
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T09:00:00.000Z')
for (const key of ['provider_id', 'model', 'is_stream', 'api_format', 'slow_threshold_ms']) {
expect(api.performance.mock.lastCall?.[0][key]).toBeUndefined()
}
change(root, 'select[aria-label="时间范围"]', 'last1hour')
await settle()
vi.setSystemTime(new Date('2026-09-18T09:29:50Z'))
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-18T08:30:00.000Z', to: '2026-09-18T09:30:00.000Z' })
expect(router.currentRoute.value.query.relative_preset).toBe('last1hour')
})
it('keeps explicit historical ranges fixed when enabling refresh and polling', async () => {
const { root } = await mount('/admin/operations?from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
vi.setSystemTime(new Date('2026-09-18T09:00:00Z'))
enableAutoRefresh(root)
await settle()
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.calls.length).toBeGreaterThan(1)
for (const [query] of api.performance.mock.calls) expect(query).toMatchObject({ from: '2026-09-10T23:30:00.000Z', to: '2026-09-11T00:30:00.000Z' })
})
it('uses the shared preset picker without exact time, custom date or timezone controls', async () => {
const { root } = await mount()
const picker = root.querySelector<HTMLSelectElement>('select[aria-label="时间范围"]')!
expect(picker.dataset.presetOnly).toBe('true')
expect(picker.dataset.showGranularity).toBe('false')
expect(Array.from(picker.options).filter(option => !option.disabled).map(option => option.value)).toEqual(['last1hour', 'today', 'last24hours', 'last7days', 'last30days'])
expect(root.querySelector('button[aria-label="精确时间"]')).toBeNull()
expect(root.querySelector('input[type="datetime-local"], input[type="date"]')).toBeNull()
expect(root.querySelector('input[list="overview-timezones"]')).toBeNull()
})
it('restores rolling refresh when choosing a preset from an explicit historical range', async () => {
const { root, router } = await mount('/admin/operations?from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
expect(root.querySelector<HTMLSelectElement>('select[aria-label="时间范围"]')!.value).toBe('custom')
expect(router.currentRoute.value.query.relative_preset).toBeUndefined()
change(root, 'select[aria-label="时间范围"]', 'last24hours')
await settle()
expect(router.currentRoute.value.query.relative_preset).toBe('last24hours')
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T08:00:00.000Z', to: '2026-09-18T08:00:00.000Z' })
vi.setSystemTime(new Date('2026-09-18T09:00:00Z'))
enableAutoRefresh(root)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T09:00:00.000Z', to: '2026-09-18T09:00:00.000Z' })
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T09:00:10.000Z', to: '2026-09-18T09:00:10.000Z' })
})
it('retains data on a failed refresh and ignores legacy filters but clears performance results for a different time preset', async () => {
const { root, router } = await mount()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
api.live.mockRejectedValue(new Error('live unavailable'))
api.performance.mockRejectedValue(new Error('performance unavailable'))
vi.setSystemTime(new Date('2026-09-18T08:30:00Z'))
enableAutoRefresh(root)
await settle()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
for (const message of ['live unavailable', 'performance unavailable']) expect(root.textContent).toContain(message)
await router.replace({ query: { ...router.currentRoute.value.query, model: 'legacy-model' } })
await settle()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(api.performance).toHaveBeenCalledTimes(2)
change(root, 'select[aria-label="时间范围"]', 'last1hour')
await settle()
expect(root.querySelector('[data-request-count]')).toBeNull()
expect(api.performance).toHaveBeenCalledTimes(3)
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
expect(root.textContent).toContain('performance unavailable')
expect(api.live).toHaveBeenCalledTimes(2)
expect(api.resources).not.toHaveBeenCalled()
})
})
@@ -1,41 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/views/admin/PerformanceAnalysis.vue'),
'utf8',
)
describe('PerformanceAnalysis progressive live data', () => {
it('commits each live response before the full request group settles', () => {
const liveDataBlock = source
.split('async function loadLiveData')[1]
?.split('const errorTrendChartData')[0]
expect(liveDataBlock).toBeTruthy()
expect(liveDataBlock).toContain('function commitIfCurrent<T>')
expect(liveDataBlock).toContain('if (requestId !== liveRequestId) return')
expect(liveDataBlock).toContain('systemStatus.value = value')
expect(liveDataBlock).toContain('resilienceStatus.value = value')
expect(liveDataBlock).toContain('circuitHistory.value = value.items')
expect(liveDataBlock).toContain('gatewayMetrics.value = value')
expect(liveDataBlock).toContain('liveReady.value = true')
expect(liveDataBlock).not.toContain('systemStatus.value = systemResult.value')
expect(liveDataBlock).not.toContain('resilienceStatus.value = resilienceResult.value')
expect(liveDataBlock).not.toContain('gatewayMetrics.value = metricsResult.value')
})
it('keeps group completion for loading state and error aggregation', () => {
const liveDataBlock = source
.split('async function loadLiveData')[1]
?.split('const errorTrendChartData')[0]
expect(liveDataBlock).toContain('await Promise.allSettled([')
expect(liveDataBlock).toContain("failedScopes.push('系统状态')")
expect(liveDataBlock).toContain("failedScopes.push('韧性状态')")
expect(liveDataBlock).toContain("failedScopes.push('熔断历史')")
expect(liveDataBlock).toContain("failedScopes.push('网关指标')")
expect(liveDataBlock).toContain('liveLoading.value = false')
expect(liveDataBlock).toContain('liveRefreshing.value = false')
})
})
@@ -3,7 +3,7 @@ import {
buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers'
} from '@/features/overview/operations/performanceHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
describe('performanceAnalysisHelpers', () => {