mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge pull request #509 from beilo/feat/key-ranking-usage
Add paginated API key usage leaderboard
This commit is contained in:
@@ -7,23 +7,12 @@
|
|||||||
<SelectValue placeholder="选择时间段" />
|
<SelectValue placeholder="选择时间段" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent :searchable="false">
|
<SelectContent :searchable="false">
|
||||||
<SelectItem value="today">
|
<SelectItem
|
||||||
今天
|
v-for="preset in activePresetOptions"
|
||||||
</SelectItem>
|
:key="preset"
|
||||||
<SelectItem value="yesterday">
|
:value="preset"
|
||||||
昨天
|
>
|
||||||
</SelectItem>
|
{{ presetLabels[preset] }}
|
||||||
<SelectItem value="last7days">
|
|
||||||
最近7天
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last30days">
|
|
||||||
最近30天
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last90days">
|
|
||||||
最近90天
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="custom">
|
|
||||||
自定义
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -85,27 +74,51 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
||||||
|
type SelectablePreset = typeof selectablePresets[number]
|
||||||
|
|
||||||
|
const presetLabels: Record<SelectablePreset, string> = {
|
||||||
|
today: '今天',
|
||||||
|
yesterday: '昨天',
|
||||||
|
last7days: '最近7天',
|
||||||
|
last30days: '最近30天',
|
||||||
|
last90days: '最近90天',
|
||||||
|
custom: '自定义'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: DateRangeParams
|
modelValue: DateRangeParams
|
||||||
showGranularity?: boolean
|
showGranularity?: boolean
|
||||||
allowHourly?: boolean
|
allowHourly?: boolean
|
||||||
}>()
|
presetOptions?: SelectablePreset[]
|
||||||
|
}>(), {
|
||||||
|
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: DateRangeParams]
|
'update:modelValue': [value: DateRangeParams]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
const activePresetOptions = computed<SelectablePreset[]>(() => {
|
||||||
type SelectablePreset = typeof selectablePresets[number]
|
const unique = new Set(props.presetOptions)
|
||||||
|
const filtered = selectablePresets.filter((preset) => unique.has(preset))
|
||||||
|
return filtered.length > 0 ? filtered : [...selectablePresets]
|
||||||
|
})
|
||||||
|
|
||||||
|
function defaultPreset(): SelectablePreset {
|
||||||
|
const options = activePresetOptions.value
|
||||||
|
if (options.includes('last7days')) return 'last7days'
|
||||||
|
return options[0] ?? 'last7days'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePreset(value: DateRangeParams): SelectablePreset {
|
function normalizePreset(value: DateRangeParams): SelectablePreset {
|
||||||
if (value.preset && selectablePresets.includes(value.preset as SelectablePreset)) {
|
if (value.preset && activePresetOptions.value.includes(value.preset as SelectablePreset)) {
|
||||||
return value.preset as SelectablePreset
|
return value.preset as SelectablePreset
|
||||||
}
|
}
|
||||||
if (!value.preset && (value.start_date || value.end_date)) {
|
if (!value.preset && (value.start_date || value.end_date) && activePresetOptions.value.includes('custom')) {
|
||||||
return 'custom'
|
return 'custom'
|
||||||
}
|
}
|
||||||
return 'last7days'
|
return defaultPreset()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
|
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
|
||||||
@@ -168,6 +181,12 @@ watch(() => props.modelValue, (value) => {
|
|||||||
lastEmittedValue = getValueKey(value)
|
lastEmittedValue = getValueKey(value)
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(activePresetOptions, () => {
|
||||||
|
if (!activePresetOptions.value.includes(selectedPreset.value)) {
|
||||||
|
selectedPreset.value = normalizePreset(props.modelValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||||
if (!allowHourly.value || !canUseHourly.value) {
|
if (!allowHourly.value || !canUseHourly.value) {
|
||||||
if (selectedGranularity.value === 'hour') {
|
if (selectedGranularity.value === 'hour') {
|
||||||
|
|||||||
70
frontend/src/components/stats/LeaderboardControls.vue
Normal file
70
frontend/src/components/stats/LeaderboardControls.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<TimeRangePicker
|
||||||
|
:model-value="timeRange"
|
||||||
|
:show-granularity="false"
|
||||||
|
:preset-options="timeRangePresetOptions"
|
||||||
|
@update:model-value="$emit('update:timeRange', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
:model-value="metric"
|
||||||
|
@update:model-value="emitMetric"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-8 text-xs w-28">
|
||||||
|
<SelectValue placeholder="指标" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="requests">
|
||||||
|
请求数
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tokens">
|
||||||
|
Tokens
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cost">
|
||||||
|
成本
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TimeRangePicker } from '@/components/common'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui'
|
||||||
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
|
type LeaderboardMetric = 'requests' | 'tokens' | 'cost'
|
||||||
|
type LeaderboardTimeRangePreset = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days' | 'custom'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
metric: LeaderboardMetric
|
||||||
|
timeRange: DateRangeParams
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:metric', value: LeaderboardMetric): void
|
||||||
|
(e: 'update:timeRange', value: DateRangeParams): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const timeRangePresetOptions: LeaderboardTimeRangePreset[] = [
|
||||||
|
'today',
|
||||||
|
'yesterday',
|
||||||
|
'last7days',
|
||||||
|
'last30days',
|
||||||
|
'last90days',
|
||||||
|
'custom'
|
||||||
|
]
|
||||||
|
|
||||||
|
function emitMetric(value: string) {
|
||||||
|
if (value === 'requests' || value === 'tokens' || value === 'cost') {
|
||||||
|
emit('update:metric', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<TableCard :title="title">
|
<TableCard :title="title">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<slot name="actions">
|
||||||
<Select
|
<Select
|
||||||
v-if="showMetricSelect"
|
v-if="showMetricSelect"
|
||||||
:model-value="metric"
|
:model-value="metric"
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -77,6 +79,8 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
<slot name="pagination" />
|
||||||
</TableCard>
|
</TableCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
||||||
|
export { default as LeaderboardControls } from './LeaderboardControls.vue'
|
||||||
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
||||||
export { default as CostForecastChart } from './CostForecastChart.vue'
|
export { default as CostForecastChart } from './CostForecastChart.vue'
|
||||||
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
|
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
|
||||||
|
|||||||
@@ -64,6 +64,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LeaderboardTable
|
||||||
|
title="API Key 用量排行"
|
||||||
|
:items="apiKeyLeaderboard"
|
||||||
|
:metric="apiKeyLeaderboardMetric"
|
||||||
|
:loading="apiKeyLeaderboardLoading"
|
||||||
|
:show-metric-select="false"
|
||||||
|
@update:metric="apiKeyLeaderboardMetric = $event"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<LeaderboardControls
|
||||||
|
:metric="apiKeyLeaderboardMetric"
|
||||||
|
:time-range="apiKeyLeaderboardTimeRange"
|
||||||
|
@update:metric="apiKeyLeaderboardMetric = $event"
|
||||||
|
@update:time-range="apiKeyLeaderboardTimeRange = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #pagination>
|
||||||
|
<Pagination
|
||||||
|
v-if="apiKeyLeaderboardTotal > 0"
|
||||||
|
:current="apiKeyLeaderboardPage"
|
||||||
|
:total="apiKeyLeaderboardTotal"
|
||||||
|
:page-size="apiKeyLeaderboardPageSize"
|
||||||
|
:page-size-options="apiKeyLeaderboardPageSizeOptions"
|
||||||
|
@update:current="apiKeyLeaderboardPage = $event"
|
||||||
|
@update:page-size="apiKeyLeaderboardPageSize = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LeaderboardTable>
|
||||||
|
|
||||||
<UsageProviderTable
|
<UsageProviderTable
|
||||||
:data="providerStats"
|
:data="providerStats"
|
||||||
:is-admin="true"
|
:is-admin="true"
|
||||||
@@ -74,10 +103,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
|
import { Pagination } from '@/components/ui'
|
||||||
import { TimeRangePicker } from '@/components/common'
|
import { TimeRangePicker } from '@/components/common'
|
||||||
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
|
import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
|
||||||
import { UsageProviderTable } from '@/features/usage/components'
|
import { UsageProviderTable } from '@/features/usage/components'
|
||||||
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
|
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
|
||||||
import { usageApi } from '@/api/usage'
|
import { usageApi } from '@/api/usage'
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
@@ -90,16 +120,26 @@ const forecast = ref<CostForecastResponse | null>(null)
|
|||||||
const costSavings = ref<CostSavingsResponse | null>(null)
|
const costSavings = ref<CostSavingsResponse | null>(null)
|
||||||
const quotaProviders = ref<QuotaUsageProvider[]>([])
|
const quotaProviders = ref<QuotaUsageProvider[]>([])
|
||||||
const providerStats = ref<ProviderStatsItem[]>([])
|
const providerStats = ref<ProviderStatsItem[]>([])
|
||||||
|
const apiKeyLeaderboard = ref<LeaderboardItem[]>([])
|
||||||
|
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
|
||||||
|
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
||||||
|
const apiKeyLeaderboardPage = ref(1)
|
||||||
|
const apiKeyLeaderboardPageSize = ref(10)
|
||||||
|
const apiKeyLeaderboardTotal = ref(0)
|
||||||
|
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
|
||||||
|
|
||||||
const forecastLoading = ref(false)
|
const forecastLoading = ref(false)
|
||||||
const quotaLoading = ref(false)
|
const quotaLoading = ref(false)
|
||||||
|
const apiKeyLeaderboardLoading = ref(false)
|
||||||
let forecastRequestId = 0
|
let forecastRequestId = 0
|
||||||
let savingsRequestId = 0
|
let savingsRequestId = 0
|
||||||
let quotaRequestId = 0
|
let quotaRequestId = 0
|
||||||
let providerStatsRequestId = 0
|
let providerStatsRequestId = 0
|
||||||
|
let apiKeyLeaderboardRequestId = 0
|
||||||
let loadAllPromise: Promise<void> | null = null
|
let loadAllPromise: Promise<void> | null = null
|
||||||
let hasPendingLoadAll = false
|
let hasPendingLoadAll = false
|
||||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const forecastHistory = computed(() => forecast.value?.history || [])
|
const forecastHistory = computed(() => forecast.value?.history || [])
|
||||||
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
||||||
@@ -159,12 +199,55 @@ async function loadProviderStats() {
|
|||||||
providerStats.value = stats
|
providerStats.value = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadApiKeyLeaderboard() {
|
||||||
|
const requestId = ++apiKeyLeaderboardRequestId
|
||||||
|
apiKeyLeaderboardLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getLeaderboardApiKeys({
|
||||||
|
...buildApiKeyLeaderboardTimeRangeParams(),
|
||||||
|
metric: apiKeyLeaderboardMetric.value,
|
||||||
|
order: 'desc',
|
||||||
|
limit: apiKeyLeaderboardPageSize.value,
|
||||||
|
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
|
||||||
|
include_inactive: false,
|
||||||
|
exclude_admin: false
|
||||||
|
})
|
||||||
|
if (requestId !== apiKeyLeaderboardRequestId) return
|
||||||
|
apiKeyLeaderboard.value = response.items
|
||||||
|
apiKeyLeaderboardTotal.value = response.total
|
||||||
|
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
|
||||||
|
apiKeyLeaderboardPage.value = 1
|
||||||
|
scheduleApiKeyLeaderboardLoad()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === apiKeyLeaderboardRequestId) {
|
||||||
|
apiKeyLeaderboardLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiKeyLeaderboardTimeRangeParams() {
|
||||||
|
return {
|
||||||
|
start_date: apiKeyLeaderboardTimeRange.value.start_date,
|
||||||
|
end_date: apiKeyLeaderboardTimeRange.value.end_date,
|
||||||
|
preset: apiKeyLeaderboardTimeRange.value.preset,
|
||||||
|
timezone: apiKeyLeaderboardTimeRange.value.timezone,
|
||||||
|
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
if (loadAllPromise) {
|
if (loadAllPromise) {
|
||||||
hasPendingLoadAll = true
|
hasPendingLoadAll = true
|
||||||
return loadAllPromise
|
return loadAllPromise
|
||||||
}
|
}
|
||||||
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
loadAllPromise = Promise.all([
|
||||||
|
loadForecast(),
|
||||||
|
loadSavings(),
|
||||||
|
loadQuotaUsage(),
|
||||||
|
loadProviderStats(),
|
||||||
|
loadApiKeyLeaderboard()
|
||||||
|
])
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadAllPromise = null
|
loadAllPromise = null
|
||||||
@@ -186,7 +269,36 @@ function scheduleLoadAll() {
|
|||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
function scheduleApiKeyLeaderboardLoad() {
|
||||||
|
if (apiKeyLeaderboardDebounceTimer) {
|
||||||
|
clearTimeout(apiKeyLeaderboardDebounceTimer)
|
||||||
|
}
|
||||||
|
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
|
||||||
|
apiKeyLeaderboardDebounceTimer = null
|
||||||
|
void loadApiKeyLeaderboard()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetApiKeyLeaderboardPage() {
|
||||||
|
if (apiKeyLeaderboardPage.value === 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKeyLeaderboardPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(timeRange, () => {
|
||||||
|
resetApiKeyLeaderboardPage()
|
||||||
|
scheduleLoadAll()
|
||||||
|
}, { deep: true })
|
||||||
|
watch(apiKeyLeaderboardMetric, () => {
|
||||||
|
resetApiKeyLeaderboardPage()
|
||||||
|
scheduleApiKeyLeaderboardLoad()
|
||||||
|
})
|
||||||
|
watch(apiKeyLeaderboardTimeRange, () => {
|
||||||
|
resetApiKeyLeaderboardPage()
|
||||||
|
scheduleApiKeyLeaderboardLoad()
|
||||||
|
}, { deep: true })
|
||||||
|
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadAll()
|
void loadAll()
|
||||||
@@ -197,11 +309,16 @@ onUnmounted(() => {
|
|||||||
clearTimeout(loadAllDebounceTimer)
|
clearTimeout(loadAllDebounceTimer)
|
||||||
loadAllDebounceTimer = null
|
loadAllDebounceTimer = null
|
||||||
}
|
}
|
||||||
|
if (apiKeyLeaderboardDebounceTimer) {
|
||||||
|
clearTimeout(apiKeyLeaderboardDebounceTimer)
|
||||||
|
apiKeyLeaderboardDebounceTimer = null
|
||||||
|
}
|
||||||
hasPendingLoadAll = false
|
hasPendingLoadAll = false
|
||||||
loadAllPromise = null
|
loadAllPromise = null
|
||||||
forecastRequestId += 1
|
forecastRequestId += 1
|
||||||
savingsRequestId += 1
|
savingsRequestId += 1
|
||||||
quotaRequestId += 1
|
quotaRequestId += 1
|
||||||
providerStatsRequestId += 1
|
providerStatsRequestId += 1
|
||||||
|
apiKeyLeaderboardRequestId += 1
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user