Skip to content

Commit 1f8e114

Browse files
authored
Merge pull request Wei-Shaw#932 from 0xObjc/codex/usage-view-charts
feat(admin): add metric toggle to usage charts
2 parents 1e51de8 + 0ddaef3 commit 1f8e114

8 files changed

Lines changed: 547 additions & 24 deletions

File tree

frontend/src/components/charts/GroupDistributionChart.vue

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
11
<template>
22
<div class="card p-4">
3-
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
4-
{{ t('admin.dashboard.groupDistribution') }}
5-
</h3>
3+
<div class="mb-4 flex items-center justify-between gap-3">
4+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
5+
{{ t('admin.dashboard.groupDistribution') }}
6+
</h3>
7+
<div
8+
v-if="showMetricToggle"
9+
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
10+
>
11+
<button
12+
type="button"
13+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
14+
:class="metric === 'tokens'
15+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
16+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
17+
@click="emit('update:metric', 'tokens')"
18+
>
19+
{{ t('admin.dashboard.metricTokens') }}
20+
</button>
21+
<button
22+
type="button"
23+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
24+
:class="metric === 'actual_cost'
25+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
26+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
27+
@click="emit('update:metric', 'actual_cost')"
28+
>
29+
{{ t('admin.dashboard.metricActualCost') }}
30+
</button>
31+
</div>
32+
</div>
633
<div v-if="loading" class="flex h-48 items-center justify-center">
734
<LoadingSpinner />
835
</div>
9-
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
36+
<div v-else-if="displayGroupStats.length > 0 && chartData" class="flex items-center gap-6">
1037
<div class="h-48 w-48">
1138
<Doughnut :data="chartData" :options="doughnutOptions" />
1239
</div>
@@ -23,7 +50,7 @@
2350
</thead>
2451
<tbody>
2552
<tr
26-
v-for="group in groupStats"
53+
v-for="group in displayGroupStats"
2754
:key="group.group_id"
2855
class="border-t border-gray-100 dark:border-gray-700"
2956
>
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
7198
7299
const { t } = useI18n()
73100
74-
const props = defineProps<{
101+
type DistributionMetric = 'tokens' | 'actual_cost'
102+
103+
const props = withDefaults(defineProps<{
75104
groupStats: GroupStat[]
76105
loading?: boolean
106+
metric?: DistributionMetric
107+
showMetricToggle?: boolean
108+
}>(), {
109+
loading: false,
110+
metric: 'tokens',
111+
showMetricToggle: false,
112+
})
113+
114+
const emit = defineEmits<{
115+
'update:metric': [value: DistributionMetric]
77116
}>()
78117
79118
const chartColors = [
@@ -89,15 +128,22 @@ const chartColors = [
89128
'#84cc16'
90129
]
91130
131+
const displayGroupStats = computed(() => {
132+
if (!props.groupStats?.length) return []
133+
134+
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
135+
return [...props.groupStats].sort((a, b) => b[metricKey] - a[metricKey])
136+
})
137+
92138
const chartData = computed(() => {
93139
if (!props.groupStats?.length) return null
94140
95141
return {
96-
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
142+
labels: displayGroupStats.value.map((g) => g.group_name || String(g.group_id)),
97143
datasets: [
98144
{
99-
data: props.groupStats.map((g) => g.total_tokens),
100-
backgroundColor: chartColors.slice(0, props.groupStats.length),
145+
data: displayGroupStats.value.map((g) => props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens),
146+
backgroundColor: chartColors.slice(0, displayGroupStats.value.length),
101147
borderWidth: 0
102148
}
103149
]
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
116162
label: (context: any) => {
117163
const value = context.raw as number
118164
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
119-
const percentage = ((value / total) * 100).toFixed(1)
120-
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
165+
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
166+
const formattedValue = props.metric === 'actual_cost'
167+
? `$${formatCost(value)}`
168+
: formatTokens(value)
169+
return `${context.label}: ${formattedValue} (${percentage}%)`
121170
}
122171
}
123172
}

frontend/src/components/charts/ModelDistributionChart.vue

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
11
<template>
22
<div class="card p-4">
3-
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
4-
{{ t('admin.dashboard.modelDistribution') }}
5-
</h3>
3+
<div class="mb-4 flex items-center justify-between gap-3">
4+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
5+
{{ t('admin.dashboard.modelDistribution') }}
6+
</h3>
7+
<div
8+
v-if="showMetricToggle"
9+
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
10+
>
11+
<button
12+
type="button"
13+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
14+
:class="metric === 'tokens'
15+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
16+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
17+
@click="emit('update:metric', 'tokens')"
18+
>
19+
{{ t('admin.dashboard.metricTokens') }}
20+
</button>
21+
<button
22+
type="button"
23+
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
24+
:class="metric === 'actual_cost'
25+
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
26+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
27+
@click="emit('update:metric', 'actual_cost')"
28+
>
29+
{{ t('admin.dashboard.metricActualCost') }}
30+
</button>
31+
</div>
32+
</div>
633
<div v-if="loading" class="flex h-48 items-center justify-center">
734
<LoadingSpinner />
835
</div>
9-
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
36+
<div v-else-if="displayModelStats.length > 0 && chartData" class="flex items-center gap-6">
1037
<div class="h-48 w-48">
1138
<Doughnut :data="chartData" :options="doughnutOptions" />
1239
</div>
@@ -23,7 +50,7 @@
2350
</thead>
2451
<tbody>
2552
<tr
26-
v-for="model in modelStats"
53+
v-for="model in displayModelStats"
2754
:key="model.model"
2855
class="border-t border-gray-100 dark:border-gray-700"
2956
>
@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
7198
7299
const { t } = useI18n()
73100
74-
const props = defineProps<{
101+
type DistributionMetric = 'tokens' | 'actual_cost'
102+
103+
const props = withDefaults(defineProps<{
75104
modelStats: ModelStat[]
76105
loading?: boolean
106+
metric?: DistributionMetric
107+
showMetricToggle?: boolean
108+
}>(), {
109+
loading: false,
110+
metric: 'tokens',
111+
showMetricToggle: false,
112+
})
113+
114+
const emit = defineEmits<{
115+
'update:metric': [value: DistributionMetric]
77116
}>()
78117
79118
const chartColors = [
@@ -89,15 +128,22 @@ const chartColors = [
89128
'#84cc16'
90129
]
91130
131+
const displayModelStats = computed(() => {
132+
if (!props.modelStats?.length) return []
133+
134+
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
135+
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
136+
})
137+
92138
const chartData = computed(() => {
93139
if (!props.modelStats?.length) return null
94140
95141
return {
96-
labels: props.modelStats.map((m) => m.model),
142+
labels: displayModelStats.value.map((m) => m.model),
97143
datasets: [
98144
{
99-
data: props.modelStats.map((m) => m.total_tokens),
100-
backgroundColor: chartColors.slice(0, props.modelStats.length),
145+
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
146+
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
101147
borderWidth: 0
102148
}
103149
]
@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
116162
label: (context: any) => {
117163
const value = context.raw as number
118164
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
119-
const percentage = ((value / total) * 100).toFixed(1)
120-
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
165+
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
166+
const formattedValue = props.metric === 'actual_cost'
167+
? `$${formatCost(value)}`
168+
: formatTokens(value)
169+
return `${context.label}: ${formattedValue} (${percentage}%)`
121170
}
122171
}
123172
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { mount } from '@vue/test-utils'
3+
4+
import GroupDistributionChart from '../GroupDistributionChart.vue'
5+
6+
const messages: Record<string, string> = {
7+
'admin.dashboard.groupDistribution': 'Group Distribution',
8+
'admin.dashboard.group': 'Group',
9+
'admin.dashboard.noGroup': 'No Group',
10+
'admin.dashboard.requests': 'Requests',
11+
'admin.dashboard.tokens': 'Tokens',
12+
'admin.dashboard.actual': 'Actual',
13+
'admin.dashboard.standard': 'Standard',
14+
'admin.dashboard.metricTokens': 'By Tokens',
15+
'admin.dashboard.metricActualCost': 'By Actual Cost',
16+
'admin.dashboard.noDataAvailable': 'No data available',
17+
}
18+
19+
vi.mock('vue-i18n', async () => {
20+
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
21+
return {
22+
...actual,
23+
useI18n: () => ({
24+
t: (key: string) => messages[key] ?? key,
25+
}),
26+
}
27+
})
28+
29+
vi.mock('vue-chartjs', () => ({
30+
Doughnut: {
31+
props: ['data'],
32+
template: '<div class="chart-data">{{ JSON.stringify(data) }}</div>',
33+
},
34+
}))
35+
36+
describe('GroupDistributionChart', () => {
37+
const groupStats = [
38+
{
39+
group_id: 1,
40+
group_name: 'group-a',
41+
requests: 9,
42+
total_tokens: 1200,
43+
cost: 1.8,
44+
actual_cost: 0.1,
45+
},
46+
{
47+
group_id: 2,
48+
group_name: 'group-b',
49+
requests: 4,
50+
total_tokens: 600,
51+
cost: 0.7,
52+
actual_cost: 0.9,
53+
},
54+
]
55+
56+
it('uses total_tokens and token ordering by default', () => {
57+
const wrapper = mount(GroupDistributionChart, {
58+
props: {
59+
groupStats,
60+
},
61+
global: {
62+
stubs: {
63+
LoadingSpinner: true,
64+
},
65+
},
66+
})
67+
68+
const chartData = JSON.parse(wrapper.find('.chart-data').text())
69+
expect(chartData.labels).toEqual(['group-a', 'group-b'])
70+
expect(chartData.datasets[0].data).toEqual([1200, 600])
71+
72+
const rows = wrapper.findAll('tbody tr')
73+
expect(rows[0].text()).toContain('group-a')
74+
expect(rows[1].text()).toContain('group-b')
75+
76+
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
77+
const label = options.plugins.tooltip.callbacks.label({
78+
label: 'group-a',
79+
raw: 1200,
80+
dataset: { data: [1200, 600] },
81+
})
82+
expect(label).toBe('group-a: 1.20K (66.7%)')
83+
})
84+
85+
it('uses actual_cost and reorders rows in actual cost mode', () => {
86+
const wrapper = mount(GroupDistributionChart, {
87+
props: {
88+
groupStats,
89+
metric: 'actual_cost',
90+
},
91+
global: {
92+
stubs: {
93+
LoadingSpinner: true,
94+
},
95+
},
96+
})
97+
98+
const chartData = JSON.parse(wrapper.find('.chart-data').text())
99+
expect(chartData.labels).toEqual(['group-b', 'group-a'])
100+
expect(chartData.datasets[0].data).toEqual([0.9, 0.1])
101+
102+
const rows = wrapper.findAll('tbody tr')
103+
expect(rows[0].text()).toContain('group-b')
104+
expect(rows[1].text()).toContain('group-a')
105+
106+
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
107+
const label = options.plugins.tooltip.callbacks.label({
108+
label: 'group-b',
109+
raw: 0.9,
110+
dataset: { data: [0.9, 0.1] },
111+
})
112+
expect(label).toBe('group-b: $0.900 (90.0%)')
113+
})
114+
})

0 commit comments

Comments
 (0)