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:
DaydreamCoding
2026-05-30 18:56:08 +08:00
committed by QTom
co-authored by Claude Opus 4.8
parent b60d8bb4cc
commit bf24b61139
10 changed files with 325 additions and 74 deletions
@@ -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'])
})
})