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()