Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
4588258d
Unverified
Commit
4588258d
authored
Mar 13, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 13, 2026
Browse files
Merge pull request #960 from 0xObjc/codex/user-spending-ranking
feat(admin): add user spending ranking dashboard view
parents
c12e48f9
80d8d6c3
Changes
17
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/dashboard_handler.go
View file @
4588258d
...
@@ -466,9 +466,60 @@ type BatchUsersUsageRequest struct {
...
@@ -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
)
{
...
...
backend/internal/handler/admin/dashboard_handler_request_type_test.go
View file @
4588258d
...
@@ -19,6 +19,9 @@ type dashboardUsageRepoCapture struct {
...
@@ -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(
...
@@ -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
...
@@ -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) {
...
@@ -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"
))
}
backend/internal/handler/sora_gateway_handler_test.go
View file @
4588258d
...
@@ -343,6 +343,9 @@ func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e
...
@@ -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
}
}
...
...
backend/internal/pkg/usagestats/usage_log_types.go
View file @
4588258d
...
@@ -103,6 +103,21 @@ type UserUsageTrendPoint struct {
...
@@ -103,6 +103,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"`
...
...
backend/internal/repository/usage_log_repo.go
View file @
4588258d
...
@@ -1993,6 +1993,10 @@ type ModelStat = usagestats.ModelStat
...
@@ -1993,6 +1993,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
...
@@ -2109,6 +2113,78 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e
...
@@ -2109,6 +2113,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
...
...
backend/internal/repository/usage_log_repo_request_type_test.go
View file @
4588258d
...
@@ -248,6 +248,35 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T)
...
@@ -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
...
...
backend/internal/server/api_contract_test.go
View file @
4588258d
...
@@ -1635,6 +1635,10 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
...
@@ -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
{
...
...
backend/internal/server/routes/admin.go
View file @
4588258d
...
@@ -192,6 +192,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -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
)
...
...
backend/internal/service/account_usage_service.go
View file @
4588258d
...
@@ -47,6 +47,7 @@ type UsageLogRepository interface {
...
@@ -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
)
...
...
backend/internal/service/dashboard_service.go
View file @
4588258d
...
@@ -327,6 +327,14 @@ func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, end
...
@@ -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
{
...
...
frontend/src/api/admin/dashboard.ts
View file @
4588258d
...
@@ -11,6 +11,7 @@ import type {
...
@@ -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 {
...
@@ -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
...
@@ -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 = {
...
@@ -271,6 +291,7 @@ export const dashboardAPI = {
getSnapshotV2
,
getSnapshotV2
,
getApiKeyUsageTrend
,
getApiKeyUsageTrend
,
getUserUsageTrend
,
getUserUsageTrend
,
getUserSpendingRanking
,
getBatchUsersUsage
,
getBatchUsersUsage
,
getBatchApiKeysUsage
getBatchApiKeysUsage
}
}
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
4588258d
...
@@ -2,38 +2,72 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 = [
...
@@ -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(() => {
...
@@ -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(() => ({
...
@@ -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 => {
...
@@ -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
'
...
...
frontend/src/i18n/locales/en.ts
View file @
4588258d
...
@@ -963,6 +963,18 @@ export default {
...
@@ -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
'
},
},
...
...
frontend/src/i18n/locales/zh.ts
View file @
4588258d
...
@@ -974,6 +974,18 @@ export default {
...
@@ -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 后,使用历史将显示在这里。
'
,
...
...
frontend/src/types/index.ts
View file @
4588258d
...
@@ -1162,6 +1162,21 @@ export interface UserUsageTrendPoint {
...
@@ -1162,6 +1162,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
...
...
frontend/src/views/admin/DashboardView.vue
View file @
4588258d
...
@@ -236,7 +236,16 @@
...
@@ -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 @@
...
@@ -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 {
...
@@ -286,7 +302,6 @@ import {
LinearScale
,
LinearScale
,
PointElement
,
PointElement
,
LineElement
,
LineElement
,
Title
,
Tooltip
,
Tooltip
,
Legend
,
Legend
,
Filler
Filler
...
@@ -299,24 +314,30 @@ ChartJS.register(
...
@@ -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
=>
{
...
@@ -505,6 +526,17 @@ const formatDuration = (ms: number): string => {
...
@@ -505,6 +526,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
...
@@ -585,14 +617,46 @@ const loadUsersTrend = async () => {
...
@@ -585,14 +617,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
(()
=>
{
...
...
frontend/src/views/admin/UsageView.vue
View file @
4588258d
...
@@ -89,6 +89,7 @@
...
@@ -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
...
@@ -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(getTodayLocalDate()); const endDate = ref(getTodayLocalDat
...
@@ -140,6 +141,38 @@ const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDat
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) => {
...
@@ -329,6 +362,7 @@ const handleColumnClickOutside = (event: MouseEvent) => {
}
}
onMounted
(()
=>
{
onMounted
(()
=>
{
applyRouteQueryFilters
()
loadLogs
()
loadLogs
()
loadStats
()
loadStats
()
window
.
setTimeout
(()
=>
{
window
.
setTimeout
(()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment