feat(dashboard): toggle recent usage between tokens and spending

This commit is contained in:
wucm667
2026-09-28 14:48:42 +08:00
parent 9a62841fd1
commit aa3a3ea787
15 changed files with 118 additions and 20 deletions
@@ -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
@@ -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())
}
}
@@ -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
@@ -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")
}
@@ -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() {
@@ -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 {
+1 -1
View File
@@ -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")
}
@@ -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)
@@ -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) {
@@ -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)
}
+1
View File
@@ -235,6 +235,7 @@ export async function getApiKeyUsageTrend(
export interface UserTrendParams extends TrendParams {
limit?: number
metric?: 'tokens' | 'actual_cost'
}
export interface UserTrendResponse {
@@ -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',
@@ -50,6 +50,7 @@ export default {
tokens: 'Token',
cache: '缓存',
recentUsage: '最近使用',
actualSpending: '实际消费 ($)',
viewModelDistribution: '模型分布',
viewSpendingRanking: '用户消费榜',
spendingRankingTitle: '用户消费榜',
+26 -7
View File
@@ -318,9 +318,16 @@
<!-- User Usage Trend (Full Width) -->
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.recentUsage') }} (Top 12)
</h3>
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.dashboard.recentUsage') }} (Top 12)</h3>
<div class="flex gap-1" role="group" :aria-label="t('admin.dashboard.recentUsage')">
<button v-for="metric in (['tokens', 'actual_cost'] as const)" :key="metric" type="button"
class="rounded px-2 py-1 text-xs" :class="userTrendMetric === metric ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-gray-300'"
:aria-pressed="userTrendMetric === metric" @click="setUserTrendMetric(metric)">
{{ t(metric === 'tokens' ? 'admin.dashboard.tokens' : 'admin.dashboard.actualSpending') }}
</button>
</div>
</div>
<div class="h-64">
<div v-if="userTrendLoading" class="flex h-full items-center justify-center">
<LoadingSpinner size="md" />
@@ -401,6 +408,7 @@ const rankingError = ref(false)
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
const userTrendMetric = ref<'tokens' | 'actual_cost'>('tokens')
const rankingItems = ref<UserSpendingRankingItem[]>([])
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 || []
@@ -11,6 +11,10 @@ const { getSnapshotV2, getUserUsageTrend, getUserSpendingRanking } = vi.hoisted(
getUserSpendingRanking: vi.fn()
}))
vi.mock('vue-chartjs', () => ({
Line: { name: 'Line', props: ['data', 'options'], template: '<div class="line-chart" />' }
}))
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: '<div><slot /></div>' }, 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: {