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