Merge pull request #1019 from Ethan0x0000/feat/usage-endpoint-distribution

feat: add endpoint metadata and usage endpoint distribution insights
This commit is contained in:
Wesley Liddick
2026-03-15 16:42:03 +08:00
committed by GitHub
26 changed files with 894 additions and 37 deletions

View File

@@ -5,6 +5,7 @@
import { apiClient } from '../client'
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse, UsageRequestType } from '@/types'
import type { EndpointStat } from '@/types'
// ==================== Types ====================
@@ -18,6 +19,9 @@ export interface AdminUsageStatsResponse {
total_actual_cost: number
total_account_cost?: number
average_duration_ms: number
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
endpoint_paths?: EndpointStat[]
}
export interface SimpleUser {

View File

@@ -446,6 +446,18 @@
<!-- Model Distribution -->
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
<EndpointDistributionChart
:endpoint-stats="stats.endpoints || []"
:loading="false"
:title="t('usage.inboundEndpoint')"
/>
<EndpointDistributionChart
:endpoint-stats="stats.upstream_endpoints || []"
:loading="false"
:title="t('usage.upstreamEndpoint')"
/>
</template>
<!-- No Data State -->
@@ -489,6 +501,7 @@ import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'

View File

@@ -410,6 +410,18 @@
<!-- Model Distribution -->
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
<EndpointDistributionChart
:endpoint-stats="stats.endpoints || []"
:loading="false"
:title="t('usage.inboundEndpoint')"
/>
<EndpointDistributionChart
:endpoint-stats="stats.upstream_endpoints || []"
:loading="false"
:title="t('usage.upstreamEndpoint')"
/>
</template>
<!-- No Data State -->
@@ -453,6 +465,7 @@ import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'

View File

@@ -35,6 +35,19 @@
</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 }}
@@ -328,6 +341,7 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
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`

View File

@@ -0,0 +1,257 @@
<template>
<div class="card p-4">
<div class="mb-4 flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ title || t('usage.endpointDistribution') }}
</h3>
<div class="flex flex-col items-end gap-2">
<div
v-if="showSourceToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'inbound'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'inbound')"
>
{{ t('usage.inbound') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'upstream'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'upstream')"
>
{{ t('usage.upstream') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'path'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'path')"
>
{{ t('usage.path') }}
</button>
</div>
<div
v-if="showMetricToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'tokens')"
>
{{ t('admin.dashboard.metricTokens') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'actual_cost')"
>
{{ t('admin.dashboard.metricActualCost') }}
</button>
</div>
</div>
</div>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="displayEndpointStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="pb-2 text-left">{{ t('usage.endpoint') }}</th>
<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.standard') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in displayEndpointStats"
:key="item.endpoint"
class="border-t border-gray-100 dark:border-gray-700"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="item.endpoint">
{{ item.endpoint }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(item.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(item.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(item.cost) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { EndpointStat } from '@/types'
ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost'
type EndpointSource = 'inbound' | 'upstream' | 'path'
const props = withDefaults(
defineProps<{
endpointStats: EndpointStat[]
upstreamEndpointStats?: EndpointStat[]
endpointPathStats?: EndpointStat[]
loading?: boolean
title?: string
metric?: DistributionMetric
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
}>(),
{
upstreamEndpointStats: () => [],
endpointPathStats: () => [],
loading: false,
title: '',
metric: 'tokens',
source: 'inbound',
showMetricToggle: false,
showSourceToggle: false
}
)
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
'update:source': [value: EndpointSource]
}>()
const chartColors = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
'#06b6d4',
'#a855f7'
]
const displayEndpointStats = computed(() => {
const sourceStats = props.source === 'upstream'
? props.upstreamEndpointStats
: props.source === 'path'
? props.endpointPathStats
: props.endpointStats
if (!sourceStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
})
const chartData = computed(() => {
if (!displayEndpointStats.value?.length) return null
return {
labels: displayEndpointStats.value.map((item) => item.endpoint),
datasets: [
{
data: displayEndpointStats.value.map((item) =>
props.metric === 'actual_cost' ? item.actual_cost : item.total_tokens
),
backgroundColor: chartColors.slice(0, displayEndpointStats.value.length),
borderWidth: 0
}
]
}
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
const formattedValue = props.metric === 'actual_cost'
? `$${formatCost(value)}`
: formatTokens(value)
return `${context.label}: ${formattedValue} (${percentage}%)`
}
}
}
}
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
</script>

View File

@@ -718,6 +718,13 @@ export default {
preparingExport: 'Preparing export...',
model: 'Model',
reasoningEffort: 'Reasoning Effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
inbound: 'Inbound',
upstream: 'Upstream',
path: 'Path',
inboundEndpoint: 'Inbound Endpoint',
upstreamEndpoint: 'Upstream Endpoint',
type: 'Type',
tokens: 'Tokens',
cost: 'Cost',

View File

@@ -723,6 +723,13 @@ export default {
preparingExport: '正在准备导出...',
model: '模型',
reasoningEffort: '推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
inbound: '入站',
upstream: '上游',
path: '路径',
inboundEndpoint: '入站端点',
upstreamEndpoint: '上游端点',
type: '类型',
tokens: 'Token',
cost: '费用',

View File

@@ -962,6 +962,8 @@ export interface UsageLog {
model: string
service_tier?: string | null
reasoning_effort?: string | null
inbound_endpoint?: string | null
upstream_endpoint?: string | null
group_id: number | null
subscription_id: number | null
@@ -1168,6 +1170,14 @@ export interface ModelStat {
actual_cost: number // 实际扣除
}
export interface EndpointStat {
endpoint: string
requests: number
total_tokens: number
cost: number
actual_cost: number
}
export interface GroupStat {
group_id: number
group_name: string
@@ -1362,6 +1372,8 @@ export interface AccountUsageStatsResponse {
history: AccountUsageHistory[]
summary: AccountUsageSummary
models: ModelStat[]
endpoints: EndpointStat[]
upstream_endpoints: EndpointStat[]
}
// ==================== User Attribute Types ====================

View File

@@ -26,7 +26,20 @@
:show-metric-toggle="true"
/>
</div>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<EndpointDistributionChart
v-model:source="endpointDistributionSource"
v-model:metric="endpointDistributionMetric"
:endpoint-stats="inboundEndpointStats"
:upstream-endpoint-stats="upstreamEndpointStats"
:endpoint-path-stats="endpointPathStats"
:loading="endpointStatsLoading"
:show-source-toggle="true"
:show-metric-toggle="true"
:title="t('usage.endpointDistribution')"
/>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
</div>
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<template #after-reset>
@@ -99,19 +112,28 @@ import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageEx
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, EndpointStat, AdminUser } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
const { t } = useI18n()
const appStore = useAppStore()
type DistributionMetric = 'tokens' | 'actual_cost'
type EndpointSource = 'inbound' | 'upstream' | 'path'
const route = useRoute()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
const modelDistributionMetric = ref<DistributionMetric>('tokens')
const groupDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionSource = ref<EndpointSource>('inbound')
const inboundEndpointStats = ref<EndpointStat[]>([])
const upstreamEndpointStats = ref<EndpointStat[]>([])
const endpointPathStats = ref<EndpointStat[]>([])
const endpointStatsLoading = ref(false)
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
let chartReqSeq = 0
let statsReqSeq = 0
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const cleanupDialogVisible = ref(false)
// Balance history modal state
@@ -183,13 +205,25 @@ const loadLogs = async () => {
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
}
const loadStats = async () => {
const seq = ++statsReqSeq
endpointStatsLoading.value = true
try {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const s = await adminAPI.usage.getStats({ ...filters.value, stream: legacyStream === null ? undefined : legacyStream })
if (seq !== statsReqSeq) return
usageStats.value = s
inboundEndpointStats.value = s.endpoints || []
upstreamEndpointStats.value = s.upstream_endpoints || []
endpointPathStats.value = s.endpoint_paths || []
} catch (error) {
if (seq !== statsReqSeq) return
console.error('Failed to load usage stats:', error)
inboundEndpointStats.value = []
upstreamEndpointStats.value = []
endpointPathStats.value = []
} finally {
if (seq === statsReqSeq) endpointStatsLoading.value = false
}
}
const loadChartData = async () => {
@@ -246,6 +280,7 @@ const exportToExcel = async () => {
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
@@ -263,7 +298,8 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', getRequestTypeLabel(log),
formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
@@ -301,6 +337,7 @@ const allColumns = computed(() => [
{ key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
@@ -343,12 +380,18 @@ const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) {
(JSON.parse(saved) as string[]).forEach(key => hiddenColumns.add(key))
(JSON.parse(saved) as string[]).forEach((key) => {
hiddenColumns.add(key)
})
} else {
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
DEFAULT_HIDDEN_COLUMNS.forEach((key) => {
hiddenColumns.add(key)
})
}
} catch {
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
DEFAULT_HIDDEN_COLUMNS.forEach((key) => {
hiddenColumns.add(key)
})
}
}

View File

@@ -166,6 +166,12 @@
</span>
</template>
<template #cell-endpoint="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300 block max-w-[320px] whitespace-normal break-all">
{{ formatUsageEndpoints(row) }}
</span>
</template>
<template #cell-stream="{ row }">
<span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
@@ -516,6 +522,7 @@ const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
@@ -615,6 +622,11 @@ const getRequestTypeExportText = (log: UsageLog): string => {
return 'Unknown'
}
const formatUsageEndpoints = (log: UsageLog): string => {
const inbound = log.inbound_endpoint?.trim()
return inbound || '-'
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -789,6 +801,7 @@ const exportToCSV = async () => {
'API Key Name',
'Model',
'Reasoning Effort',
'Inbound Endpoint',
'Type',
'Input Tokens',
'Output Tokens',
@@ -806,6 +819,7 @@ const exportToCSV = async () => {
log.api_key?.name || '',
log.model,
formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '',
getRequestTypeExportText(log),
log.input_tokens,
log.output_tokens,