mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 04:05:29 +00:00
feat(admin): add metric toggle to usage charts
This commit is contained in:
@@ -1,12 +1,39 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.groupDistribution') }}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.groupDistribution') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="showMetricToggle"
|
||||
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="metric === 'tokens'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="emit('update:metric', 'tokens')"
|
||||
>
|
||||
{{ t('admin.dashboard.metricTokens') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="metric === 'actual_cost'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="emit('update:metric', 'actual_cost')"
|
||||
>
|
||||
{{ t('admin.dashboard.metricActualCost') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div v-else-if="displayGroupStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
@@ -23,7 +50,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groupStats"
|
||||
v-for="group in displayGroupStats"
|
||||
:key="group.group_id"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
groupStats: GroupStat[]
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showMetricToggle?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
showMetricToggle: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
}>()
|
||||
|
||||
const chartColors = [
|
||||
@@ -89,15 +128,22 @@ const chartColors = [
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
const displayGroupStats = computed(() => {
|
||||
if (!props.groupStats?.length) return []
|
||||
|
||||
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
||||
return [...props.groupStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.groupStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
|
||||
labels: displayGroupStats.value.map((g) => g.group_name || String(g.group_id)),
|
||||
datasets: [
|
||||
{
|
||||
data: props.groupStats.map((g) => g.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.groupStats.length),
|
||||
data: displayGroupStats.value.map((g) => props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, displayGroupStats.value.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||
const formattedValue = props.metric === 'actual_cost'
|
||||
? `$${formatCost(value)}`
|
||||
: formatTokens(value)
|
||||
return `${context.label}: ${formattedValue} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.modelDistribution') }}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.modelDistribution') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="showMetricToggle"
|
||||
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="metric === 'tokens'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="emit('update:metric', 'tokens')"
|
||||
>
|
||||
{{ t('admin.dashboard.metricTokens') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="metric === 'actual_cost'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="emit('update:metric', 'actual_cost')"
|
||||
>
|
||||
{{ t('admin.dashboard.metricActualCost') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div v-else-if="displayModelStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
@@ -23,7 +50,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="model in modelStats"
|
||||
v-for="model in displayModelStats"
|
||||
:key="model.model"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelStats: ModelStat[]
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showMetricToggle?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
showMetricToggle: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
}>()
|
||||
|
||||
const chartColors = [
|
||||
@@ -89,15 +128,22 @@ const chartColors = [
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
const displayModelStats = computed(() => {
|
||||
if (!props.modelStats?.length) return []
|
||||
|
||||
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
||||
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.modelStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.modelStats.map((m) => m.model),
|
||||
labels: displayModelStats.value.map((m) => m.model),
|
||||
datasets: [
|
||||
{
|
||||
data: props.modelStats.map((m) => m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||
const formattedValue = props.metric === 'actual_cost'
|
||||
? `$${formatCost(value)}`
|
||||
: formatTokens(value)
|
||||
return `${context.label}: ${formattedValue} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import GroupDistributionChart from '../GroupDistributionChart.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.dashboard.groupDistribution': 'Group Distribution',
|
||||
'admin.dashboard.group': 'Group',
|
||||
'admin.dashboard.noGroup': 'No Group',
|
||||
'admin.dashboard.requests': 'Requests',
|
||||
'admin.dashboard.tokens': 'Tokens',
|
||||
'admin.dashboard.actual': 'Actual',
|
||||
'admin.dashboard.standard': 'Standard',
|
||||
'admin.dashboard.metricTokens': 'By Tokens',
|
||||
'admin.dashboard.metricActualCost': 'By Actual Cost',
|
||||
'admin.dashboard.noDataAvailable': 'No data available',
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Doughnut: {
|
||||
props: ['data'],
|
||||
template: '<div class="chart-data">{{ JSON.stringify(data) }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('GroupDistributionChart', () => {
|
||||
const groupStats = [
|
||||
{
|
||||
group_id: 1,
|
||||
group_name: 'group-a',
|
||||
requests: 9,
|
||||
total_tokens: 1200,
|
||||
cost: 1.8,
|
||||
actual_cost: 0.1,
|
||||
},
|
||||
{
|
||||
group_id: 2,
|
||||
group_name: 'group-b',
|
||||
requests: 4,
|
||||
total_tokens: 600,
|
||||
cost: 0.7,
|
||||
actual_cost: 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
it('uses total_tokens and token ordering by default', () => {
|
||||
const wrapper = mount(GroupDistributionChart, {
|
||||
props: {
|
||||
groupStats,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingSpinner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = JSON.parse(wrapper.find('.chart-data').text())
|
||||
expect(chartData.labels).toEqual(['group-a', 'group-b'])
|
||||
expect(chartData.datasets[0].data).toEqual([1200, 600])
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows[0].text()).toContain('group-a')
|
||||
expect(rows[1].text()).toContain('group-b')
|
||||
|
||||
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
|
||||
const label = options.plugins.tooltip.callbacks.label({
|
||||
label: 'group-a',
|
||||
raw: 1200,
|
||||
dataset: { data: [1200, 600] },
|
||||
})
|
||||
expect(label).toBe('group-a: 1.20K (66.7%)')
|
||||
})
|
||||
|
||||
it('uses actual_cost and reorders rows in actual cost mode', () => {
|
||||
const wrapper = mount(GroupDistributionChart, {
|
||||
props: {
|
||||
groupStats,
|
||||
metric: 'actual_cost',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingSpinner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = JSON.parse(wrapper.find('.chart-data').text())
|
||||
expect(chartData.labels).toEqual(['group-b', 'group-a'])
|
||||
expect(chartData.datasets[0].data).toEqual([0.9, 0.1])
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows[0].text()).toContain('group-b')
|
||||
expect(rows[1].text()).toContain('group-a')
|
||||
|
||||
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
|
||||
const label = options.plugins.tooltip.callbacks.label({
|
||||
label: 'group-b',
|
||||
raw: 0.9,
|
||||
dataset: { data: [0.9, 0.1] },
|
||||
})
|
||||
expect(label).toBe('group-b: $0.900 (90.0%)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import ModelDistributionChart from '../ModelDistributionChart.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.dashboard.modelDistribution': 'Model Distribution',
|
||||
'admin.dashboard.model': 'Model',
|
||||
'admin.dashboard.requests': 'Requests',
|
||||
'admin.dashboard.tokens': 'Tokens',
|
||||
'admin.dashboard.actual': 'Actual',
|
||||
'admin.dashboard.standard': 'Standard',
|
||||
'admin.dashboard.metricTokens': 'By Tokens',
|
||||
'admin.dashboard.metricActualCost': 'By Actual Cost',
|
||||
'admin.dashboard.noDataAvailable': 'No data available',
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Doughnut: {
|
||||
props: ['data'],
|
||||
template: '<div class="chart-data">{{ JSON.stringify(data) }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ModelDistributionChart', () => {
|
||||
const modelStats = [
|
||||
{
|
||||
model: 'model-a',
|
||||
requests: 8,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_tokens: 1000,
|
||||
cost: 1.5,
|
||||
actual_cost: 0.2,
|
||||
},
|
||||
{
|
||||
model: 'model-b',
|
||||
requests: 3,
|
||||
input_tokens: 40,
|
||||
output_tokens: 20,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_tokens: 500,
|
||||
cost: 0.5,
|
||||
actual_cost: 1.4,
|
||||
},
|
||||
]
|
||||
|
||||
it('uses total_tokens and token ordering by default', () => {
|
||||
const wrapper = mount(ModelDistributionChart, {
|
||||
props: {
|
||||
modelStats,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingSpinner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = JSON.parse(wrapper.find('.chart-data').text())
|
||||
expect(chartData.labels).toEqual(['model-a', 'model-b'])
|
||||
expect(chartData.datasets[0].data).toEqual([1000, 500])
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows[0].text()).toContain('model-a')
|
||||
expect(rows[1].text()).toContain('model-b')
|
||||
|
||||
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
|
||||
const label = options.plugins.tooltip.callbacks.label({
|
||||
label: 'model-a',
|
||||
raw: 1000,
|
||||
dataset: { data: [1000, 500] },
|
||||
})
|
||||
expect(label).toBe('model-a: 1.00K (66.7%)')
|
||||
})
|
||||
|
||||
it('uses actual_cost and reorders rows in actual cost mode', () => {
|
||||
const wrapper = mount(ModelDistributionChart, {
|
||||
props: {
|
||||
modelStats,
|
||||
metric: 'actual_cost',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingSpinner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = JSON.parse(wrapper.find('.chart-data').text())
|
||||
expect(chartData.labels).toEqual(['model-b', 'model-a'])
|
||||
expect(chartData.datasets[0].data).toEqual([1.4, 0.2])
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows[0].text()).toContain('model-b')
|
||||
expect(rows[1].text()).toContain('model-a')
|
||||
|
||||
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
|
||||
const label = options.plugins.tooltip.callbacks.label({
|
||||
label: 'model-b',
|
||||
raw: 1.4,
|
||||
dataset: { data: [1.4, 0.2] },
|
||||
})
|
||||
expect(label).toBe('model-b: $1.40 (87.5%)')
|
||||
})
|
||||
})
|
||||
@@ -950,6 +950,8 @@ export default {
|
||||
hour: 'Hour',
|
||||
modelDistribution: 'Model Distribution',
|
||||
groupDistribution: 'Group Usage Distribution',
|
||||
metricTokens: 'By Tokens',
|
||||
metricActualCost: 'By Actual Cost',
|
||||
tokenUsageTrend: 'Token Usage Trend',
|
||||
userUsageTrend: 'User Usage Trend (Top 12)',
|
||||
model: 'Model',
|
||||
|
||||
@@ -963,6 +963,8 @@ export default {
|
||||
hour: '按小时',
|
||||
modelDistribution: '模型分布',
|
||||
groupDistribution: '分组使用分布',
|
||||
metricTokens: '按 Token',
|
||||
metricActualCost: '按实际消费',
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
|
||||
@@ -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