Skip to content

Commit dd8df48

Browse files
authored
Merge pull request Wei-Shaw#696 from touwaeriol/feat/group-usage-distribution-chart
feat(dashboard): add group usage distribution chart to usage page
2 parents 2129584 + 65459a9 commit dd8df48

14 files changed

Lines changed: 379 additions & 6 deletions

File tree

backend/internal/handler/admin/dashboard_handler.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,76 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
333333
})
334334
}
335335

336+
// GetGroupStats handles getting group usage statistics
337+
// GET /api/v1/admin/dashboard/groups
338+
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, request_type, stream, billing_type
339+
func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
340+
startTime, endTime := parseTimeRange(c)
341+
342+
var userID, apiKeyID, accountID, groupID int64
343+
var requestType *int16
344+
var stream *bool
345+
var billingType *int8
346+
347+
if userIDStr := c.Query("user_id"); userIDStr != "" {
348+
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
349+
userID = id
350+
}
351+
}
352+
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
353+
if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
354+
apiKeyID = id
355+
}
356+
}
357+
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
358+
if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
359+
accountID = id
360+
}
361+
}
362+
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
363+
if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
364+
groupID = id
365+
}
366+
}
367+
if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
368+
parsed, err := service.ParseUsageRequestType(requestTypeStr)
369+
if err != nil {
370+
response.BadRequest(c, err.Error())
371+
return
372+
}
373+
value := int16(parsed)
374+
requestType = &value
375+
} else if streamStr := c.Query("stream"); streamStr != "" {
376+
if streamVal, err := strconv.ParseBool(streamStr); err == nil {
377+
stream = &streamVal
378+
} else {
379+
response.BadRequest(c, "Invalid stream value, use true or false")
380+
return
381+
}
382+
}
383+
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
384+
if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
385+
bt := int8(v)
386+
billingType = &bt
387+
} else {
388+
response.BadRequest(c, "Invalid billing_type")
389+
return
390+
}
391+
}
392+
393+
stats, err := h.dashboardService.GetGroupStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
394+
if err != nil {
395+
response.Error(c, 500, "Failed to get group statistics")
396+
return
397+
}
398+
399+
response.Success(c, gin.H{
400+
"groups": stats,
401+
"start_date": startTime.Format("2006-01-02"),
402+
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
403+
})
404+
}
405+
336406
// GetAPIKeyUsageTrend handles getting API key usage trend data
337407
// GET /api/v1/admin/dashboard/api-keys-trend
338408
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 5)

backend/internal/handler/sora_gateway_handler_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,9 @@ func (s *stubUsageLogRepo) GetUsageTrendWithFilters(ctx context.Context, startTi
320320
func (s *stubUsageLogRepo) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
321321
return nil, nil
322322
}
323+
func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
324+
return nil, nil
325+
}
323326
func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
324327
return nil, nil
325328
}

backend/internal/pkg/usagestats/usage_log_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ type ModelStat struct {
7878
ActualCost float64 `json:"actual_cost"` // 实际扣除
7979
}
8080

81+
// GroupStat represents usage statistics for a single group
82+
type GroupStat struct {
83+
GroupID int64 `json:"group_id"`
84+
GroupName string `json:"group_name"`
85+
Requests int64 `json:"requests"`
86+
TotalTokens int64 `json:"total_tokens"`
87+
Cost float64 `json:"cost"` // 标准计费
88+
ActualCost float64 `json:"actual_cost"` // 实际扣除
89+
}
90+
8191
// UserUsageTrendPoint represents user usage trend data point
8292
type UserUsageTrendPoint struct {
8393
Date string `json:"date"`

backend/internal/repository/usage_log_repo.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,77 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start
17841784
return results, nil
17851785
}
17861786

1787+
// GetGroupStatsWithFilters returns group usage statistics with optional filters
1788+
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
1789+
query := `
1790+
SELECT
1791+
COALESCE(ul.group_id, 0) as group_id,
1792+
COALESCE(g.name, '') as group_name,
1793+
COUNT(*) as requests,
1794+
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
1795+
COALESCE(SUM(ul.total_cost), 0) as cost,
1796+
COALESCE(SUM(ul.actual_cost), 0) as actual_cost
1797+
FROM usage_logs ul
1798+
LEFT JOIN groups g ON g.id = ul.group_id
1799+
WHERE ul.created_at >= $1 AND ul.created_at < $2
1800+
`
1801+
1802+
args := []any{startTime, endTime}
1803+
if userID > 0 {
1804+
query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1)
1805+
args = append(args, userID)
1806+
}
1807+
if apiKeyID > 0 {
1808+
query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1)
1809+
args = append(args, apiKeyID)
1810+
}
1811+
if accountID > 0 {
1812+
query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1)
1813+
args = append(args, accountID)
1814+
}
1815+
if groupID > 0 {
1816+
query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
1817+
args = append(args, groupID)
1818+
}
1819+
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
1820+
if billingType != nil {
1821+
query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
1822+
args = append(args, int16(*billingType))
1823+
}
1824+
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
1825+
1826+
rows, err := r.sql.QueryContext(ctx, query, args...)
1827+
if err != nil {
1828+
return nil, err
1829+
}
1830+
defer func() {
1831+
if closeErr := rows.Close(); closeErr != nil && err == nil {
1832+
err = closeErr
1833+
results = nil
1834+
}
1835+
}()
1836+
1837+
results = make([]usagestats.GroupStat, 0)
1838+
for rows.Next() {
1839+
var row usagestats.GroupStat
1840+
if err := rows.Scan(
1841+
&row.GroupID,
1842+
&row.GroupName,
1843+
&row.Requests,
1844+
&row.TotalTokens,
1845+
&row.Cost,
1846+
&row.ActualCost,
1847+
); err != nil {
1848+
return nil, err
1849+
}
1850+
results = append(results, row)
1851+
}
1852+
if err := rows.Err(); err != nil {
1853+
return nil, err
1854+
}
1855+
return results, nil
1856+
}
1857+
17871858
// GetGlobalStats gets usage statistics for all users within a time range
17881859
func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) {
17891860
query := `

backend/internal/server/api_contract_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,10 @@ func (r *stubUsageLogRepo) GetModelStatsWithFilters(ctx context.Context, startTi
15721572
return nil, errors.New("not implemented")
15731573
}
15741574

1575+
func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
1576+
return nil, errors.New("not implemented")
1577+
}
1578+
15751579
func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
15761580
return nil, errors.New("not implemented")
15771581
}

backend/internal/server/routes/admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
184184
dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics)
185185
dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend)
186186
dashboard.GET("/models", h.Admin.Dashboard.GetModelStats)
187+
dashboard.GET("/groups", h.Admin.Dashboard.GetGroupStats)
187188
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend)
188189
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
189190
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)

backend/internal/service/account_usage_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type UsageLogRepository interface {
3737
GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error)
3838
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error)
3939
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error)
40+
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
4041
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
4142
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
4243
GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error)

backend/internal/service/dashboard_service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi
140140
return stats, nil
141141
}
142142

143+
func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
144+
stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
145+
if err != nil {
146+
return nil, fmt.Errorf("get group stats with filters: %w", err)
147+
}
148+
return stats, nil
149+
}
150+
143151
func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
144152
data, err := s.cache.GetDashboardStats(ctx)
145153
if err != nil {

frontend/src/api/admin/dashboard.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
DashboardStats,
99
TrendDataPoint,
1010
ModelStat,
11+
GroupStat,
1112
ApiKeyUsageTrendPoint,
1213
UserUsageTrendPoint,
1314
UsageRequestType
@@ -101,6 +102,34 @@ export async function getModelStats(params?: ModelStatsParams): Promise<ModelSta
101102
return data
102103
}
103104

105+
export interface GroupStatsParams {
106+
start_date?: string
107+
end_date?: string
108+
user_id?: number
109+
api_key_id?: number
110+
account_id?: number
111+
group_id?: number
112+
request_type?: UsageRequestType
113+
stream?: boolean
114+
billing_type?: number | null
115+
}
116+
117+
export interface GroupStatsResponse {
118+
groups: GroupStat[]
119+
start_date: string
120+
end_date: string
121+
}
122+
123+
/**
124+
* Get group usage statistics
125+
* @param params - Query parameters for filtering
126+
* @returns Group usage statistics
127+
*/
128+
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
129+
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
130+
return data
131+
}
132+
104133
export interface ApiKeyTrendParams extends TrendParams {
105134
limit?: number
106135
}
@@ -203,6 +232,7 @@ export const dashboardAPI = {
203232
getRealtimeMetrics,
204233
getUsageTrend,
205234
getModelStats,
235+
getGroupStats,
206236
getApiKeyUsageTrend,
207237
getUserUsageTrend,
208238
getBatchUsersUsage,

0 commit comments

Comments
 (0)