Merge pull request #2007 from PMExtra/feature/user-usage-admin-parity

feat: align user usage analytics with admin
This commit is contained in:
Wesley Liddick
2026-06-30 17:10:09 +08:00
committed by GitHub
23 changed files with 1910 additions and 1639 deletions
+52 -6
View File
@@ -11,6 +11,8 @@ import type {
PaginatedResponse,
TrendDataPoint,
ModelStat,
GroupStat,
UsageRequestType,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
@@ -57,6 +59,14 @@ export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
api_key_id?: number
model?: string
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}
export interface TrendResponse {
@@ -91,6 +101,22 @@ export interface ApiKeyDailyUsageResponse {
end_date: string
}
export interface UsageDashboardSnapshotV2Params extends TrendParams {
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
}
export interface UsageDashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
@@ -141,10 +167,12 @@ export async function query(
* @returns Usage statistics
*/
export async function getStats(
period: string = 'today',
paramsOrPeriod: (UsageQueryParams & { period?: string; timezone?: string }) | string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = { period }
const params: Record<string, unknown> = typeof paramsOrPeriod === 'string'
? { period: paramsOrPeriod }
: { ...paramsOrPeriod }
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
@@ -251,6 +279,15 @@ export async function getDashboardTrend(params?: TrendParams): Promise<TrendResp
export async function getDashboardModels(params?: {
start_date?: string
end_date?: string
api_key_id?: number
model?: string
model_source?: 'requested'
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
return data
@@ -273,6 +310,16 @@ export async function getMyApiKeyDailyUsage(
return data
}
export async function getDashboardSnapshotV2(
params?: UsageDashboardSnapshotV2Params
): Promise<UsageDashboardSnapshotV2Response> {
const { data } = await apiClient.get<UsageDashboardSnapshotV2Response>(
'/usage/dashboard/snapshot-v2',
{ params }
)
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
@@ -308,11 +355,9 @@ export async function getDashboardApiKeysUsage(
}
export async function listMyErrorRequests(
params: UserErrorListParams,
config: { signal?: AbortSignal } = {}
params: UserErrorListParams
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
...config,
params
})
return data
@@ -335,10 +380,11 @@ export const usageAPI = {
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardSnapshotV2,
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail,
getMyErrorDetail
}
export default usageAPI
@@ -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: {
+8 -5
View File
@@ -1290,6 +1290,7 @@ export interface UsageLog {
// User-Agent
user_agent: string | null
ip_address?: string | null
// Cache TTL Override
cache_ttl_overridden: boolean
@@ -1323,9 +1324,6 @@ export interface AdminUsageLog extends UsageLog {
channel_id?: number | null
billing_tier?: string | null
// 用户请求 IP(仅管理员可见)
ip_address?: string | null
// 最小账号信息(仅管理员接口返回)
account?: UsageLogAccountSummary
}
@@ -1468,6 +1466,9 @@ export interface UsageStatsResponse {
total_actual_cost: number // 实际扣除
average_duration_ms: number
models?: Record<string, number>
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
endpoint_paths?: EndpointStat[]
}
// ==================== Trend & Chart Types ====================
@@ -1494,7 +1495,7 @@ export interface ModelStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface EndpointStat {
@@ -1512,7 +1513,7 @@ export interface GroupStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface UserBreakdownItem {
@@ -1687,8 +1688,10 @@ export interface UsageQueryParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
start_date?: string
end_date?: string
timezone?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,26 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UsageView from '../UsageView.vue'
const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, showInfo } = vi.hoisted(() => ({
const {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
list,
getAvailable,
showError,
showWarning,
showSuccess,
showInfo,
} = vi.hoisted(() => ({
query: vi.fn(),
getStatsByDateRange: vi.fn(),
getStats: vi.fn(),
getDashboardModels: vi.fn(),
getDashboardSnapshotV2: vi.fn(),
list: vi.fn(),
getAvailable: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
@@ -15,62 +28,55 @@ const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, s
}))
const messages: Record<string, string> = {
'usage.costDetails': 'Cost Breakdown',
'admin.usage.inputCost': 'Input Cost',
'admin.usage.outputCost': 'Output Cost',
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
'admin.usage.cacheReadCost': 'Cache Read Cost',
'usage.inputTokenPrice': 'Input price',
'usage.outputTokenPrice': 'Output price',
'usage.perMillionTokens': '/ 1M tokens',
'usage.serviceTier': 'Service tier',
'usage.serviceTierPriority': 'Fast',
'usage.serviceTierFlex': 'Flex',
'usage.serviceTierStandard': 'Standard',
'usage.rate': 'Rate',
'usage.original': 'Original',
'usage.billed': 'Billed',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.reasoningEffort': 'Reasoning Effort',
'usage.type': 'Type',
'usage.tokens': 'Tokens',
'usage.cost': 'Cost',
'usage.firstToken': 'First Token',
'usage.duration': 'Duration',
'usage.time': 'Time',
'usage.userAgent': 'User Agent',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.dashboard.timeRange': 'Time range',
'admin.dashboard.granularity': 'Granularity',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.users.columnSettings': 'Columns',
'admin.usage.group': 'Group',
'admin.usage.billingType': 'Billing type',
'admin.usage.billingMode': 'Billing mode',
'admin.usage.allTypes': 'All types',
'admin.usage.allBillingTypes': 'All billing types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.allBillingModes': 'All billing modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'usage.exporting': 'Exporting',
'usage.exportCsv': 'Export CSV',
'usage.failedToLoad': 'Failed to load',
'usage.noDataToExport': 'No data',
'usage.preparingExport': 'Preparing export',
'usage.exportSuccess': 'Export success',
'usage.exportFailed': 'Export failed',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
}
vi.mock('@/api', () => ({
usageAPI: {
query,
getStatsByDateRange,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
},
keysAPI: {
list,
},
userGroupsAPI: {
getAvailable,
},
}))
vi.mock('@/stores/app', () => ({
@@ -87,178 +93,131 @@ vi.mock('vue-i18n', async () => {
}
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const TablePageLayoutStub = {
template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
const simpleStub = { template: '<div><slot /></div>' }
const chartStub = { template: '<div />' }
const usageLog = {
id: 1,
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
ip_address: '203.0.113.10',
api_key: { name: 'demo-key' },
billing_mode: 'token',
request_type: 'sync',
stream: false,
}
describe('user UsageView tooltip', () => {
function mountUsageView() {
return mount(UsageView, {
global: {
stubs: {
AppLayout: simpleStub,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
UsageStatsCards: chartStub,
UsageTable: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
TokenUsageTrend: chartStub,
},
},
})
}
describe('user UsageView', () => {
beforeEach(() => {
query.mockReset()
getStatsByDateRange.mockReset()
getStats.mockReset()
getDashboardModels.mockReset()
getDashboardSnapshotV2.mockReset()
list.mockReset()
getAvailable.mockReset()
showError.mockReset()
showWarning.mockReset()
showSuccess.mockReset()
showInfo.mockReset()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 20,
left: 20,
right: 120,
bottom: 40,
width: 100,
height: 20,
toJSON: () => ({}),
} as DOMRect)
;(globalThis as any).ResizeObserver = class {
observe() {}
disconnect() {}
}
query.mockResolvedValue({ items: [usageLog], total: 1, pages: 1 })
getStats.mockResolvedValue({
total_requests: 1,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_tokens: 0,
total_tokens: 30,
total_cost: 0.1,
total_actual_cost: 0.08,
average_duration_ms: 12,
endpoints: [],
upstream_endpoints: [],
endpoint_paths: [],
})
getDashboardModels.mockResolvedValue({
models: [{ model: 'gpt-5.4', requests: 1, input_tokens: 10, output_tokens: 20, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 30, cost: 0.1, actual_cost: 0.08 }],
start_date: '2026-03-08',
end_date: '2026-03-08',
})
getDashboardSnapshotV2.mockResolvedValue({
generated_at: '2026-03-08T00:00:00Z',
start_date: '2026-03-08',
end_date: '2026-03-08',
granularity: 'hour',
trend: [],
groups: [],
})
list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] })
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
})
it('shows fast service tier and unit prices in user tooltip', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 0,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
it('loads logs, stats, model stats, and snapshot on first render', async () => {
mountUsageView()
await flushPromises()
await nextTick()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
}
setupState.tooltipVisible = true
await nextTick()
const text = wrapper.text()
expect(text).toContain('Service tier')
expect(text).toContain('Fast')
expect(text).toContain('Rate')
expect(text).toContain('1.00x')
expect(text).toContain('Billed')
expect(text).toContain('$0.092883')
expect(text).toContain('$5.0000 / 1M tokens')
expect(text).toContain('$30.0000 / 1M tokens')
expect(query).toHaveBeenCalled()
expect(getStats).toHaveBeenCalled()
expect(getDashboardModels).toHaveBeenCalled()
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({
include_trend: true,
include_model_stats: false,
include_group_stats: true,
}))
expect(list).toHaveBeenCalledWith(1, 100)
expect(getAvailable).toHaveBeenCalled()
})
it('exports csv with input and output unit price columns', async () => {
const exportedLogs = [
{
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
it('exports csv with current filters and without admin-only fields', async () => {
const wrapper = mountUsageView()
await flushPromises()
let exportedBlob: Blob | null = null
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
@@ -268,146 +227,38 @@ describe('user UsageView tooltip', () => {
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
await (wrapper.vm as any).exportToCSV()
expect(exportedBlob).not.toBeNull()
const hasSortedExportQuery = query.mock.calls.some((call) => {
const params = call[0] as Record<string, unknown> | undefined
const config = call[1]
return (
params?.page_size === 100 &&
params?.sort_by === 'created_at' &&
params?.sort_order === 'desc' &&
config === undefined
)
})
expect(hasSortedExportQuery).toBe(true)
expect(query).toHaveBeenCalledWith(expect.objectContaining({
page_size: 100,
sort_by: 'created_at',
sort_order: 'desc',
}))
expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled()
expect(csvContent).toContain('IP Address')
expect(csvContent).toContain('203.0.113.10')
expect(csvContent).toContain('Billed Cost')
expect(csvContent).toContain('Original Cost')
expect(csvContent).not.toContain('Upstream Endpoint')
expect(csvContent).not.toContain('account_cost')
expect(csvContent).not.toContain('account_rate_multiplier')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
it('exports historical image rows with image billing mode derived from image_count', async () => {
const exportedLogs = [
{
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
let exportedBlob: Blob | null = null
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob as Blob)
})
expect(csv).toContain('Billing Mode')
expect(csv).toContain('Image')
expect(csv).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('does not display a 2K fallback for historical image rows with missing size', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-legacy-missing-image',
...usageLog,
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
@@ -416,125 +267,40 @@ describe('user UsageView tooltip', () => {
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
billing_mode: null,
ip_address: null,
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
it('shows image billing metadata in the user cost tooltip', async () => {
query.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
avg_duration_ms: 0,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
const wrapper = mountUsageView()
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-output-image',
actual_cost: 0.8,
total_cost: 0.8,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
billing_mode: null,
image_count: 2,
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 2 },
}
setupState.tooltipVisible = true
await nextTick()
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('4K')
expect(text).toContain('Size source')
expect(text).toContain('Upstream output')
expect(text).toContain('Input size')
expect(text).toContain('1024x1024')
expect(text).toContain('Output size')
expect(text).toContain('3840x2160')
expect(text).toContain('4K x 2')
await (wrapper.vm as any).exportToCSV()
expect(csvContent).toContain('Billing Mode')
expect(csvContent).toContain('Image')
expect(csvContent).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
})