Commit eefab159 authored by Ethan0x0000's avatar Ethan0x0000
Browse files

feat: 完善使用记录端点可观测性与分布统计

将入站、上游与路径三类端点分布统一到使用记录页的一致化卡片交互中,并补齐端点元数据与统计链路,提升排障与流量分析效率。
parent 6da5fa01
......@@ -723,6 +723,13 @@ export default {
preparingExport: '正在准备导出...',
model: '模型',
reasoningEffort: '推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
inbound: '入站',
upstream: '上游',
path: '路径',
inboundEndpoint: '入站端点',
upstreamEndpoint: '上游端点',
type: '类型',
tokens: 'Token',
cost: '费用',
......
......@@ -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 ====================
......
......@@ -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)
})
}
}
......
......@@ -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,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment