mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-10 10:04:46 +08: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%)')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user