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
80d8d6c3
Commit
80d8d6c3
authored
Mar 13, 2026
by
Peter
Browse files
feat(admin): add user spending ranking dashboard view
parent
826090e0
Changes
17
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/dashboard_handler.go
View file @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -102,6 +102,21 @@ type UserUsageTrendPoint struct {
...
@@ -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"`
...
...
backend/internal/repository/usage_log_repo.go
View file @
80d8d6c3
...
@@ -1039,6 +1039,10 @@ type ModelStat = usagestats.ModelStat
...
@@ -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
...
@@ -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
...
...
backend/internal/repository/usage_log_repo_request_type_test.go
View file @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -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 @
80d8d6c3
...
@@ -1161,6 +1161,21 @@ export interface UserUsageTrendPoint {
...
@@ -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
...
...
frontend/src/views/admin/DashboardView.vue
View file @
80d8d6c3
...
@@ -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
=>
{
...
@@ -502,6 +523,17 @@ const formatDuration = (ms: number): 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 () => {
...
@@ -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
(()
=>
{
...
...
frontend/src/views/admin/UsageView.vue
View file @
80d8d6c3
...
@@ -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(formatLD(weekAgo)); const endDate = ref(formatLD(now))
...
@@ -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) => {
...
@@ -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