mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
perf(usage): 优化 /admin/usage 打开速度与刷新响应
根因:页面 mount 并发 6 个请求,其中 5 个在原始 usage_logs 上 live 聚合, 且有 1 个重复 getModelStats。优化(不引入预聚合): 前端 - UsageView 经 :model-options 下传 model 列表,移除 UsageFilters 重复的 getModelStats(mount 请求 6→5,少一次 usage_logs 全表 GROUP BY model) - 刷新/换筛选保留旧模型数据(invalidateModelStatsCache 只失效标记不清空数据), 图表不再闪空,刷新期间页面保持可交互 后端 - GetStatsWithFilters 4 条聚合 errgroup 并行(仅 *sql.DB 连接池路径,ent.Tx 顺序回退以保事务内不并发),endpoint 明细 best-effort;抑制取消级联噪声日志 - /admin/usage/stats 复用 dashboard 的 newSnapshotCache 30s 处理器层缓存,按 filters+窗口为 key;前端手动刷新带 nocache=1 强制回源(刷新=最新) 注:自合并提交 4c8396c 迁移而来,仅取"列表打开速度与刷新响应"部分;原提交的 "审计查看弹窗大 body 渲染"改动(AuditLogModal / audit-log-format / 审计 i18n) 依赖尚未迁移的审计功能(d8389ade),本次已排除。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
QTom
co-authored by
Claude Opus 4.8
parent
b60d8bb4cc
commit
bf24b61139
@@ -121,6 +121,7 @@ export async function getStats(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
timezone?: string
|
||||
nocache?: number
|
||||
}): Promise<AdminUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||
params
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||
@@ -182,6 +182,7 @@ interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
modelOptions?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -222,7 +223,10 @@ const accountResults = ref<SimpleAccount[]>([])
|
||||
const showAccountDropdown = ref(false)
|
||||
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||
const modelOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('admin.usage.allModels') },
|
||||
...(props.modelOptions ?? []).map((m) => ({ value: m, label: m })),
|
||||
])
|
||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
|
||||
const requestTypeOptions = ref<SelectOption[]>([
|
||||
@@ -421,26 +425,9 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
|
||||
try {
|
||||
const [gs, ms] = await Promise.all([
|
||||
adminAPI.groups.list(1, 1000),
|
||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
|
||||
])
|
||||
|
||||
const gs = await adminAPI.groups.list(1, 1000)
|
||||
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
||||
|
||||
const uniqueModels = new Set<string>()
|
||||
ms.models?.forEach((s: any) => {
|
||||
if (s.model) {
|
||||
uniqueModels.add(s.model)
|
||||
}
|
||||
})
|
||||
modelOptions.value.push(
|
||||
...Array.from(uniqueModels)
|
||||
.sort()
|
||||
.map((m) => ({ value: m, label: m }))
|
||||
)
|
||||
} catch {
|
||||
// Ignore filter option loading errors (page still usable)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ vi.mock('vue-i18n', async () => {
|
||||
// Mock the admin API module — we control searchUsers return value per test
|
||||
const mockSearchUsers = vi.fn()
|
||||
const mockSearchApiKeys = vi.fn().mockResolvedValue([])
|
||||
const mockGroupsList = vi.fn().mockResolvedValue({ items: [] })
|
||||
const mockGetModelStats = vi.fn().mockResolvedValue({ models: [] })
|
||||
const mockAccountsList = vi.fn().mockResolvedValue({ items: [] })
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
@@ -57,15 +60,9 @@ vi.mock('@/api/admin', () => ({
|
||||
searchUsers: (...args: any[]) => mockSearchUsers(...args),
|
||||
searchApiKeys: (...args: any[]) => mockSearchApiKeys(...args),
|
||||
},
|
||||
groups: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
dashboard: {
|
||||
getModelStats: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
accounts: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
groups: { list: (...args: any[]) => mockGroupsList(...args) },
|
||||
dashboard: { getModelStats: (...args: any[]) => mockGetModelStats(...args) },
|
||||
accounts: { list: (...args: any[]) => mockAccountsList(...args) },
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -91,6 +88,7 @@ function mountFilters(filters = defaultFilters()) {
|
||||
startDate: '2026-05-01',
|
||||
endDate: '2026-05-28',
|
||||
showActions: false,
|
||||
modelOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -166,3 +164,32 @@ describe('UsageFilters — user search dropdown', () => {
|
||||
expect(wrapper.props('modelValue').user_id).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UsageFilters — model options come from prop (no dup request)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockGetModelStats.mockClear()
|
||||
mockGroupsList.mockClear()
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('does not call dashboard.getModelStats on mount and renders model options from prop', async () => {
|
||||
const wrapper = mount(UsageFilters, {
|
||||
props: {
|
||||
modelValue: defaultFilters(),
|
||||
exporting: false,
|
||||
startDate: '2026-05-01',
|
||||
endDate: '2026-05-28',
|
||||
showActions: false,
|
||||
modelOptions: ['claude-3', 'gpt-4o'],
|
||||
},
|
||||
global: { stubs: { Select: true, Teleport: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetModelStats).not.toHaveBeenCalled()
|
||||
|
||||
const opts = (wrapper.vm as any).modelOptions as Array<{ value: string | null; label: string }>
|
||||
expect(opts.map((o) => o.value)).toEqual([null, 'claude-3', 'gpt-4o'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<template #after-reset>
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -192,6 +192,10 @@ const breakdownFilters = computed(() => {
|
||||
return f
|
||||
})
|
||||
|
||||
const modelNameOptions = computed(() =>
|
||||
Array.from(new Set(requestedModelStats.value.map((m) => m.model).filter(Boolean))).sort()
|
||||
)
|
||||
|
||||
const handleUserClick = async (userId: number) => {
|
||||
try {
|
||||
const user = await adminAPI.users.getById(userId, true)
|
||||
@@ -306,13 +310,17 @@ const loadLogs = async () => {
|
||||
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
|
||||
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
|
||||
}
|
||||
const loadStats = async () => {
|
||||
const loadStats = async (force = false) => {
|
||||
const seq = ++statsReqSeq
|
||||
endpointStatsLoading.value = true
|
||||
try {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const s = await adminAPI.usage.getStats({ ...filters.value, stream: legacyStream === null ? undefined : legacyStream })
|
||||
const s = await adminAPI.usage.getStats({
|
||||
...filters.value,
|
||||
stream: legacyStream === null ? undefined : legacyStream,
|
||||
...(force ? { nocache: 1 } : {}),
|
||||
})
|
||||
if (seq !== statsReqSeq) return
|
||||
usageStats.value = s
|
||||
inboundEndpointStats.value = s.endpoints || []
|
||||
@@ -329,10 +337,8 @@ const loadStats = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resetModelStatsCache = () => {
|
||||
requestedModelStats.value = []
|
||||
upstreamModelStats.value = []
|
||||
mappingModelStats.value = []
|
||||
// 失效模型统计缓存:仅标记需要重取,保留旧数据直到新数据到达(避免刷新时图表闪空)。
|
||||
const invalidateModelStatsCache = () => {
|
||||
loadedModelSources.requested = false
|
||||
loadedModelSources.upstream = false
|
||||
loadedModelSources.mapping = false
|
||||
@@ -421,16 +427,16 @@ const loadChartData = async () => {
|
||||
}
|
||||
const applyFilters = () => {
|
||||
pagination.page = 1
|
||||
resetModelStatsCache()
|
||||
invalidateModelStatsCache()
|
||||
loadLogs()
|
||||
loadStats()
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
}
|
||||
const refreshData = () => {
|
||||
resetModelStatsCache()
|
||||
invalidateModelStatsCache()
|
||||
loadLogs()
|
||||
loadStats()
|
||||
loadStats(true)
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
}
|
||||
@@ -611,4 +617,6 @@ onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); do
|
||||
watch(modelDistributionSource, (source) => {
|
||||
void loadModelStats(source)
|
||||
})
|
||||
|
||||
defineExpose({ requestedModelStats, refreshData })
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
|
||||
const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
@@ -15,6 +15,7 @@ const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
|
||||
getStats: vi.fn(),
|
||||
getSnapshotV2: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getModelStats: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,6 +41,7 @@ vi.mock('@/api/admin', () => ({
|
||||
},
|
||||
dashboard: {
|
||||
getSnapshotV2,
|
||||
getModelStats,
|
||||
},
|
||||
users: {
|
||||
getById,
|
||||
@@ -116,6 +118,7 @@ describe('admin UsageView distribution metric toggles', () => {
|
||||
getStats.mockReset()
|
||||
getSnapshotV2.mockReset()
|
||||
getById.mockReset()
|
||||
getModelStats.mockReset()
|
||||
|
||||
list.mockResolvedValue({
|
||||
items: [],
|
||||
@@ -137,12 +140,44 @@ describe('admin UsageView distribution metric toggles', () => {
|
||||
models: [],
|
||||
groups: [],
|
||||
})
|
||||
getModelStats.mockResolvedValue({ models: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps previous model stats visible during refresh until new data arrives', async () => {
|
||||
// 首次加载返回 A
|
||||
getModelStats.mockResolvedValueOnce({ models: [{ model: 'A', total_tokens: 10 }] })
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: { stubs: {
|
||||
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
|
||||
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
|
||||
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
|
||||
ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub,
|
||||
EndpointDistributionChart: true,
|
||||
} },
|
||||
})
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
|
||||
|
||||
// 刷新:让第二次 getModelStats 处于 pending,断言旧数据 A 仍在(不被清空成 [])
|
||||
let resolveSecond: (v: any) => void = () => {}
|
||||
getModelStats.mockReturnValueOnce(new Promise((res) => { resolveSecond = res }))
|
||||
;(wrapper.vm as any).refreshData()
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
|
||||
|
||||
// 新数据到达后替换为 B
|
||||
resolveSecond({ models: [{ model: 'B', total_tokens: 20 }] })
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'B', total_tokens: 20 }])
|
||||
})
|
||||
|
||||
it('keeps model and group metric toggles independent without refetching chart data', async () => {
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
|
||||
Reference in New Issue
Block a user