mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 04:44:49 +00:00
feat(admin): add user spending ranking dashboard view
This commit is contained in:
@@ -466,9 +466,60 @@ type BatchUsersUsageRequest struct {
|
|||||||
UserIDs []int64 `json:"user_ids" binding:"required"`
|
UserIDs []int64 `json:"user_ids" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
|
||||||
var dashboardBatchUsersUsageCache = newSnapshotCache(30 * time.Second)
|
var dashboardBatchUsersUsageCache = newSnapshotCache(30 * time.Second)
|
||||||
var dashboardBatchAPIKeysUsageCache = newSnapshotCache(30 * time.Second)
|
var dashboardBatchAPIKeysUsageCache = newSnapshotCache(30 * time.Second)
|
||||||
|
|
||||||
|
func parseRankingLimit(raw string) int {
|
||||||
|
limit, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserSpendingRanking handles getting user spending ranking data.
|
||||||
|
// GET /api/v1/admin/dashboard/users-ranking
|
||||||
|
func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
|
||||||
|
startTime, endTime := parseTimeRange(c)
|
||||||
|
limit := parseRankingLimit(c.DefaultQuery("limit", "12"))
|
||||||
|
|
||||||
|
keyRaw, _ := json.Marshal(struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}{
|
||||||
|
Start: startTime.UTC().Format(time.RFC3339),
|
||||||
|
End: endTime.UTC().Format(time.RFC3339),
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
cacheKey := string(keyRaw)
|
||||||
|
if cached, ok := dashboardUsersRankingCache.Get(cacheKey); ok {
|
||||||
|
c.Header("X-Snapshot-Cache", "hit")
|
||||||
|
response.Success(c, cached.Payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking, err := h.dashboardService.GetUserSpendingRanking(c.Request.Context(), startTime, endTime, limit)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, 500, "Failed to get user spending ranking")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := gin.H{
|
||||||
|
"ranking": ranking.Ranking,
|
||||||
|
"total_actual_cost": ranking.TotalActualCost,
|
||||||
|
"start_date": startTime.Format("2006-01-02"),
|
||||||
|
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
dashboardUsersRankingCache.Set(cacheKey, payload)
|
||||||
|
c.Header("X-Snapshot-Cache", "miss")
|
||||||
|
response.Success(c, payload)
|
||||||
|
}
|
||||||
|
|
||||||
// GetBatchUsersUsage handles getting usage stats for multiple users
|
// GetBatchUsersUsage handles getting usage stats for multiple users
|
||||||
// POST /api/v1/admin/dashboard/users-usage
|
// POST /api/v1/admin/dashboard/users-usage
|
||||||
func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
|
func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type dashboardUsageRepoCapture struct {
|
|||||||
trendStream *bool
|
trendStream *bool
|
||||||
modelRequestType *int16
|
modelRequestType *int16
|
||||||
modelStream *bool
|
modelStream *bool
|
||||||
|
rankingLimit int
|
||||||
|
ranking []usagestats.UserSpendingRankingItem
|
||||||
|
rankingTotal float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
||||||
@@ -49,6 +52,18 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
|
|||||||
return []usagestats.ModelStat{}, nil
|
return []usagestats.ModelStat{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *dashboardUsageRepoCapture) GetUserSpendingRanking(
|
||||||
|
ctx context.Context,
|
||||||
|
startTime, endTime time.Time,
|
||||||
|
limit int,
|
||||||
|
) (*usagestats.UserSpendingRankingResponse, error) {
|
||||||
|
s.rankingLimit = limit
|
||||||
|
return &usagestats.UserSpendingRankingResponse{
|
||||||
|
Ranking: s.ranking,
|
||||||
|
TotalActualCost: s.rankingTotal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Engine {
|
func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
dashboardSvc := service.NewDashboardService(repo, nil, nil, nil)
|
dashboardSvc := service.NewDashboardService(repo, nil, nil, nil)
|
||||||
@@ -56,6 +71,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
|
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
|
||||||
router.GET("/admin/dashboard/models", handler.GetModelStats)
|
router.GET("/admin/dashboard/models", handler.GetModelStats)
|
||||||
|
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,3 +146,30 @@ func TestDashboardModelStatsInvalidStream(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
|
||||||
|
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
|
||||||
|
repo := &dashboardUsageRepoCapture{
|
||||||
|
ranking: []usagestats.UserSpendingRankingItem{
|
||||||
|
{UserID: 7, Email: "rank@example.com", ActualCost: 10.5, Requests: 3, Tokens: 300},
|
||||||
|
},
|
||||||
|
rankingTotal: 88.8,
|
||||||
|
}
|
||||||
|
router := newDashboardRequestTypeTestRouter(repo)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Equal(t, 50, repo.rankingLimit)
|
||||||
|
require.Contains(t, rec.Body.String(), "\"total_actual_cost\":88.8")
|
||||||
|
require.Equal(t, "miss", rec.Header().Get("X-Snapshot-Cache"))
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec2.Code)
|
||||||
|
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,6 +343,9 @@ func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e
|
|||||||
func (s *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
|
func (s *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (s *stubUsageLogRepo) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (s *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
func (s *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,21 @@ type UserUsageTrendPoint struct {
|
|||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserSpendingRankingItem represents a user spending ranking row.
|
||||||
|
type UserSpendingRankingItem struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSpendingRankingResponse represents ranking rows plus total spend for the time range.
|
||||||
|
type UserSpendingRankingResponse struct {
|
||||||
|
Ranking []UserSpendingRankingItem `json:"ranking"`
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
// APIKeyUsageTrendPoint represents API key usage trend data point
|
// APIKeyUsageTrendPoint represents API key usage trend data point
|
||||||
type APIKeyUsageTrendPoint struct {
|
type APIKeyUsageTrendPoint struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
|
|||||||
@@ -1039,6 +1039,10 @@ type ModelStat = usagestats.ModelStat
|
|||||||
// UserUsageTrendPoint represents user usage trend data point
|
// UserUsageTrendPoint represents user usage trend data point
|
||||||
type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
|
type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
|
||||||
|
|
||||||
|
// UserSpendingRankingItem represents a user spending ranking row.
|
||||||
|
type UserSpendingRankingItem = usagestats.UserSpendingRankingItem
|
||||||
|
type UserSpendingRankingResponse = usagestats.UserSpendingRankingResponse
|
||||||
|
|
||||||
// APIKeyUsageTrendPoint represents API key usage trend data point
|
// APIKeyUsageTrendPoint represents API key usage trend data point
|
||||||
type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint
|
type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint
|
||||||
|
|
||||||
@@ -1154,6 +1158,78 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserSpendingRanking returns user spending ranking aggregated within the time range.
|
||||||
|
func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (result *UserSpendingRankingResponse, err error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
WITH user_spend AS (
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(us.email, '') as email,
|
||||||
|
COALESCE(SUM(u.actual_cost), 0) as actual_cost,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
|
||||||
|
FROM usage_logs u
|
||||||
|
LEFT JOIN users us ON u.user_id = us.id
|
||||||
|
WHERE u.created_at >= $1 AND u.created_at < $2
|
||||||
|
GROUP BY u.user_id, us.email
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
actual_cost,
|
||||||
|
requests,
|
||||||
|
tokens,
|
||||||
|
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost
|
||||||
|
FROM user_spend
|
||||||
|
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||||
|
LIMIT $3
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
actual_cost,
|
||||||
|
requests,
|
||||||
|
tokens,
|
||||||
|
total_actual_cost
|
||||||
|
FROM ranked
|
||||||
|
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
result = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ranking := make([]UserSpendingRankingItem, 0)
|
||||||
|
totalActualCost := 0.0
|
||||||
|
for rows.Next() {
|
||||||
|
var row UserSpendingRankingItem
|
||||||
|
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ranking = append(ranking, row)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserSpendingRankingResponse{
|
||||||
|
Ranking: ranking,
|
||||||
|
TotalActualCost: totalActualCost,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UserDashboardStats 用户仪表盘统计
|
// UserDashboardStats 用户仪表盘统计
|
||||||
type UserDashboardStats = usagestats.UserDashboardStats
|
type UserDashboardStats = usagestats.UserDashboardStats
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,35 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T)
|
|||||||
require.NoError(t, mock.ExpectationsWereMet())
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||||
|
db, mock := newSQLMock(t)
|
||||||
|
repo := &usageLogRepository{sql: db}
|
||||||
|
|
||||||
|
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost"}).
|
||||||
|
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0).
|
||||||
|
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0).
|
||||||
|
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0)
|
||||||
|
|
||||||
|
mock.ExpectQuery("WITH user_spend AS \\(").
|
||||||
|
WithArgs(start, end, 12).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
got, err := repo.GetUserSpendingRanking(context.Background(), start, end, 12)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &usagestats.UserSpendingRankingResponse{
|
||||||
|
Ranking: []usagestats.UserSpendingRankingItem{
|
||||||
|
{UserID: 2, Email: "beta@example.com", ActualCost: 12.5, Requests: 9, Tokens: 900},
|
||||||
|
{UserID: 1, Email: "alpha@example.com", ActualCost: 12.5, Requests: 8, Tokens: 800},
|
||||||
|
{UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300},
|
||||||
|
},
|
||||||
|
TotalActualCost: 40.0,
|
||||||
|
}, got)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildRequestTypeFilterConditionLegacyFallback(t *testing.T) {
|
func TestBuildRequestTypeFilterConditionLegacyFallback(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1635,6 +1635,10 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
|
|||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
logs := r.userLogs[userID]
|
logs := r.userLogs[userID]
|
||||||
if len(logs) == 0 {
|
if len(logs) == 0 {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
dashboard.GET("/groups", h.Admin.Dashboard.GetGroupStats)
|
dashboard.GET("/groups", h.Admin.Dashboard.GetGroupStats)
|
||||||
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend)
|
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend)
|
||||||
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
|
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
|
||||||
|
dashboard.GET("/users-ranking", h.Admin.Dashboard.GetUserSpendingRanking)
|
||||||
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
|
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
|
||||||
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage)
|
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage)
|
||||||
dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation)
|
dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type UsageLogRepository interface {
|
|||||||
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
||||||
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, 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) ([]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)
|
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)
|
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
||||||
|
|
||||||
|
|||||||
@@ -327,6 +327,14 @@ func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, end
|
|||||||
return trend, nil
|
return trend, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) {
|
||||||
|
ranking, err := s.usageRepo.GetUserSpendingRanking(ctx, startTime, endTime, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user spending ranking: %w", err)
|
||||||
|
}
|
||||||
|
return ranking, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||||
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
|
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
GroupStat,
|
GroupStat,
|
||||||
ApiKeyUsageTrendPoint,
|
ApiKeyUsageTrendPoint,
|
||||||
UserUsageTrendPoint,
|
UserUsageTrendPoint,
|
||||||
|
UserSpendingRankingResponse,
|
||||||
UsageRequestType
|
UsageRequestType
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
@@ -201,6 +202,11 @@ export interface UserTrendResponse {
|
|||||||
granularity: string
|
granularity: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSpendingRankingParams
|
||||||
|
extends Pick<TrendParams, 'start_date' | 'end_date'> {
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user usage trend data
|
* Get user usage trend data
|
||||||
* @param params - Query parameters for filtering
|
* @param params - Query parameters for filtering
|
||||||
@@ -213,6 +219,20 @@ export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserT
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user spending ranking data
|
||||||
|
* @param params - Query parameters for filtering
|
||||||
|
* @returns User spending ranking data
|
||||||
|
*/
|
||||||
|
export async function getUserSpendingRanking(
|
||||||
|
params?: UserSpendingRankingParams
|
||||||
|
): Promise<UserSpendingRankingResponse> {
|
||||||
|
const { data } = await apiClient.get<UserSpendingRankingResponse>('/admin/dashboard/users-ranking', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchUserUsageStats {
|
export interface BatchUserUsageStats {
|
||||||
user_id: number
|
user_id: number
|
||||||
today_actual_cost: number
|
today_actual_cost: number
|
||||||
@@ -271,6 +291,7 @@ export const dashboardAPI = {
|
|||||||
getSnapshotV2,
|
getSnapshotV2,
|
||||||
getApiKeyUsageTrend,
|
getApiKeyUsageTrend,
|
||||||
getUserUsageTrend,
|
getUserUsageTrend,
|
||||||
|
getUserSpendingRanking,
|
||||||
getBatchUsersUsage,
|
getBatchUsersUsage,
|
||||||
getBatchApiKeysUsage
|
getBatchApiKeysUsage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,72 @@
|
|||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{{ t('admin.dashboard.modelDistribution') }}
|
{{ !enableRankingView || activeView === 'model_distribution'
|
||||||
|
? t('admin.dashboard.modelDistribution')
|
||||||
|
: t('admin.dashboard.spendingRankingTitle') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div class="flex items-center gap-2">
|
||||||
v-if="showMetricToggle"
|
<div
|
||||||
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
|
v-if="showMetricToggle"
|
||||||
>
|
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
|
||||||
:class="metric === 'tokens'
|
|
||||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
|
||||||
@click="emit('update:metric', 'tokens')"
|
|
||||||
>
|
>
|
||||||
{{ t('admin.dashboard.metricTokens') }}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||||
type="button"
|
:class="metric === 'tokens'
|
||||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||||
:class="metric === 'actual_cost'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
@click="emit('update:metric', 'tokens')"
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
>
|
||||||
@click="emit('update:metric', 'actual_cost')"
|
{{ t('admin.dashboard.metricTokens') }}
|
||||||
>
|
</button>
|
||||||
{{ t('admin.dashboard.metricActualCost') }}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||||
|
:class="metric === 'actual_cost'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
@click="emit('update:metric', 'actual_cost')"
|
||||||
|
>
|
||||||
|
{{ t('admin.dashboard.metricActualCost') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="enableRankingView" class="inline-flex rounded-lg bg-gray-100 p-1 dark:bg-dark-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeView === 'model_distribution'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
"
|
||||||
|
@click="activeView = 'model_distribution'"
|
||||||
|
>
|
||||||
|
{{ t('admin.dashboard.viewModelDistribution') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeView === 'spending_ranking'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
"
|
||||||
|
@click="activeView = 'spending_ranking'"
|
||||||
|
>
|
||||||
|
{{ t('admin.dashboard.viewSpendingRanking') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
|
||||||
|
<div v-if="activeView === 'model_distribution' && loading" class="flex h-48 items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="displayModelStats.length > 0 && chartData" class="flex items-center gap-6">
|
<div
|
||||||
|
v-else-if="activeView === 'model_distribution' && displayModelStats.length > 0 && chartData"
|
||||||
|
class="flex items-center gap-6"
|
||||||
|
>
|
||||||
<div class="h-48 w-48">
|
<div class="h-48 w-48">
|
||||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +111,70 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="activeView === 'model_distribution'"
|
||||||
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.dashboard.noDataAvailable') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="rankingLoading" class="flex h-48 items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="rankingError"
|
||||||
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.dashboard.failedToLoad') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="rankingItems.length > 0 && rankingChartData" class="flex items-center gap-6">
|
||||||
|
<div class="h-48 w-48">
|
||||||
|
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-500 dark:text-gray-400">
|
||||||
|
<th class="pb-2 text-left">{{ t('admin.dashboard.spendingRankingUser') }}</th>
|
||||||
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingRequests') }}</th>
|
||||||
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingTokens') }}</th>
|
||||||
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingSpend') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in rankingItems"
|
||||||
|
:key="`${item.user_id}-${index}`"
|
||||||
|
class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
|
||||||
|
@click="emit('ranking-click', item)"
|
||||||
|
>
|
||||||
|
<td class="py-1.5">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span class="shrink-0 text-[11px] font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
|
||||||
|
:title="getRankingUserLabel(item)"
|
||||||
|
>
|
||||||
|
{{ getRankingUserLabel(item) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatNumber(item.requests) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTokens(item.tokens) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||||
|
${{ formatCost(item.actual_cost) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
@@ -87,34 +185,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||||
import { Doughnut } from 'vue-chartjs'
|
import { Doughnut } from 'vue-chartjs'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import type { ModelStat } from '@/types'
|
import type { ModelStat, UserSpendingRankingItem } from '@/types'
|
||||||
|
|
||||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelStats: ModelStat[]
|
modelStats: ModelStat[]
|
||||||
|
enableRankingView?: boolean
|
||||||
|
rankingItems?: UserSpendingRankingItem[]
|
||||||
|
rankingTotalActualCost?: number
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
metric?: DistributionMetric
|
metric?: DistributionMetric
|
||||||
showMetricToggle?: boolean
|
showMetricToggle?: boolean
|
||||||
|
rankingLoading?: boolean
|
||||||
|
rankingError?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
|
enableRankingView: false,
|
||||||
|
rankingItems: () => [],
|
||||||
|
rankingTotalActualCost: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
metric: 'tokens',
|
metric: 'tokens',
|
||||||
showMetricToggle: false,
|
showMetricToggle: false,
|
||||||
|
rankingLoading: false,
|
||||||
|
rankingError: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:metric': [value: DistributionMetric]
|
'update:metric': [value: DistributionMetric]
|
||||||
|
'ranking-click': [item: UserSpendingRankingItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const enableRankingView = computed(() => props.enableRankingView)
|
||||||
|
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
|
||||||
|
|
||||||
const chartColors = [
|
const chartColors = [
|
||||||
'#3b82f6',
|
'#3b82f6',
|
||||||
'#10b981',
|
'#10b981',
|
||||||
@@ -125,7 +236,9 @@ const chartColors = [
|
|||||||
'#14b8a6',
|
'#14b8a6',
|
||||||
'#f97316',
|
'#f97316',
|
||||||
'#6366f1',
|
'#6366f1',
|
||||||
'#84cc16'
|
'#84cc16',
|
||||||
|
'#06b6d4',
|
||||||
|
'#a855f7'
|
||||||
]
|
]
|
||||||
|
|
||||||
const displayModelStats = computed(() => {
|
const displayModelStats = computed(() => {
|
||||||
@@ -150,6 +263,31 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rankingChartData = computed(() => {
|
||||||
|
if (!props.rankingItems?.length) return null
|
||||||
|
|
||||||
|
const rankedTotal = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
|
||||||
|
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedTotal, 0)
|
||||||
|
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
|
||||||
|
const data = props.rankingItems.map((item) => item.actual_cost)
|
||||||
|
|
||||||
|
if (otherActualCost > 0.000001) {
|
||||||
|
labels.push(t('admin.dashboard.spendingRankingOther'))
|
||||||
|
data.push(otherActualCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
backgroundColor: chartColors.slice(0, data.length),
|
||||||
|
borderWidth: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const doughnutOptions = computed(() => ({
|
const doughnutOptions = computed(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -173,6 +311,26 @@ const doughnutOptions = computed(() => ({
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const rankingDoughnutOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: any) => {
|
||||||
|
const value = context.raw as number
|
||||||
|
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||||
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||||
|
return `${context.label}: $${formatCost(value)} (${percentage}%)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
const formatTokens = (value: number): string => {
|
const formatTokens = (value: number): string => {
|
||||||
if (value >= 1_000_000_000) {
|
if (value >= 1_000_000_000) {
|
||||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||||
@@ -188,6 +346,11 @@ const formatNumber = (value: number): string => {
|
|||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
|
||||||
|
if (item.email) return item.email
|
||||||
|
return t('admin.redeem.userPrefix', { id: item.user_id })
|
||||||
|
}
|
||||||
|
|
||||||
const formatCost = (value: number): string => {
|
const formatCost = (value: number): string => {
|
||||||
if (value >= 1000) {
|
if (value >= 1000) {
|
||||||
return (value / 1000).toFixed(2) + 'K'
|
return (value / 1000).toFixed(2) + 'K'
|
||||||
|
|||||||
@@ -963,6 +963,18 @@ export default {
|
|||||||
standard: 'Standard',
|
standard: 'Standard',
|
||||||
noDataAvailable: 'No data available',
|
noDataAvailable: 'No data available',
|
||||||
recentUsage: 'Recent Usage',
|
recentUsage: 'Recent Usage',
|
||||||
|
viewModelDistribution: 'Model Distribution',
|
||||||
|
viewSpendingRanking: 'User Spending Ranking',
|
||||||
|
spendingRankingTitle: 'User Spending Ranking',
|
||||||
|
spendingRankingUser: 'User',
|
||||||
|
spendingRankingRequests: 'Requests',
|
||||||
|
spendingRankingTokens: 'Tokens',
|
||||||
|
spendingRankingSpend: 'Spend',
|
||||||
|
spendingRankingOther: 'Others',
|
||||||
|
spendingRankingUsage: 'Usage',
|
||||||
|
spendShort: 'Spend',
|
||||||
|
requestsShort: 'Req',
|
||||||
|
tokensShort: 'Tok',
|
||||||
failedToLoad: 'Failed to load dashboard statistics'
|
failedToLoad: 'Failed to load dashboard statistics'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -974,6 +974,18 @@ export default {
|
|||||||
tokens: 'Token',
|
tokens: 'Token',
|
||||||
cache: '缓存',
|
cache: '缓存',
|
||||||
recentUsage: '最近使用',
|
recentUsage: '最近使用',
|
||||||
|
viewModelDistribution: '模型分布',
|
||||||
|
viewSpendingRanking: '用户消费榜',
|
||||||
|
spendingRankingTitle: '用户消费榜',
|
||||||
|
spendingRankingUser: '用户',
|
||||||
|
spendingRankingRequests: '请求',
|
||||||
|
spendingRankingTokens: 'Token',
|
||||||
|
spendingRankingSpend: '消费',
|
||||||
|
spendingRankingOther: '其他',
|
||||||
|
spendingRankingUsage: '用量',
|
||||||
|
spendShort: '消费',
|
||||||
|
requestsShort: '请求',
|
||||||
|
tokensShort: 'Token',
|
||||||
last7Days: '近 7 天',
|
last7Days: '近 7 天',
|
||||||
noUsageRecords: '暂无使用记录',
|
noUsageRecords: '暂无使用记录',
|
||||||
startUsingApi: '开始使用 API 后,使用历史将显示在这里。',
|
startUsingApi: '开始使用 API 后,使用历史将显示在这里。',
|
||||||
|
|||||||
@@ -1161,6 +1161,21 @@ export interface UserUsageTrendPoint {
|
|||||||
actual_cost: number // 实际扣除
|
actual_cost: number // 实际扣除
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSpendingRankingItem {
|
||||||
|
user_id: number
|
||||||
|
email: string
|
||||||
|
actual_cost: number
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSpendingRankingResponse {
|
||||||
|
ranking: UserSpendingRankingItem[]
|
||||||
|
total_actual_cost: number
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKeyUsageTrendPoint {
|
export interface ApiKeyUsageTrendPoint {
|
||||||
date: string
|
date: string
|
||||||
api_key_id: number
|
api_key_id: number
|
||||||
|
|||||||
@@ -236,7 +236,16 @@
|
|||||||
|
|
||||||
<!-- Charts Grid -->
|
<!-- Charts Grid -->
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
|
<ModelDistributionChart
|
||||||
|
:model-stats="modelStats"
|
||||||
|
:enable-ranking-view="true"
|
||||||
|
:ranking-items="rankingItems"
|
||||||
|
:ranking-total-actual-cost="rankingTotalActualCost"
|
||||||
|
:loading="chartsLoading"
|
||||||
|
:ranking-loading="rankingLoading"
|
||||||
|
:ranking-error="rankingError"
|
||||||
|
@ranking-click="goToUserUsage"
|
||||||
|
/>
|
||||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,11 +276,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
TrendDataPoint,
|
||||||
|
ModelStat,
|
||||||
|
UserUsageTrendPoint,
|
||||||
|
UserSpendingRankingItem
|
||||||
|
} from '@/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
@@ -286,7 +302,6 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler
|
Filler
|
||||||
@@ -299,24 +314,30 @@ ChartJS.register(
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler
|
Filler
|
||||||
)
|
)
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const router = useRouter()
|
||||||
const stats = ref<DashboardStats | null>(null)
|
const stats = ref<DashboardStats | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const chartsLoading = ref(false)
|
const chartsLoading = ref(false)
|
||||||
const userTrendLoading = ref(false)
|
const userTrendLoading = ref(false)
|
||||||
|
const rankingLoading = ref(false)
|
||||||
|
const rankingError = ref(false)
|
||||||
|
|
||||||
// Chart data
|
// Chart data
|
||||||
const trendData = ref<TrendDataPoint[]>([])
|
const trendData = ref<TrendDataPoint[]>([])
|
||||||
const modelStats = ref<ModelStat[]>([])
|
const modelStats = ref<ModelStat[]>([])
|
||||||
const userTrend = ref<UserUsageTrendPoint[]>([])
|
const userTrend = ref<UserUsageTrendPoint[]>([])
|
||||||
|
const rankingItems = ref<UserSpendingRankingItem[]>([])
|
||||||
|
const rankingTotalActualCost = ref(0)
|
||||||
let chartLoadSeq = 0
|
let chartLoadSeq = 0
|
||||||
let usersTrendLoadSeq = 0
|
let usersTrendLoadSeq = 0
|
||||||
|
let rankingLoadSeq = 0
|
||||||
|
const rankingLimit = 12
|
||||||
|
|
||||||
// Helper function to format date in local timezone
|
// Helper function to format date in local timezone
|
||||||
const formatLocalDate = (date: Date): string => {
|
const formatLocalDate = (date: Date): string => {
|
||||||
@@ -502,6 +523,17 @@ const formatDuration = (ms: number): string => {
|
|||||||
return `${Math.round(ms)}ms`
|
return `${Math.round(ms)}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToUserUsage = (item: UserSpendingRankingItem) => {
|
||||||
|
void router.push({
|
||||||
|
path: '/admin/usage',
|
||||||
|
query: {
|
||||||
|
user_id: String(item.user_id),
|
||||||
|
start_date: startDate.value,
|
||||||
|
end_date: endDate.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Date range change handler
|
// Date range change handler
|
||||||
const onDateRangeChange = (range: {
|
const onDateRangeChange = (range: {
|
||||||
startDate: string
|
startDate: string
|
||||||
@@ -582,14 +614,46 @@ const loadUsersTrend = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadUserSpendingRanking = async () => {
|
||||||
|
const currentSeq = ++rankingLoadSeq
|
||||||
|
rankingLoading.value = true
|
||||||
|
rankingError.value = false
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.dashboard.getUserSpendingRanking({
|
||||||
|
start_date: startDate.value,
|
||||||
|
end_date: endDate.value,
|
||||||
|
limit: rankingLimit
|
||||||
|
})
|
||||||
|
if (currentSeq !== rankingLoadSeq) return
|
||||||
|
rankingItems.value = response.ranking || []
|
||||||
|
rankingTotalActualCost.value = response.total_actual_cost || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (currentSeq !== rankingLoadSeq) return
|
||||||
|
console.error('Error loading user spending ranking:', error)
|
||||||
|
rankingItems.value = []
|
||||||
|
rankingTotalActualCost.value = 0
|
||||||
|
rankingError.value = true
|
||||||
|
} finally {
|
||||||
|
if (currentSeq === rankingLoadSeq) {
|
||||||
|
rankingLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadDashboardStats = async () => {
|
const loadDashboardStats = async () => {
|
||||||
await loadDashboardSnapshot(true)
|
await Promise.all([
|
||||||
void loadUsersTrend()
|
loadDashboardSnapshot(true),
|
||||||
|
loadUsersTrend(),
|
||||||
|
loadUserSpendingRanking()
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadChartData = async () => {
|
const loadChartData = async () => {
|
||||||
await loadDashboardSnapshot(false)
|
await Promise.all([
|
||||||
void loadUsersTrend()
|
loadDashboardSnapshot(false),
|
||||||
|
loadUsersTrend(),
|
||||||
|
loadUserSpendingRanking()
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
|
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
|
||||||
import { formatReasoningEffort } from '@/utils/format'
|
import { formatReasoningEffort } from '@/utils/format'
|
||||||
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
||||||
@@ -104,7 +105,7 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||||
|
const route = useRoute()
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||||
const modelDistributionMetric = ref<DistributionMetric>('tokens')
|
const modelDistributionMetric = ref<DistributionMetric>('tokens')
|
||||||
@@ -140,6 +141,38 @@ const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
|||||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||||
|
|
||||||
|
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
|
||||||
|
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNumericQueryValue = (value: string | null | Array<string | null> | undefined): number | undefined => {
|
||||||
|
const raw = getSingleQueryValue(value)
|
||||||
|
if (!raw) return undefined
|
||||||
|
const parsed = Number(raw)
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyRouteQueryFilters = () => {
|
||||||
|
const queryStartDate = getSingleQueryValue(route.query.start_date)
|
||||||
|
const queryEndDate = getSingleQueryValue(route.query.end_date)
|
||||||
|
const queryUserId = getNumericQueryValue(route.query.user_id)
|
||||||
|
|
||||||
|
if (queryStartDate) {
|
||||||
|
startDate.value = queryStartDate
|
||||||
|
}
|
||||||
|
if (queryEndDate) {
|
||||||
|
endDate.value = queryEndDate
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.value = {
|
||||||
|
...filters.value,
|
||||||
|
user_id: queryUserId,
|
||||||
|
start_date: startDate.value,
|
||||||
|
end_date: endDate.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
|
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -329,6 +362,7 @@ const handleColumnClickOutside = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
applyRouteQueryFilters()
|
||||||
loadLogs()
|
loadLogs()
|
||||||
loadStats()
|
loadStats()
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user