feat: align user usage analytics with admin

This commit is contained in:
PMExtra
2026-06-30 15:31:28 +08:00
parent 89b2d63ef2
commit cafc95c3e2
23 changed files with 1910 additions and 1639 deletions
@@ -68,9 +68,14 @@
${{ (stats?.total_actual_cost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ (stats?.total_account_cost || 0).toFixed(4) }}</span>
<span> · </span>
<span>{{ t('usage.standardCost') }} ${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<template v-if="showAccountCost && totalAccountCost != null">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ totalAccountCost.toFixed(4) }}</span>
<span> · </span>
</template>
<span>
{{ t('usage.standardCost') }}
<span :class="{ 'line-through': strikeStandardCost }">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</span>
</p>
</div>
</div>
@@ -84,14 +89,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import type { UsageStatsResponse } from '@/types'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const props = withDefaults(defineProps<{
stats: (AdminUsageStatsResponse | UsageStatsResponse) | null
showAccountCost?: boolean
strikeStandardCost?: boolean
}>(), {
showAccountCost: true,
strikeStandardCost: false,
})
const { t } = useI18n()
const totalAccountCost = computed(() => {
const stats = props.stats as (AdminUsageStatsResponse & { total_account_cost?: number }) | null
return stats?.total_account_cost ?? null
})
const showAccountCost = computed(() => props.showAccountCost)
const strikeStandardCost = computed(() => props.strikeStandardCost)
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
@@ -68,7 +68,7 @@
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
<div class="break-all text-gray-700 dark:text-gray-300">
<div v-if="showUpstreamEndpoint" class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
</div>
@@ -163,7 +163,7 @@
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
<div v-if="showAccountBilling && row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
@@ -380,20 +380,22 @@
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
<template v-if="showAccountBilling">
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</template>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
@@ -449,19 +451,25 @@ interface Props {
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
showAccountBilling?: boolean
showUpstreamEndpoint?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
defaultSortOrder: 'asc',
showAccountBilling: true,
showUpstreamEndpoint: true
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n()
const showAccountBilling = props.showAccountBilling
const showUpstreamEndpoint = props.showUpstreamEndpoint
// Tooltip state - cost
const tooltipVisible = ref(false)
@@ -89,13 +89,14 @@
<tbody>
<template v-for="item in displayEndpointStats" :key="item.endpoint">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown(item.endpoint)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown(item.endpoint)"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
<td class="max-w-[180px] truncate py-1.5 font-medium" :class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'" :title="item.endpoint">
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ item.endpoint }}
</span>
</td>
@@ -159,6 +160,7 @@ const props = withDefaults(
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
enableBreakdown?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -171,7 +173,8 @@ const props = withDefaults(
metric: 'tokens',
source: 'inbound',
showMetricToggle: false,
showSourceToggle: false
showSourceToggle: false,
enableBreakdown: true
}
)
@@ -45,7 +45,7 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
@@ -53,17 +53,17 @@
<template v-for="group in displayGroupStats" :key="group.group_id">
<tr
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="group.group_id > 0 && toggleBreakdown('group', group.group_id)"
:class="enableBreakdown && group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && group.group_id > 0 && toggleBreakdown('group', group.group_id)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium"
:class="group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:class="enableBreakdown && group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="group.group_name || String(group.group_id)"
>
<span class="inline-flex items-center gap-1">
<svg v-if="group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown && group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</span>
</td>
@@ -76,7 +76,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(group.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -85,10 +85,11 @@
</tr>
<!-- User breakdown sub-rows -->
<tr v-if="expandedKey === `group-${group.group_id}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -127,6 +128,8 @@ const props = withDefaults(defineProps<{
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -134,6 +137,8 @@ const props = withDefaults(defineProps<{
loading: false,
metric: 'tokens',
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
})
const emit = defineEmits<{
@@ -143,6 +148,8 @@ const emit = defineEmits<{
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const toggleBreakdown = async (type: string, id: number | string) => {
const key = `${type}-${id}`
@@ -114,23 +114,25 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<template v-for="model in displayModelStats" :key="model.model">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown('model', model.model)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown('model', model.model)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
class="max-w-[100px] truncate py-1.5 font-medium"
:class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="model.model"
>
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ model.model }}
</span>
</td>
@@ -143,7 +145,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(model.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -151,10 +153,11 @@
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -270,6 +273,8 @@ const props = withDefaults(defineProps<{
metric?: DistributionMetric
showSourceToggle?: boolean
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
@@ -288,6 +293,8 @@ const props = withDefaults(defineProps<{
metric: 'tokens',
showSourceToggle: false,
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
rankingLoading: false,
rankingError: false
})
@@ -328,6 +335,8 @@ const emit = defineEmits<{
}>()
const enableRankingView = computed(() => props.enableRankingView)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
const chartColors = [
@@ -25,7 +25,7 @@
<td class="py-1 text-right text-green-600 dark:text-green-400">
${{ formatCost(user.actual_cost) }}
</td>
<td class="py-1 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(user.account_cost) }}
</td>
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
@@ -38,16 +38,23 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { UserBreakdownItem } from '@/types'
const { t } = useI18n()
defineProps<{
const props = withDefaults(defineProps<{
items: UserBreakdownItem[]
loading?: boolean
}>()
showAccountCost?: boolean
}>(), {
loading: false,
showAccountCost: true,
})
const showAccountCost = computed(() => props.showAccountCost)
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -56,7 +63,8 @@ const formatTokens = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
const formatCost = (value: number | undefined | null): string => {
if (value == null) return '0.0000'
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'
if (value >= 1) return value.toFixed(2)
if (value >= 0.01) return value.toFixed(3)
@@ -10,6 +10,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -111,4 +112,22 @@ describe('GroupDistributionChart', () => {
})
expect(label).toBe('group-b: $0.900 (90.0%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(GroupDistributionChart, {
props: {
groupStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
})
@@ -17,6 +17,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -126,6 +127,24 @@ describe('ModelDistributionChart', () => {
expect(label).toBe('model-b: $1.40 (87.5%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
const wrapper = mount(ModelDistributionChart, {
props: {