merge: sync upstream/main before PR
This commit is contained in:
@@ -82,6 +82,7 @@
|
||||
:utilization="usageInfo.five_hour.utilization"
|
||||
:resets-at="usageInfo.five_hour.resets_at"
|
||||
:window-stats="usageInfo.five_hour.window_stats"
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
@@ -90,6 +91,7 @@
|
||||
:utilization="usageInfo.seven_day.utilization"
|
||||
:resets-at="usageInfo.seven_day.resets_at"
|
||||
:window-stats="usageInfo.seven_day.window_stats"
|
||||
:show-now-when-idle="true"
|
||||
color="emerald"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</span>
|
||||
|
||||
<!-- Reset time -->
|
||||
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
|
||||
<span v-if="shouldShowResetTime" class="shrink-0 text-[10px] text-gray-400">
|
||||
{{ formatResetTime }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -68,6 +68,7 @@ const props = defineProps<{
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple' | 'amber'
|
||||
windowStats?: WindowStats | null
|
||||
showNowWhenIdle?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -139,9 +140,20 @@ const displayPercent = computed(() => {
|
||||
return percent > 999 ? '>999%' : `${percent}%`
|
||||
})
|
||||
|
||||
const shouldShowResetTime = computed(() => {
|
||||
if (props.resetsAt) return true
|
||||
return Boolean(props.showNowWhenIdle && props.utilization <= 0)
|
||||
})
|
||||
|
||||
// Format reset time
|
||||
const formatResetTime = computed(() => {
|
||||
// For rolling windows, when utilization is 0%, treat as immediately available.
|
||||
if (props.showNowWhenIdle && props.utilization <= 0) {
|
||||
return '现在'
|
||||
}
|
||||
|
||||
if (!props.resetsAt) return '-'
|
||||
|
||||
const date = new Date(props.resetsAt)
|
||||
const diffMs = date.getTime() - now.value.getTime()
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UsageProgressBar from '../UsageProgressBar.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsageProgressBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-17T00:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '5h',
|
||||
utilization: 0,
|
||||
resetsAt: '2026-03-17T02:30:00Z',
|
||||
showNowWhenIdle: true,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('现在')
|
||||
expect(wrapper.text()).not.toContain('2h 30m')
|
||||
})
|
||||
|
||||
it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '7d',
|
||||
utilization: 12,
|
||||
resetsAt: '2026-03-17T02:30:00Z',
|
||||
showNowWhenIdle: true,
|
||||
color: 'emerald'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('2h 30m')
|
||||
expect(wrapper.text()).not.toContain('现在')
|
||||
})
|
||||
|
||||
it('showNowWhenIdle=false 时保持原有倒计时行为', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '1d',
|
||||
utilization: 0,
|
||||
resetsAt: '2026-03-17T02:30:00Z',
|
||||
showNowWhenIdle: false,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('2h 30m')
|
||||
expect(wrapper.text()).not.toContain('现在')
|
||||
})
|
||||
})
|
||||
@@ -25,8 +25,16 @@
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<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 }">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="mb-4 flex items-center 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 class="flex flex-wrap items-center justify-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"
|
||||
|
||||
@@ -6,7 +6,42 @@
|
||||
? t('admin.dashboard.modelDistribution')
|
||||
: t('admin.dashboard.spendingRankingTitle') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-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 === 'requested'
|
||||
? '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', 'requested')"
|
||||
>
|
||||
{{ t('usage.requestedModel') }}
|
||||
</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.upstreamModel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="source === 'mapping'
|
||||
? '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', 'mapping')"
|
||||
>
|
||||
{{ t('usage.mapping') }}
|
||||
</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"
|
||||
@@ -215,9 +250,13 @@ ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
const { t } = useI18n()
|
||||
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
type ModelSource = 'requested' | 'upstream' | 'mapping'
|
||||
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
|
||||
const props = withDefaults(defineProps<{
|
||||
modelStats: ModelStat[]
|
||||
upstreamModelStats?: ModelStat[]
|
||||
mappingModelStats?: ModelStat[]
|
||||
source?: ModelSource
|
||||
enableRankingView?: boolean
|
||||
rankingItems?: UserSpendingRankingItem[]
|
||||
rankingTotalActualCost?: number
|
||||
@@ -225,12 +264,16 @@ const props = withDefaults(defineProps<{
|
||||
rankingTotalTokens?: number
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showSourceToggle?: boolean
|
||||
showMetricToggle?: boolean
|
||||
rankingLoading?: boolean
|
||||
rankingError?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>(), {
|
||||
upstreamModelStats: () => [],
|
||||
mappingModelStats: () => [],
|
||||
source: 'requested',
|
||||
enableRankingView: false,
|
||||
rankingItems: () => [],
|
||||
rankingTotalActualCost: 0,
|
||||
@@ -238,6 +281,7 @@ const props = withDefaults(defineProps<{
|
||||
rankingTotalTokens: 0,
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
showSourceToggle: false,
|
||||
showMetricToggle: false,
|
||||
rankingLoading: false,
|
||||
rankingError: false
|
||||
@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => {
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
model: id,
|
||||
model_source: props.source,
|
||||
})
|
||||
breakdownItems.value = res.users || []
|
||||
} catch {
|
||||
@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
'update:source': [value: ModelSource]
|
||||
'ranking-click': [item: UserSpendingRankingItem]
|
||||
}>()
|
||||
|
||||
@@ -294,14 +340,19 @@ const chartColors = [
|
||||
]
|
||||
|
||||
const displayModelStats = computed(() => {
|
||||
if (!props.modelStats?.length) return []
|
||||
const sourceStats = props.source === 'upstream'
|
||||
? props.upstreamModelStats
|
||||
: props.source === 'mapping'
|
||||
? props.mappingModelStats
|
||||
: props.modelStats
|
||||
if (!sourceStats?.length) return []
|
||||
|
||||
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
||||
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.modelStats?.length) return null
|
||||
if (!displayModelStats.value.length) return null
|
||||
|
||||
return {
|
||||
labels: displayModelStats.value.map((m) => m.model),
|
||||
|
||||
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- 并发槽位 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(concurrencyUsed, concurrencyMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ concurrencyUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ concurrencyMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话数 -->
|
||||
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(sessionsUsed, sessionsMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ sessionsUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ sessionsMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- RPM -->
|
||||
<div v-if="rpmMax > 0" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(rpmUsed, rpmMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ rpmUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ rpmMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
concurrencyUsed: number
|
||||
concurrencyMax: number
|
||||
sessionsUsed: number
|
||||
sessionsMax: number
|
||||
rpmUsed: number
|
||||
rpmMax: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
concurrencyUsed: 0,
|
||||
concurrencyMax: 0,
|
||||
sessionsUsed: 0,
|
||||
sessionsMax: 0,
|
||||
rpmUsed: 0,
|
||||
rpmMax: 0
|
||||
})
|
||||
|
||||
function capacityClass(used: number, max: number): string {
|
||||
if (max > 0 && used >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (used > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user