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:02:13 +08:00 committed by QTom
parent b60d8bb4cc
commit bf24b61139
10 changed files with 325 additions and 74 deletions

View File

@ -325,10 +325,24 @@ func (h *UsageHandler) Stats(c *gin.Context) {
EndTime: &endTime,
}
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
var stats *usagestats.UsageStats
// nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
if parseBoolQueryWithDefault(c.Query("nocache"), false) {
s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", "bypass")
} else {
s, hit, err := h.getStatsCached(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
}
response.Success(c, stats)

View File

@ -0,0 +1,62 @@
package admin
import (
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
)
// 与 dashboard 查询缓存同款:30s TTL 进程内缓存,仅服务 /admin/usage/stats 读路径。
var usageStatsCache = newSnapshotCache(30 * time.Second)
type usageStatsCacheKeyData struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
}
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
start := ""
if filters.StartTime != nil {
start = filters.StartTime.UTC().Format(time.RFC3339)
}
end := ""
if filters.EndTime != nil {
end = filters.EndTime.UTC().Format(time.RFC3339)
}
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
})
}
// getStatsCached 命中则返回缓存,未命中则回源 usageService 并写缓存。
func (h *UsageHandler) getStatsCached(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, bool, error) {
key := usageStatsCacheKey(filters)
entry, hit, err := usageStatsCache.GetOrLoad(key, func() (any, error) {
return h.usageService.GetStatsWithFilters(ctx, filters)
})
if err != nil {
return nil, hit, err
}
stats, err := snapshotPayloadAs[*usagestats.UsageStats](entry.Payload)
return stats, hit, err
}

View File

@ -0,0 +1,28 @@
package admin
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/stretchr/testify/require"
)
func TestUsageStatsCacheKey_StableAndDistinct(t *testing.T) {
start := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC)
base := usagestats.UsageLogFilters{StartTime: &start, EndTime: &end, Model: "claude-3"}
k1 := usageStatsCacheKey(base)
k2 := usageStatsCacheKey(base)
require.NotEmpty(t, k1)
require.Equal(t, k1, k2, "same filters must produce same key")
other := base
other.Model = "gpt-4o"
require.NotEqual(t, k1, usageStatsCacheKey(other), "different model must change key")
withUser := base
withUser.UserID = 7
require.NotEqual(t, k1, usageStatsCacheKey(withUser), "different user must change key")
}

View File

@ -27,6 +27,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
gocache "github.com/patrickmn/go-cache"
"golang.org/x/sync/errgroup"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
@ -3538,24 +3539,6 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
stats := &UsageStats{}
var totalAccountCost float64
if err := scanSingleRow(
ctx,
r.sql,
query,
args,
&stats.TotalRequests,
&stats.TotalInputTokens,
&stats.TotalOutputTokens,
&stats.TotalCacheTokens,
&stats.TotalCost,
&stats.TotalActualCost,
&totalAccountCost,
&stats.AverageDurationMs,
); err != nil {
return nil, err
}
stats.TotalAccountCost = &totalAccountCost
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
start := time.Unix(0, 0).UTC()
if filters.StartTime != nil {
@ -3566,21 +3549,76 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
end = *filters.EndTime
}
endpoints, endpointErr := r.GetEndpointStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if endpointErr != nil {
logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", endpointErr)
endpoints = []EndpointStat{}
var endpoints, upstreamEndpoints, endpointPaths []EndpointStat
// 汇总查询:失败即致命。
runSummary := func(c context.Context) error {
return scanSingleRow(
c, r.sql, query, args,
&stats.TotalRequests,
&stats.TotalInputTokens,
&stats.TotalOutputTokens,
&stats.TotalCacheTokens,
&stats.TotalCost,
&stats.TotalActualCost,
&totalAccountCost,
&stats.AverageDurationMs,
)
}
upstreamEndpoints, upstreamEndpointErr := r.GetUpstreamEndpointStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if upstreamEndpointErr != nil {
logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", upstreamEndpointErr)
upstreamEndpoints = []EndpointStat{}
// endpoint 明细:best-effort(失败 log + 返空),不致命。
runEndpoints := func(c context.Context) {
res, err := r.GetEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
endpoints = res
}
endpointPaths, endpointPathErr := r.getEndpointPathStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if endpointPathErr != nil {
logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", endpointPathErr)
endpointPaths = []EndpointStat{}
runUpstream := func(c context.Context) {
res, err := r.GetUpstreamEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
upstreamEndpoints = res
}
runPaths := func(c context.Context) {
res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
endpointPaths = res
}
if r.db != nil {
// 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return runSummary(gctx) })
g.Go(func() error { runEndpoints(gctx); return nil })
g.Go(func() error { runUpstream(gctx); return nil })
g.Go(func() error { runPaths(gctx); return nil })
if err := g.Wait(); err != nil {
return nil, err
}
} else {
// 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。
if err := runSummary(ctx); err != nil {
return nil, err
}
runEndpoints(ctx)
runUpstream(ctx)
runPaths(ctx)
}
stats.TotalAccountCost = &totalAccountCost
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
stats.Endpoints = endpoints
stats.UpstreamEndpoints = upstreamEndpoints
stats.EndpointPaths = endpointPaths

View File

@ -0,0 +1,51 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := newUsageLogRepositoryWithSQL(client, tx)
user := mustCreateUser(t, client, &service.User{Email: "stats@test.com"})
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-stats-1", Name: "k"})
account := mustCreateAccount(t, client, &service.Account{Name: "acc-stats"})
now := time.Now().UTC()
inboundEndpoint := "/v1/messages"
upstreamEndpoint := "/v1/responses"
for i := 0; i < 3; i++ {
_, err := repo.Create(ctx, &service.UsageLog{
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
Model: "claude-3", InputTokens: 2, OutputTokens: 3,
TotalCost: 0.5, ActualCost: 0.4, CreatedAt: now,
InboundEndpoint: &inboundEndpoint, UpstreamEndpoint: &upstreamEndpoint,
})
require.NoError(t, err)
}
start := now.Add(-1 * time.Hour)
end := now.Add(1 * time.Hour)
// 按本测试创建的 user 维度过滤:集成库为共享实例,其它用 testEntClient 的兄弟测试会留下
// 已提交的 usage_log 行(含零 token 的失败请求),不限定 user 会把它们计入 TotalRequests。
stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{UserID: user.ID, StartTime: &start, EndTime: &end})
require.NoError(t, err)
require.Equal(t, int64(3), stats.TotalRequests)
require.Equal(t, int64(6), stats.TotalInputTokens)
require.Equal(t, int64(9), stats.TotalOutputTokens)
require.InDelta(t, 1.2, stats.TotalActualCost, 1e-9)
require.NotEmpty(t, stats.Endpoints)
require.NotEmpty(t, stats.UpstreamEndpoints)
require.NotEmpty(t, stats.EndpointPaths)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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'])
})
})

View File

@ -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>

View File

@ -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: {