mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
feat(dashboard): toggle recent usage between tokens and spending
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: '用户消费榜',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user