mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 16:07:02 +00:00
Merge pull request #2007 from PMExtra/feature/user-usage-admin-parity
feat: align user usage analytics with admin
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user