diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index e7097442..ad87843f 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -83,7 +83,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher) httpUpstream := repository.NewHTTPUpstream(configConfig) accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) - accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService) + accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, usageLogRepository) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) proxyHandler := admin.NewProxyHandler(adminService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 3e92d2d4..b5a2d794 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -6,6 +6,8 @@ import ( "sub2api/internal/pkg/claude" "sub2api/internal/pkg/openai" "sub2api/internal/pkg/response" + "sub2api/internal/pkg/timezone" + "sub2api/internal/repository" "sub2api/internal/service" "github.com/gin-gonic/gin" @@ -31,10 +33,11 @@ type AccountHandler struct { rateLimitService *service.RateLimitService accountUsageService *service.AccountUsageService accountTestService *service.AccountTestService + usageLogRepo *repository.UsageLogRepository } // NewAccountHandler creates a new admin account handler -func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService) *AccountHandler { +func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, usageLogRepo *repository.UsageLogRepository) *AccountHandler { return &AccountHandler{ adminService: adminService, oauthService: oauthService, @@ -42,6 +45,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service. rateLimitService: rateLimitService, accountUsageService: accountUsageService, accountTestService: accountTestService, + usageLogRepo: usageLogRepo, } } @@ -297,15 +301,26 @@ func (h *AccountHandler) GetStats(c *gin.Context) { return } - // Return mock data for now - _ = accountID - response.Success(c, gin.H{ - "total_requests": 0, - "successful_requests": 0, - "failed_requests": 0, - "total_tokens": 0, - "average_response_time": 0, - }) + // Parse days parameter (default 30) + days := 30 + if daysStr := c.Query("days"); daysStr != "" { + if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 90 { + days = d + } + } + + // Calculate time range + now := timezone.Now() + endTime := timezone.StartOfDay(now.AddDate(0, 0, 1)) + startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1)) + + stats, err := h.usageLogRepo.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime) + if err != nil { + response.InternalError(c, "Failed to get account stats: "+err.Error()) + return + } + + response.Success(c, stats) } // ClearError handles clearing account error diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 2fd4f268..99f9cad9 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -175,7 +175,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { } } - stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID) + stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, 0) if err != nil { response.Error(c, 500, "Failed to get model statistics") return diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index c32517de..6df392e4 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -937,7 +937,7 @@ func (r *UsageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start } // GetModelStatsWithFilters returns model statistics with optional user/api_key filters -func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]ModelStat, error) { +func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]ModelStat, error) { var results []ModelStat db := r.db.WithContext(ctx).Model(&model.UsageLog{}). @@ -958,6 +958,9 @@ func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, start if apiKeyID > 0 { db = db.Where("api_key_id = ?", apiKeyID) } + if accountID > 0 { + db = db.Where("account_id = ?", accountID) + } err := db.Group("model").Order("total_tokens DESC").Scan(&results).Error if err != nil { @@ -1007,3 +1010,209 @@ func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT AverageDurationMs: stats.AverageDurationMs, }, nil } + +// AccountUsageHistory represents daily usage history for an account +type AccountUsageHistory struct { + Date string `json:"date"` + Label string `json:"label"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + Cost float64 `json:"cost"` + ActualCost float64 `json:"actual_cost"` +} + +// AccountUsageSummary represents summary statistics for an account +type AccountUsageSummary struct { + Days int `json:"days"` + ActualDaysUsed int `json:"actual_days_used"` + TotalCost float64 `json:"total_cost"` + TotalStandardCost float64 `json:"total_standard_cost"` + TotalRequests int64 `json:"total_requests"` + TotalTokens int64 `json:"total_tokens"` + AvgDailyCost float64 `json:"avg_daily_cost"` + AvgDailyRequests float64 `json:"avg_daily_requests"` + AvgDailyTokens float64 `json:"avg_daily_tokens"` + AvgDurationMs float64 `json:"avg_duration_ms"` + Today *struct { + Date string `json:"date"` + Cost float64 `json:"cost"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + } `json:"today"` + HighestCostDay *struct { + Date string `json:"date"` + Label string `json:"label"` + Cost float64 `json:"cost"` + Requests int64 `json:"requests"` + } `json:"highest_cost_day"` + HighestRequestDay *struct { + Date string `json:"date"` + Label string `json:"label"` + Requests int64 `json:"requests"` + Cost float64 `json:"cost"` + } `json:"highest_request_day"` +} + +// AccountUsageStatsResponse represents the full usage statistics response for an account +type AccountUsageStatsResponse struct { + History []AccountUsageHistory `json:"history"` + Summary AccountUsageSummary `json:"summary"` + Models []ModelStat `json:"models"` +} + +// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range +func (r *UsageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*AccountUsageStatsResponse, error) { + daysCount := int(endTime.Sub(startTime).Hours()/24) + 1 + if daysCount <= 0 { + daysCount = 30 + } + + // Get daily history + var historyResults []struct { + Date string `gorm:"column:date"` + Requests int64 `gorm:"column:requests"` + Tokens int64 `gorm:"column:tokens"` + Cost float64 `gorm:"column:cost"` + ActualCost float64 `gorm:"column:actual_cost"` + } + + err := r.db.WithContext(ctx).Model(&model.UsageLog{}). + Select(` + TO_CHAR(created_at, 'YYYY-MM-DD') as date, + COUNT(*) as requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, + COALESCE(SUM(total_cost), 0) as cost, + COALESCE(SUM(actual_cost), 0) as actual_cost + `). + Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, startTime, endTime). + Group("date"). + Order("date ASC"). + Scan(&historyResults).Error + if err != nil { + return nil, err + } + + // Build history with labels + history := make([]AccountUsageHistory, 0, len(historyResults)) + for _, h := range historyResults { + // Parse date to get label (MM/DD) + t, _ := time.Parse("2006-01-02", h.Date) + label := t.Format("01/02") + history = append(history, AccountUsageHistory{ + Date: h.Date, + Label: label, + Requests: h.Requests, + Tokens: h.Tokens, + Cost: h.Cost, + ActualCost: h.ActualCost, + }) + } + + // Calculate summary + var totalActualCost, totalStandardCost float64 + var totalRequests, totalTokens int64 + var highestCostDay, highestRequestDay *AccountUsageHistory + + for i := range history { + h := &history[i] + totalActualCost += h.ActualCost + totalStandardCost += h.Cost + totalRequests += h.Requests + totalTokens += h.Tokens + + if highestCostDay == nil || h.ActualCost > highestCostDay.ActualCost { + highestCostDay = h + } + if highestRequestDay == nil || h.Requests > highestRequestDay.Requests { + highestRequestDay = h + } + } + + actualDaysUsed := len(history) + if actualDaysUsed == 0 { + actualDaysUsed = 1 + } + + // Get average duration + var avgDuration struct { + AvgDurationMs float64 `gorm:"column:avg_duration_ms"` + } + r.db.WithContext(ctx).Model(&model.UsageLog{}). + Select("COALESCE(AVG(duration_ms), 0) as avg_duration_ms"). + Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, startTime, endTime). + Scan(&avgDuration) + + summary := AccountUsageSummary{ + Days: daysCount, + ActualDaysUsed: actualDaysUsed, + TotalCost: totalActualCost, + TotalStandardCost: totalStandardCost, + TotalRequests: totalRequests, + TotalTokens: totalTokens, + AvgDailyCost: totalActualCost / float64(actualDaysUsed), + AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed), + AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed), + AvgDurationMs: avgDuration.AvgDurationMs, + } + + // Set today's stats + todayStr := timezone.Now().Format("2006-01-02") + for i := range history { + if history[i].Date == todayStr { + summary.Today = &struct { + Date string `json:"date"` + Cost float64 `json:"cost"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + }{ + Date: history[i].Date, + Cost: history[i].ActualCost, + Requests: history[i].Requests, + Tokens: history[i].Tokens, + } + break + } + } + + // Set highest cost day + if highestCostDay != nil { + summary.HighestCostDay = &struct { + Date string `json:"date"` + Label string `json:"label"` + Cost float64 `json:"cost"` + Requests int64 `json:"requests"` + }{ + Date: highestCostDay.Date, + Label: highestCostDay.Label, + Cost: highestCostDay.ActualCost, + Requests: highestCostDay.Requests, + } + } + + // Set highest request day + if highestRequestDay != nil { + summary.HighestRequestDay = &struct { + Date string `json:"date"` + Label string `json:"label"` + Requests int64 `json:"requests"` + Cost float64 `json:"cost"` + }{ + Date: highestRequestDay.Date, + Label: highestRequestDay.Label, + Requests: highestRequestDay.Requests, + Cost: highestRequestDay.ActualCost, + } + } + + // Get model statistics using the unified method + models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID) + if err != nil { + models = []ModelStat{} + } + + return &AccountUsageStatsResponse{ + History: history, + Summary: summary, + Models: models, + }, nil +} diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index d89e787a..92bb7cd8 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -12,6 +12,7 @@ import type { AccountUsageInfo, WindowStats, ClaudeModel, + AccountUsageStatsResponse, } from '@/types'; /** @@ -126,27 +127,12 @@ export async function refreshCredentials(id: number): Promise { /** * Get account usage statistics * @param id - Account ID - * @param period - Time period - * @returns Account usage statistics + * @param days - Number of days (default: 30) + * @returns Account usage statistics with history, summary, and models */ -export async function getStats( - id: number, - period: string = 'month' -): Promise<{ - total_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - average_response_time: number; -}> { - const { data } = await apiClient.get<{ - total_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - average_response_time: number; - }>(`/admin/accounts/${id}/stats`, { - params: { period }, +export async function getStats(id: number, days: number = 30): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/stats`, { + params: { days }, }); return data; } diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue new file mode 100644 index 00000000..27797a3e --- /dev/null +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -0,0 +1,546 @@ + + + diff --git a/frontend/src/components/account/index.ts b/frontend/src/components/account/index.ts index 7dc376c1..254c6720 100644 --- a/frontend/src/components/account/index.ts +++ b/frontend/src/components/account/index.ts @@ -5,3 +5,6 @@ export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue' export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue' export { default as AccountUsageCell } from './AccountUsageCell.vue' export { default as UsageProgressBar } from './UsageProgressBar.vue' +export { default as AccountStatsModal } from './AccountStatsModal.vue' +export { default as AccountTestModal } from './AccountTestModal.vue' +export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue' diff --git a/frontend/src/components/common/Modal.vue b/frontend/src/components/common/Modal.vue index 9205233d..928ea70f 100644 --- a/frontend/src/components/common/Modal.vue +++ b/frontend/src/components/common/Modal.vue @@ -52,7 +52,7 @@