| <template> |
| <div class="card overflow-hidden"> |
| <div class="overflow-auto"> |
| <DataTable :columns="columns" :data="data" :loading="loading"> |
| <template #cell-user="{ row }"> |
| <div class="text-sm"> |
| <button |
| v-if="row.user?.email" |
| class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" |
| @click="$emit('userClick', row.user_id, row.user?.email)" |
| :title="t('admin.usage.clickToViewBalance')" |
| > |
| {{ row.user.email }} |
| </button> |
| <span v-else class="font-medium text-gray-900 dark:text-white">-</span> |
| <span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span> |
| </div> |
| </template> |
| |
| <template #cell-api_key="{ row }"> |
| <span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span> |
| </template> |
| |
| <template #cell-account="{ row }"> |
| <span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span> |
| </template> |
| |
| <template #cell-model="{ row }"> |
| <div v-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs"> |
| <div class="break-all font-medium text-gray-900 dark:text-white"> |
| {{ row.model }} |
| </div> |
| <div class="break-all text-gray-500 dark:text-gray-400"> |
| <span class="mr-0.5">↳</span>{{ row.upstream_model }} |
| </div> |
| </div> |
| <span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span> |
| </template> |
| |
| <template #cell-reasoning_effort="{ row }"> |
| <span class="text-sm text-gray-900 dark:text-white"> |
| {{ formatReasoningEffort(row.reasoning_effort) }} |
| </span> |
| </template> |
| |
| <template #cell-endpoint="{ row }"> |
| <div class="max-w-[320px] space-y-1 text-xs"> |
| <div class="break-all text-gray-700 dark:text-gray-300"> |
| <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"> |
| <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> |
| </div> |
| </template> |
| |
| <template #cell-group="{ row }"> |
| <span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"> |
| {{ row.group.name }} |
| </span> |
| <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> |
| </template> |
| |
| <template #cell-stream="{ row }"> |
| <span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getRequestTypeBadgeClass(row)"> |
| {{ getRequestTypeLabel(row) }} |
| </span> |
| </template> |
| |
| <template #cell-tokens="{ row }"> |
| |
| <div v-if="row.image_count > 0" class="flex items-center gap-1.5"> |
| <svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> |
| </svg> |
| <span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span> |
| <span class="text-gray-400">({{ row.image_size || '2K' }})</span> |
| </div> |
| |
| <div v-else class="flex items-center gap-1.5"> |
| <div class="space-y-1 text-sm"> |
| <div class="flex items-center gap-2"> |
| <div class="inline-flex items-center gap-1"> |
| <Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" /> |
| <span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span> |
| </div> |
| <div class="inline-flex items-center gap-1"> |
| <Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" /> |
| <span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span> |
| </div> |
| </div> |
| <div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2"> |
| <div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1"> |
| <svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg> |
| <span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span> |
| </div> |
| <div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1"> |
| <svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg> |
| <span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span> |
| <span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span> |
| <span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span> |
| </div> |
| </div> |
| </div> |
| |
| <div |
| class="group relative" |
| @mouseenter="showTokenTooltip($event, row)" |
| @mouseleave="hideTokenTooltip" |
| > |
| <div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"> |
| <Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" /> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <template #cell-cost="{ row }"> |
| <div class="text-sm"> |
| <div class="flex items-center gap-1.5"> |
| <span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span> |
| |
| <div |
| class="group relative" |
| @mouseenter="showTooltip($event, row)" |
| @mouseleave="hideTooltip" |
| > |
| <div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"> |
| <Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" /> |
| </div> |
| </div> |
| </div> |
| <div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400"> |
| A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }} |
| </div> |
| </div> |
| </template> |
| |
| <template #cell-first_token="{ row }"> |
| <span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span> |
| <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> |
| </template> |
| |
| <template #cell-duration="{ row }"> |
| <span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span> |
| </template> |
| |
| <template #cell-created_at="{ value }"> |
| <span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span> |
| </template> |
| |
| <template #cell-user_agent="{ row }"> |
| <span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span> |
| <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> |
| </template> |
| |
| <template #cell-ip_address="{ row }"> |
| <span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span> |
| <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span> |
| </template> |
| |
| <template #empty><EmptyState :message="t('usage.noRecords')" /></template> |
| </DataTable> |
| </div> |
| </div> |
| |
| |
| <Teleport to="body"> |
| <div |
| v-if="tokenTooltipVisible" |
| class="fixed z-[9999] pointer-events-none -translate-y-1/2" |
| :style="{ |
| left: tokenTooltipPosition.x + 'px', |
| top: tokenTooltipPosition.y + 'px' |
| }" |
| > |
| <div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"> |
| <div class="space-y-1.5"> |
| <div> |
| <div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div> |
| <div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span> |
| <span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span> |
| </div> |
| <div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span> |
| <span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span> |
| </div> |
| <div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0"> |
| |
| <template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0"> |
| <div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400 flex items-center gap-1.5"> |
| {{ t('admin.usage.cacheCreation5mTokens') }} |
| <span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span> |
| </span> |
| <span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span> |
| </div> |
| <div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400 flex items-center gap-1.5"> |
| {{ t('admin.usage.cacheCreation1hTokens') }} |
| <span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span> |
| </span> |
| <span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span> |
| </div> |
| </template> |
| |
| <div v-else class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span> |
| <span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span> |
| </div> |
| </div> |
| <div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400 flex items-center gap-1.5"> |
| {{ t('usage.cacheTtlOverriddenLabel') }} |
| <span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span> |
| </span> |
| <span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span> |
| </div> |
| <div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span> |
| <span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span> |
| </div> |
| </div> |
| <div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"> |
| <span class="text-gray-400">{{ t('usage.totalTokens') }}</span> |
| <span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span> |
| </div> |
| </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> |
| </div> |
| </Teleport> |
| |
| |
| <Teleport to="body"> |
| <div |
| v-if="tooltipVisible" |
| class="fixed z-[9999] pointer-events-none -translate-y-1/2" |
| :style="{ |
| left: tooltipPosition.x + 'px', |
| top: tooltipPosition.y + 'px' |
| }" |
| > |
| <div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"> |
| <div class="space-y-1.5"> |
| |
| <div class="mb-2 border-b border-gray-700 pb-1.5"> |
| <div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div> |
| <div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span> |
| <span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span> |
| </div> |
| <div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> |
| <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> |
| </div> |
| <div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span> |
| <span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span> |
| </div> |
| <div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span> |
| <span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span> |
| </div> |
| <div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span> |
| <span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span> |
| </div> |
| <div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4"> |
| <span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span> |
| <span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span> |
| </div> |
| </div> |
| |
| <div class="flex items-center justify-between gap-6"> |
| <span class="text-gray-400">{{ t('usage.serviceTier') }}</span> |
| <span class="font-semibold text-cyan-300">{{ getUsageServiceTierLabel(tooltipData?.service_tier, t) }}</span> |
| </div> |
| <div class="flex items-center justify-between gap-6"> |
| <span class="text-gray-400">{{ t('usage.rate') }}</span> |
| <span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span> |
| </div> |
| <div class="flex items-center justify-between gap-6"> |
| <span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span> |
| <span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span> |
| </div> |
| <div class="flex items-center justify-between gap-6"> |
| <span class="text-gray-400">{{ t('usage.original') }}</span> |
| <span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span> |
| </div> |
| <div class="flex items-center justify-between gap-6"> |
| <span class="text-gray-400">{{ t('usage.userBilled') }}</span> |
| <span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span> |
| </div> |
| <div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"> |
| <span class="text-gray-400">{{ t('usage.accountBilled') }}</span> |
| <span class="font-semibold text-green-400"> |
| ${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }} |
| </span> |
| </div> |
| </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> |
| </div> |
| </Teleport> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { formatDateTime, formatReasoningEffort } from '@/utils/format' |
| import { formatTokenPricePerMillion } from '@/utils/usagePricing' |
| import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' |
| import { resolveUsageRequestType } from '@/utils/usageRequestType' |
| import DataTable from '@/components/common/DataTable.vue' |
| import EmptyState from '@/components/common/EmptyState.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| import type { AdminUsageLog } from '@/types' |
| |
| defineProps(['data', 'loading', 'columns']) |
| defineEmits(['userClick']) |
| const { t } = useI18n() |
| |
| |
| const tooltipVisible = ref(false) |
| const tooltipPosition = ref({ x: 0, y: 0 }) |
| const tooltipData = ref<AdminUsageLog | null>(null) |
| |
| |
| const tokenTooltipVisible = ref(false) |
| const tokenTooltipPosition = ref({ x: 0, y: 0 }) |
| const tokenTooltipData = ref<AdminUsageLog | null>(null) |
| |
| const getRequestTypeLabel = (row: AdminUsageLog): string => { |
| const requestType = resolveUsageRequestType(row) |
| if (requestType === 'ws_v2') return t('usage.ws') |
| if (requestType === 'stream') return t('usage.stream') |
| if (requestType === 'sync') return t('usage.sync') |
| return t('usage.unknown') |
| } |
| |
| const getRequestTypeBadgeClass = (row: AdminUsageLog): string => { |
| const requestType = resolveUsageRequestType(row) |
| if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200' |
| if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' |
| if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' |
| return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' |
| } |
| |
| const formatCacheTokens = (tokens: number): string => { |
| if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` |
| if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` |
| return tokens.toString() |
| } |
| |
| const formatUserAgent = (ua: string): string => { |
| return ua |
| } |
| |
| const formatDuration = (ms: number | null | undefined): string => { |
| if (ms == null) return '-' |
| if (ms < 1000) return `${ms}ms` |
| return `${(ms / 1000).toFixed(2)}s` |
| } |
| |
| |
| const showTooltip = (event: MouseEvent, row: AdminUsageLog) => { |
| const target = event.currentTarget as HTMLElement |
| const rect = target.getBoundingClientRect() |
| tooltipData.value = row |
| tooltipPosition.value.x = rect.right + 8 |
| tooltipPosition.value.y = rect.top + rect.height / 2 |
| tooltipVisible.value = true |
| } |
| |
| const hideTooltip = () => { |
| tooltipVisible.value = false |
| tooltipData.value = null |
| } |
| |
| |
| const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => { |
| const target = event.currentTarget as HTMLElement |
| const rect = target.getBoundingClientRect() |
| tokenTooltipData.value = row |
| tokenTooltipPosition.value.x = rect.right + 8 |
| tokenTooltipPosition.value.y = rect.top + rect.height / 2 |
| tokenTooltipVisible.value = true |
| } |
| |
| const hideTokenTooltip = () => { |
| tokenTooltipVisible.value = false |
| tokenTooltipData.value = null |
| } |
| </script> |
| |