diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 42d0538c9b..22c8e775b1 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -500,7 +500,11 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) { limit = 12 } - trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit) + metric := c.DefaultQuery("metric", "tokens") + if metric != "tokens" && metric != "actual_cost" { + metric = "tokens" + } + trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit, metric) if err != nil { response.Error(c, 500, "Failed to get user usage trend") return diff --git a/backend/internal/handler/admin/dashboard_handler_cache_test.go b/backend/internal/handler/admin/dashboard_handler_cache_test.go index ec88884979..357d2edf10 100644 --- a/backend/internal/handler/admin/dashboard_handler_cache_test.go +++ b/backend/internal/handler/admin/dashboard_handler_cache_test.go @@ -45,6 +45,7 @@ func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend( startTime, endTime time.Time, granularity string, limit int, + metric string, ) ([]usagestats.UserUsageTrendPoint, error) { r.usersTrendCalls.Add(1) return []usagestats.UserUsageTrendPoint{{ @@ -115,4 +116,20 @@ func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) { require.Equal(t, http.StatusOK, rec2.Code) require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache")) require.Equal(t, int32(1), repo.usersTrendCalls.Load()) + + for _, tc := range []struct { + metric, cache string + calls int32 + }{ + {"actual_cost", "miss", 2}, + {"actual_cost", "hit", 2}, + {"tokens", "hit", 2}, + } { + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8&metric="+tc.metric, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, tc.cache, rec.Header().Get("X-Snapshot-Cache")) + require.Equal(t, tc.calls, repo.usersTrendCalls.Load()) + } } diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 61d38a66e9..4a6fe4e5fe 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -53,6 +53,7 @@ type dashboardEntityTrendCacheKey struct { EndTime string `json:"end_time"` Granularity string `json:"granularity"` Limit int `json:"limit"` + Metric string `json:"metric,omitempty"` } func cacheStatusValue(hit bool) string { @@ -213,15 +214,16 @@ func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startT return trend, hit, err } -func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) { +func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, bool, error) { key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{ StartTime: startTime.UTC().Format(time.RFC3339), EndTime: endTime.UTC().Format(time.RFC3339), Granularity: granularity, Limit: limit, + Metric: metric, }) entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) + return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 1900205eaf..5e8b66ac66 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -243,7 +243,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( } if includeUsersTrend { - usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit) + usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit, "tokens") if err != nil { return nil, errors.New("failed to get user usage trend") } diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index b43c6d56ef..1ba7609fa4 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -1577,11 +1577,32 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrend() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(48 * time.Hour) - trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10) + trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10, "tokens") s.Require().NoError(err, "GetUserUsageTrend") s.Require().GreaterOrEqual(len(trend), 2) } +func (s *UsageLogRepoSuite) TestGetUserUsageTrend_SelectsTopByMetric() { + highTokens := mustCreateUser(s.T(), s.client, &service.User{Email: "tokens@test.com"}) + highSpend := mustCreateUser(s.T(), s.client, &service.User{Email: "spend@test.com"}) + tokenKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highTokens.ID, Key: "sk-trend-tokens", Name: "tokens"}) + spendKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highSpend.ID, Key: "sk-trend-spend", Name: "spend"}) + account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-trend-metric"}) + at := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + s.createUsageLog(highTokens, tokenKey, account, 1000, 0, 0.1, at) + s.createUsageLog(highSpend, spendKey, account, 10, 0, 5, at) + start, end := at.Add(-time.Hour), at.Add(time.Hour) + tokens, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "tokens") + s.Require().NoError(err) + s.Require().Len(tokens, 1) + s.Require().Equal(highTokens.ID, tokens[0].UserID) + spend, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "actual_cost") + s.Require().NoError(err) + s.Require().Len(spend, 1) + s.Require().Equal(highSpend.ID, spend[0].UserID) + s.Require().Equal(5.0, spend[0].ActualCost) +} + // --- GetAPIKeyUsageTrend --- func (s *UsageLogRepoSuite) TestGetAPIKeyUsageTrend() { diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 0b1bbe7d91..cc7a46ffbf 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -82,8 +82,12 @@ func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, } // GetUserUsageTrend returns usage trend data grouped by user and date -func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) { +func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) (results []UserUsageTrendPoint, err error) { dateFormat := safeDateFormat(granularity) + rankExpr := "SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens)" + if metric == "actual_cost" { + rankExpr = "SUM(actual_cost)" + } query := fmt.Sprintf(` WITH top_users AS ( @@ -91,7 +95,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e FROM usage_logs WHERE created_at >= $1 AND created_at < $2 GROUP BY user_id - ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC + ORDER BY %s DESC, user_id ASC LIMIT $3 ) SELECT @@ -109,7 +113,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e AND u.created_at >= $4 AND u.created_at < $5 GROUP BY date, u.user_id, us.email, us.username ORDER BY date ASC, tokens DESC - `, dateFormat) + `, rankExpr, dateFormat) rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) if err != nil { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 219c83b149..c156073c5f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -2652,7 +2652,7 @@ func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 3347aa328d..746cb324a2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -53,7 +53,7 @@ type UsageLogRepository interface { GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) - GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) + GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) diff --git a/backend/internal/service/account_usage_service_batch_test.go b/backend/internal/service/account_usage_service_batch_test.go index b4eb32ed27..06b9f10e00 100644 --- a/backend/internal/service/account_usage_service_batch_test.go +++ b/backend/internal/service/account_usage_service_batch_test.go @@ -76,7 +76,7 @@ func (r *usageBatchLogRepoStub) GetAllGroupUsageSummary(context.Context, time.Ti func (r *usageBatchLogRepoStub) GetAPIKeyUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, nil } -func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int, string) ([]usagestats.UserUsageTrendPoint, error) { return nil, nil } func (r *usageBatchLogRepoStub) GetUserSpendingRanking(context.Context, time.Time, time.Time, int) (*usagestats.UserSpendingRankingResponse, error) { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3c656b61f5..f0bb2b9f6e 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -392,8 +392,8 @@ func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return trend, nil } -func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { - trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) +func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { + trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) if err != nil { return nil, fmt.Errorf("get user usage trend: %w", err) } diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 92d9861d4f..b50be760f3 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -235,6 +235,7 @@ export async function getApiKeyUsageTrend( export interface UserTrendParams extends TrendParams { limit?: number + metric?: 'tokens' | 'actual_cost' } export interface UserTrendResponse { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 97f9ae9553..b2d33b20c9 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -50,6 +50,7 @@ export default { accountCost: 'Cost', noDataAvailable: 'No data available', recentUsage: 'Recent Usage', + actualSpending: 'Actual spending ($)', viewModelDistribution: 'Model Distribution', viewSpendingRanking: 'User Spending Ranking', spendingRankingTitle: 'User Spending Ranking', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 1e8a9887c5..56638d4bc4 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -50,6 +50,7 @@ export default { tokens: 'Token', cache: '缓存', recentUsage: '最近使用', + actualSpending: '实际消费 ($)', viewModelDistribution: '模型分布', viewSpendingRanking: '用户消费榜', spendingRankingTitle: '用户消费榜', diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index 210d17ea46..061f9e6f4b 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -318,9 +318,16 @@
-

- {{ t('admin.dashboard.recentUsage') }} (Top 12) -

+
+

{{ t('admin.dashboard.recentUsage') }} (Top 12)

+
+ +
+
@@ -401,6 +408,7 @@ const rankingError = ref(false) const trendData = ref([]) const modelStats = ref([]) const userTrend = ref([]) +const userTrendMetric = ref<'tokens' | 'actual_cost'>('tokens') const rankingItems = ref([]) const rankingTotalActualCost = ref(0) const rankingTotalRequests = ref(0) @@ -476,7 +484,7 @@ const lineOptions = computed(() => ({ }, callbacks: { label: (context: any) => { - return `${context.dataset.label}: ${formatTokens(context.raw)}` + return `${context.dataset.label}: ${formatUserTrendValue(Number(context.raw))}` } } } @@ -502,7 +510,7 @@ const lineOptions = computed(() => ({ font: { size: 10 }, - callback: (value: string | number) => formatTokens(Number(value)) + callback: (value: string | number) => formatUserTrendValue(Number(value)) } } } @@ -536,7 +544,7 @@ const userTrendChartData = computed(() => { if (!userGroups.has(key)) { userGroups.set(key, { name: getDisplayName(point), data: new Map() }) } - userGroups.get(key)!.data.set(point.date, point.tokens) + userGroups.get(key)!.data.set(point.date, userTrendMetric.value === 'tokens' ? point.tokens : point.actual_cost) }) const sortedDates = Array.from(allDates).sort() @@ -571,6 +579,9 @@ const userTrendChartData = computed(() => { }) // Format helpers +const formatUserTrendValue = (value: number): string => + userTrendMetric.value === 'tokens' ? formatTokens(value) : `$${formatCost(value)}` + const formatTokens = (value: number | undefined): string => { if (value === undefined || value === null) return '0' if (value >= 1_000_000_000) { @@ -679,6 +690,13 @@ const loadDashboardSnapshot = async (includeStats: boolean) => { } } +const setUserTrendMetric = (metric: 'tokens' | 'actual_cost') => { + if (userTrendMetric.value === metric) return + userTrendMetric.value = metric + userTrend.value = [] + loadUsersTrend() +} + const loadUsersTrend = async () => { const currentSeq = ++usersTrendLoadSeq userTrendLoading.value = true @@ -687,7 +705,8 @@ const loadUsersTrend = async () => { start_date: startDate.value, end_date: endDate.value, granularity: granularity.value, - limit: 12 + limit: 12, + metric: userTrendMetric.value }) if (currentSeq !== usersTrendLoadSeq) return userTrend.value = response.trend || [] diff --git a/frontend/src/views/admin/__tests__/DashboardView.spec.ts b/frontend/src/views/admin/__tests__/DashboardView.spec.ts index ded7db034f..898267e2e8 100644 --- a/frontend/src/views/admin/__tests__/DashboardView.spec.ts +++ b/frontend/src/views/admin/__tests__/DashboardView.spec.ts @@ -11,6 +11,10 @@ const { getSnapshotV2, getUserUsageTrend, getUserSpendingRanking } = vi.hoisted( getUserSpendingRanking: vi.fn() })) +vi.mock('vue-chartjs', () => ({ + Line: { name: 'Line', props: ['data', 'options'], template: '
' } +})) + vi.mock('@/api/admin', () => ({ adminAPI: { dashboard: { @@ -115,6 +119,30 @@ describe('admin DashboardView', () => { }) }) + it('switches metrics and ignores a stale tokens response', async () => { + let resolveTokens!: (value: any) => void + getUserUsageTrend.mockImplementationOnce(() => new Promise(resolve => { resolveTokens = resolve })) + getUserUsageTrend.mockResolvedValueOnce({ trend: [{ date: '2026-01-01', user_id: 2, username: 'spender', email: '', requests: 1, tokens: 10, cost: 5, actual_cost: 5 }] }) + const wrapper = mount(DashboardView, { + global: { stubs: { + AppLayout: { template: '
' }, LoadingSpinner: true, + Icon: true, DateRangePicker: true, Select: true, ModelDistributionChart: true, + TokenUsageTrend: true + } } + }) + await flushPromises() + expect(getUserUsageTrend).toHaveBeenCalledWith(expect.objectContaining({ metric: 'tokens', limit: 12 })) + await wrapper.findAll('button').find(button => button.text() === 'admin.dashboard.actualSpending')!.trigger('click') + expect(getUserUsageTrend).toHaveBeenCalledWith(expect.objectContaining({ metric: 'actual_cost', limit: 12 })) + await flushPromises() + const chart = wrapper.findComponent({ name: 'Line' }) + expect(chart.props('data').datasets[0].data).toEqual([5]) + expect(chart.props('options').scales.y.ticks.callback(5)).toBe('$5.00') + resolveTokens({ trend: [{ date: '2026-01-01', user_id: 1, username: 'tokens', email: '', requests: 1, tokens: 100, cost: 1, actual_cost: 1 }] }) + await flushPromises() + expect(chart.props('data').datasets[0].data).toEqual([5]) + }) + it('uses last 24 hours as default dashboard range', async () => { mount(DashboardView, { global: {