From 8147866c09a6e970bf04a2caeb709cadd6630a83 Mon Sep 17 00:00:00 2001 From: Peter <1tRq4X287b7W7sfKf9GsWI+Peter@noreply.cnb.cool> Date: Mon, 16 Mar 2026 00:17:47 +0800 Subject: [PATCH] fix(admin): polish spending ranking and usage defaults --- .../handler/admin/dashboard_handler.go | 2 + .../dashboard_handler_request_type_test.go | 4 ++ .../pkg/usagestats/usage_log_types.go | 2 + backend/internal/repository/usage_log_repo.go | 14 +++- .../usage_log_repo_request_type_test.go | 10 +-- .../charts/ModelDistributionChart.vue | 69 +++++++++++++++---- .../__tests__/ModelDistributionChart.spec.ts | 52 ++++++++++++++ frontend/src/types/index.ts | 2 + frontend/src/views/admin/DashboardView.vue | 10 ++- frontend/src/views/admin/UsageView.vue | 6 +- 10 files changed, 148 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index cc4ef2d0..f415b48f 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -512,6 +512,8 @@ func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) { payload := gin.H{ "ranking": ranking.Ranking, "total_actual_cost": ranking.TotalActualCost, + "total_requests": ranking.TotalRequests, + "total_tokens": ranking.TotalTokens, "start_date": startTime.Format("2006-01-02"), "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"), } diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 6b363bb5..9aec61d4 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -61,6 +61,8 @@ func (s *dashboardUsageRepoCapture) GetUserSpendingRanking( return &usagestats.UserSpendingRankingResponse{ Ranking: s.ranking, TotalActualCost: s.rankingTotal, + TotalRequests: 44, + TotalTokens: 1234, }, nil } @@ -164,6 +166,8 @@ func TestDashboardUsersRankingLimitAndCache(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, 50, repo.rankingLimit) require.Contains(t, rec.Body.String(), "\"total_actual_cost\":88.8") + require.Contains(t, rec.Body.String(), "\"total_requests\":44") + require.Contains(t, rec.Body.String(), "\"total_tokens\":1234") require.Equal(t, "miss", rec.Header().Get("X-Snapshot-Cache")) req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil) diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 55a049d3..6b980dc8 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -116,6 +116,8 @@ type UserSpendingRankingItem struct { type UserSpendingRankingResponse struct { Ranking []UserSpendingRankingItem `json:"ranking"` TotalActualCost float64 `json:"total_actual_cost"` + TotalRequests int64 `json:"total_requests"` + TotalTokens int64 `json:"total_tokens"` } // APIKeyUsageTrendPoint represents API key usage trend data point diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 845f2cf0..8ee21d95 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -2139,7 +2139,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi actual_cost, requests, tokens, - COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost + COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost, + COALESCE(SUM(requests) OVER (), 0) as total_requests, + COALESCE(SUM(tokens) OVER (), 0) as total_tokens FROM user_spend ORDER BY actual_cost DESC, tokens DESC, user_id ASC LIMIT $3 @@ -2150,7 +2152,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi actual_cost, requests, tokens, - total_actual_cost + total_actual_cost, + total_requests, + total_tokens FROM ranked ORDER BY actual_cost DESC, tokens DESC, user_id ASC ` @@ -2168,9 +2172,11 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi ranking := make([]UserSpendingRankingItem, 0) totalActualCost := 0.0 + totalRequests := int64(0) + totalTokens := int64(0) for rows.Next() { var row UserSpendingRankingItem - if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost); err != nil { + if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { return nil, err } ranking = append(ranking, row) @@ -2182,6 +2188,8 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi return &UserSpendingRankingResponse{ Ranking: ranking, TotalActualCost: totalActualCost, + TotalRequests: totalRequests, + TotalTokens: totalTokens, }, nil } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index bcb23717..f1bf1f1d 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -255,10 +255,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) end := start.Add(24 * time.Hour) - rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost"}). - AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0). - AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0). - AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0) + rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}). + AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)). + AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)). + AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600)) mock.ExpectQuery("WITH user_spend AS \\("). WithArgs(start, end, 12). @@ -273,6 +273,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { {UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300}, }, TotalActualCost: 40.0, + TotalRequests: 30, + TotalTokens: 2600, }, got) require.NoError(t, mock.ExpectationsWereMet()) } diff --git a/frontend/src/components/charts/ModelDistributionChart.vue b/frontend/src/components/charts/ModelDistributionChart.vue index 5db5a14f..5ae9b38e 100644 --- a/frontend/src/components/charts/ModelDistributionChart.vue +++ b/frontend/src/components/charts/ModelDistributionChart.vue @@ -127,7 +127,7 @@ > {{ t('admin.dashboard.failedToLoad') }} -
+
@@ -143,21 +143,24 @@
- #{{ index + 1 }} + {{ item.isOther ? 'Σ' : `#${index + 1}` }} - {{ getRankingUserLabel(item) }} + {{ getRankingRowLabel(item) }}
@@ -197,11 +200,14 @@ ChartJS.register(ArcElement, Tooltip, Legend) const { t } = useI18n() type DistributionMetric = 'tokens' | 'actual_cost' +type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean } const props = withDefaults(defineProps<{ modelStats: ModelStat[] enableRankingView?: boolean rankingItems?: UserSpendingRankingItem[] rankingTotalActualCost?: number + rankingTotalRequests?: number + rankingTotalTokens?: number loading?: boolean metric?: DistributionMetric showMetricToggle?: boolean @@ -211,6 +217,8 @@ const props = withDefaults(defineProps<{ enableRankingView: false, rankingItems: () => [], rankingTotalActualCost: 0, + rankingTotalRequests: 0, + rankingTotalTokens: 0, loading: false, metric: 'tokens', showMetricToggle: false, @@ -266,14 +274,14 @@ const chartData = computed(() => { const rankingChartData = computed(() => { if (!props.rankingItems?.length) return null - const rankedTotal = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0) - const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedTotal, 0) const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`) const data = props.rankingItems.map((item) => item.actual_cost) + const backgroundColor = chartColors.slice(0, props.rankingItems.length) - if (otherActualCost > 0.000001) { + if (otherRankingItem.value) { labels.push(t('admin.dashboard.spendingRankingOther')) - data.push(otherActualCost) + data.push(otherRankingItem.value.actual_cost) + backgroundColor.push('#94a3b8') } return { @@ -281,13 +289,43 @@ const rankingChartData = computed(() => { datasets: [ { data, - backgroundColor: chartColors.slice(0, data.length), + backgroundColor, borderWidth: 0 } ] } }) +const otherRankingItem = computed(() => { + if (!props.rankingItems?.length) return null + + const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0) + const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0) + const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0) + + const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0) + const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0) + const otherTokens = Math.max((props.rankingTotalTokens || 0) - rankedTokens, 0) + + if (otherActualCost <= 0.000001 && otherRequests <= 0 && otherTokens <= 0) return null + + return { + user_id: 0, + email: '', + actual_cost: otherActualCost, + requests: otherRequests, + tokens: otherTokens, + isOther: true + } +}) + +const rankingDisplayItems = computed(() => { + if (!props.rankingItems?.length) return [] + return otherRankingItem.value + ? [...props.rankingItems, otherRankingItem.value] + : [...props.rankingItems] +}) + const doughnutOptions = computed(() => ({ responsive: true, maintainAspectRatio: false, @@ -351,6 +389,11 @@ const getRankingUserLabel = (item: UserSpendingRankingItem): string => { return t('admin.redeem.userPrefix', { id: item.user_id }) } +const getRankingRowLabel = (item: RankingDisplayItem): string => { + if (item.isOther) return t('admin.dashboard.spendingRankingOther') + return getRankingUserLabel(item) +} + const formatCost = (value: number): string => { if (value >= 1000) { return (value / 1000).toFixed(2) + 'K' diff --git a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts index 27fb8bd4..82b62367 100644 --- a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts +++ b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts @@ -5,6 +5,14 @@ import ModelDistributionChart from '../ModelDistributionChart.vue' const messages: Record = { 'admin.dashboard.modelDistribution': 'Model Distribution', + 'admin.dashboard.spendingRankingTitle': 'User Spending Ranking', + 'admin.dashboard.viewModelDistribution': 'Model Distribution', + 'admin.dashboard.viewSpendingRanking': 'User Spending Ranking', + 'admin.dashboard.spendingRankingUser': 'User', + 'admin.dashboard.spendingRankingRequests': 'Requests', + 'admin.dashboard.spendingRankingTokens': 'Tokens', + 'admin.dashboard.spendingRankingSpend': 'Spend', + 'admin.dashboard.spendingRankingOther': 'Others', 'admin.dashboard.model': 'Model', 'admin.dashboard.requests': 'Requests', 'admin.dashboard.tokens': 'Tokens', @@ -13,6 +21,7 @@ const messages: Record = { 'admin.dashboard.metricTokens': 'By Tokens', 'admin.dashboard.metricActualCost': 'By Actual Cost', 'admin.dashboard.noDataAvailable': 'No data available', + 'admin.redeem.userPrefix': 'User #{id}', } vi.mock('vue-i18n', async () => { @@ -116,4 +125,47 @@ describe('ModelDistributionChart', () => { }) expect(label).toBe('model-b: $1.40 (87.5%)') }) + + it('renders Others in the spending ranking table and uses a dedicated chart color', async () => { + const wrapper = mount(ModelDistributionChart, { + props: { + modelStats: [], + enableRankingView: true, + rankingItems: [ + { user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 }, + { user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 }, + ], + rankingTotalActualCost: 30, + rankingTotalRequests: 20, + rankingTotalTokens: 2000, + }, + global: { + stubs: { + LoadingSpinner: true, + }, + }, + }) + + const rankingButton = wrapper.findAll('button').find((button) => button.text() === 'User Spending Ranking') + expect(rankingButton).toBeTruthy() + await rankingButton!.trigger('click') + + const chartData = JSON.parse(wrapper.find('.chart-data').text()) + expect(chartData.labels).toEqual([ + '#1 alpha@example.com', + '#2 beta@example.com', + 'Others', + ]) + expect(chartData.datasets[0].data).toEqual([12, 8, 10]) + expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6') + expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8') + expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0]) + + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(3) + expect(rows[2].text()).toContain('Others') + expect(rows[2].text()).toContain('4') + expect(rows[2].text()).toContain('400') + expect(rows[2].text()).toContain('$10.00') + }) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6f9bff76..de9ddf61 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1199,6 +1199,8 @@ export interface UserSpendingRankingItem { export interface UserSpendingRankingResponse { ranking: UserSpendingRankingItem[] total_actual_cost: number + total_requests: number + total_tokens: number start_date: string end_date: string } diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index 7d66b1f0..8b7ff632 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -241,6 +241,8 @@ :enable-ranking-view="true" :ranking-items="rankingItems" :ranking-total-actual-cost="rankingTotalActualCost" + :ranking-total-requests="rankingTotalRequests" + :ranking-total-tokens="rankingTotalTokens" :loading="chartsLoading" :ranking-loading="rankingLoading" :ranking-error="rankingError" @@ -334,6 +336,8 @@ const modelStats = ref([]) const userTrend = ref([]) const rankingItems = ref([]) const rankingTotalActualCost = ref(0) +const rankingTotalRequests = ref(0) +const rankingTotalTokens = ref(0) let chartLoadSeq = 0 let usersTrendLoadSeq = 0 let rankingLoadSeq = 0 @@ -347,7 +351,7 @@ const formatLocalDate = (date: Date): string => { const getTodayLocalDate = () => formatLocalDate(new Date()) // Date range -const granularity = ref<'day' | 'hour'>('day') +const granularity = ref<'day' | 'hour'>('hour') const startDate = ref(getTodayLocalDate()) const endDate = ref(getTodayLocalDate()) @@ -630,11 +634,15 @@ const loadUserSpendingRanking = async () => { if (currentSeq !== rankingLoadSeq) return rankingItems.value = response.ranking || [] rankingTotalActualCost.value = response.total_actual_cost || 0 + rankingTotalRequests.value = response.total_requests || 0 + rankingTotalTokens.value = response.total_tokens || 0 } catch (error) { if (currentSeq !== rankingLoadSeq) return console.error('Error loading user spending ranking:', error) rankingItems.value = [] rankingTotalActualCost.value = 0 + rankingTotalRequests.value = 0 + rankingTotalTokens.value = 0 rankingError.value = true } finally { if (currentSeq === rankingLoadSeq) { diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 5a498642..6b813057 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -107,7 +107,7 @@ const appStore = useAppStore() type DistributionMetric = 'tokens' | 'actual_cost' const route = useRoute() const usageStats = ref(null); const usageLogs = ref([]); const loading = ref(false); const exporting = ref(false) -const trendData = ref([]); const modelStats = ref([]); const groupStats = ref([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day') +const trendData = ref([]); const modelStats = ref([]); const groupStats = ref([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour') const modelDistributionMetric = ref('tokens') const groupDistributionMetric = ref('tokens') let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null @@ -137,6 +137,7 @@ const formatLD = (d: Date) => { return `${year}-${month}-${day}` } const getTodayLocalDate = () => formatLD(new Date()) +const getGranularityForRange = (start: string, end: string): 'day' | 'hour' => start === end ? 'hour' : 'day' const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDate()) const filters = ref({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value }) const pagination = reactive({ page: 1, page_size: 20, total: 0 }) @@ -171,6 +172,7 @@ const applyRouteQueryFilters = () => { start_date: startDate.value, end_date: endDate.value } + granularity.value = getGranularityForRange(startDate.value, endDate.value) } const loadLogs = async () => { @@ -224,7 +226,7 @@ const loadChartData = async () => { } const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() } const refreshData = () => { loadLogs(); loadStats(); loadChartData() } -const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() } +const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = getGranularityForRange(startDate.value, endDate.value); applyFilters() } const handlePageChange = (p: number) => { pagination.page = p; loadLogs() } const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() } const cancelExport = () => exportAbortController?.abort()