Merge pull request #932 from 0xObjc/codex/usage-view-charts

feat(admin): add metric toggle to usage charts
This commit is contained in:
Wesley Liddick
2026-03-12 09:32:40 +08:00
committed by GitHub
8 changed files with 547 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -963,6 +963,8 @@ export default {
hour: '按小时',
modelDistribution: '模型分布',
groupDistribution: '分组使用分布',
metricTokens: '按 Token',
metricActualCost: '按实际消费',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',

View File

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

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