mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-16 21:04:45 +08:00
Merge pull request #932 from 0xObjc/codex/usage-view-charts
feat(admin): add metric toggle to usage charts
This commit is contained in:
@@ -13,8 +13,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
|
||||
<GroupDistributionChart :group-stats="groupStats" :loading="chartsLoading" />
|
||||
<ModelDistributionChart
|
||||
v-model:metric="modelDistributionMetric"
|
||||
:model-stats="modelStats"
|
||||
:loading="chartsLoading"
|
||||
:show-metric-toggle="true"
|
||||
/>
|
||||
<GroupDistributionChart
|
||||
v-model:metric="groupDistributionMetric"
|
||||
:group-stats="groupStats"
|
||||
:loading="chartsLoading"
|
||||
:show-metric-toggle="true"
|
||||
/>
|
||||
</div>
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
@@ -93,8 +103,12 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||
const modelDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
const groupDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
let chartReqSeq = 0
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
|
||||
174
frontend/src/views/admin/__tests__/UsageView.spec.ts
Normal file
174
frontend/src/views/admin/__tests__/UsageView.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
})
|
||||
|
||||
return {
|
||||
list: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getSnapshotV2: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.dashboard.day': 'Day',
|
||||
'admin.dashboard.hour': 'Hour',
|
||||
'admin.usage.failedToLoadUser': 'Failed to load user',
|
||||
}
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
usage: {
|
||||
list,
|
||||
getStats,
|
||||
},
|
||||
dashboard: {
|
||||
getSnapshotV2,
|
||||
},
|
||||
users: {
|
||||
getById,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/usage', () => ({
|
||||
adminUsageAPI: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatReasoningEffort: (value: string | null | undefined) => value ?? '-',
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
|
||||
const ModelDistributionChartStub = {
|
||||
props: ['metric'],
|
||||
emits: ['update:metric'],
|
||||
template: `
|
||||
<div data-test="model-chart">
|
||||
<span class="metric">{{ metric }}</span>
|
||||
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
const GroupDistributionChartStub = {
|
||||
props: ['metric'],
|
||||
emits: ['update:metric'],
|
||||
template: `
|
||||
<div data-test="group-chart">
|
||||
<span class="metric">{{ metric }}</span>
|
||||
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
describe('admin UsageView distribution metric toggles', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReset()
|
||||
getStats.mockReset()
|
||||
getSnapshotV2.mockReset()
|
||||
getById.mockReset()
|
||||
|
||||
list.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
pages: 0,
|
||||
})
|
||||
getStats.mockResolvedValue({
|
||||
total_requests: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_tokens: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_actual_cost: 0,
|
||||
average_duration_ms: 0,
|
||||
})
|
||||
getSnapshotV2.mockResolvedValue({
|
||||
trend: [],
|
||||
models: [],
|
||||
groups: [],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps model and group metric toggles independent without refetching chart data', async () => {
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
UsageStatsCards: true,
|
||||
UsageFilters: UsageFiltersStub,
|
||||
UsageTable: true,
|
||||
UsageExportProgress: true,
|
||||
UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true,
|
||||
Pagination: true,
|
||||
Select: true,
|
||||
Icon: true,
|
||||
TokenUsageTrend: true,
|
||||
ModelDistributionChart: ModelDistributionChartStub,
|
||||
GroupDistributionChart: GroupDistributionChartStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
|
||||
|
||||
const modelChart = wrapper.find('[data-test="model-chart"]')
|
||||
const groupChart = wrapper.find('[data-test="group-chart"]')
|
||||
|
||||
expect(modelChart.find('.metric').text()).toBe('tokens')
|
||||
expect(groupChart.find('.metric').text()).toBe('tokens')
|
||||
|
||||
await modelChart.find('.switch-metric').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(modelChart.find('.metric').text()).toBe('actual_cost')
|
||||
expect(groupChart.find('.metric').text()).toBe('tokens')
|
||||
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
|
||||
|
||||
await groupChart.find('.switch-metric').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(modelChart.find('.metric').text()).toBe('actual_cost')
|
||||
expect(groupChart.find('.metric').text()).toBe('actual_cost')
|
||||
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user