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
6cf77040
Unverified
Commit
6cf77040
authored
Mar 17, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 17, 2026
Browse files
Merge pull request #1075 from touwaeriol/feat/dashboard-user-breakdown
feat(dashboard): add per-user drill-down for distribution charts
parents
20b70bc5
a120a6bc
Changes
18
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/dashboard_handler.go
View file @
6cf77040
...
@@ -9,6 +9,7 @@ import (
...
@@ -9,6 +9,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
...
@@ -604,3 +605,41 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
...
@@ -604,3 +605,41 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
c
.
Header
(
"X-Snapshot-Cache"
,
"miss"
)
c
.
Header
(
"X-Snapshot-Cache"
,
"miss"
)
response
.
Success
(
c
,
payload
)
response
.
Success
(
c
,
payload
)
}
}
// GetUserBreakdown handles getting per-user usage breakdown within a dimension.
// GET /api/v1/admin/dashboard/user-breakdown
// Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit
func
(
h
*
DashboardHandler
)
GetUserBreakdown
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
dim
:=
usagestats
.
UserBreakdownDimension
{}
if
v
:=
c
.
Query
(
"group_id"
);
v
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
v
,
10
,
64
);
err
==
nil
{
dim
.
GroupID
=
id
}
}
dim
.
Model
=
c
.
Query
(
"model"
)
dim
.
Endpoint
=
c
.
Query
(
"endpoint"
)
dim
.
EndpointType
=
c
.
DefaultQuery
(
"endpoint_type"
,
"inbound"
)
limit
:=
50
if
v
:=
c
.
Query
(
"limit"
);
v
!=
""
{
if
n
,
err
:=
strconv
.
Atoi
(
v
);
err
==
nil
&&
n
>
0
&&
n
<=
200
{
limit
=
n
}
}
stats
,
err
:=
h
.
dashboardService
.
GetUserBreakdownStats
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
dim
,
limit
,
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get user breakdown stats"
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"users"
:
stats
,
"start_date"
:
startTime
.
Format
(
"2006-01-02"
),
"end_date"
:
endTime
.
Add
(
-
24
*
time
.
Hour
)
.
Format
(
"2006-01-02"
),
})
}
backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go
0 → 100644
View file @
6cf77040
package
admin
import
(
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// --- mock repo ---
type
userBreakdownRepoCapture
struct
{
service
.
UsageLogRepository
capturedDim
usagestats
.
UserBreakdownDimension
capturedLimit
int
result
[]
usagestats
.
UserBreakdownItem
}
func
(
r
*
userBreakdownRepoCapture
)
GetUserBreakdownStats
(
_
context
.
Context
,
_
,
_
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
,
)
([]
usagestats
.
UserBreakdownItem
,
error
)
{
r
.
capturedDim
=
dim
r
.
capturedLimit
=
limit
if
r
.
result
!=
nil
{
return
r
.
result
,
nil
}
return
[]
usagestats
.
UserBreakdownItem
{},
nil
}
func
newUserBreakdownRouter
(
repo
*
userBreakdownRepoCapture
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
svc
:=
service
.
NewDashboardService
(
repo
,
nil
,
nil
,
nil
)
h
:=
NewDashboardHandler
(
svc
,
nil
)
router
:=
gin
.
New
()
router
.
GET
(
"/admin/dashboard/user-breakdown"
,
h
.
GetUserBreakdown
)
return
router
}
// --- tests ---
func
TestGetUserBreakdown_GroupIDFilter
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=42"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
int64
(
42
),
repo
.
capturedDim
.
GroupID
)
require
.
Empty
(
t
,
repo
.
capturedDim
.
Model
)
require
.
Empty
(
t
,
repo
.
capturedDim
.
Endpoint
)
require
.
Equal
(
t
,
50
,
repo
.
capturedLimit
)
// default limit
}
func
TestGetUserBreakdown_ModelFilter
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
"claude-opus-4-6"
,
repo
.
capturedDim
.
Model
)
require
.
Equal
(
t
,
int64
(
0
),
repo
.
capturedDim
.
GroupID
)
}
func
TestGetUserBreakdown_EndpointFilter
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/v1/messages&endpoint_type=upstream"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
"/v1/messages"
,
repo
.
capturedDim
.
Endpoint
)
require
.
Equal
(
t
,
"upstream"
,
repo
.
capturedDim
.
EndpointType
)
}
func
TestGetUserBreakdown_DefaultEndpointType
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/chat"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
"inbound"
,
repo
.
capturedDim
.
EndpointType
)
}
func
TestGetUserBreakdown_CustomLimit
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=100"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
100
,
repo
.
capturedLimit
)
}
func
TestGetUserBreakdown_LimitClamped
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
// limit > 200 should fall back to default 50
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=999"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
50
,
repo
.
capturedLimit
)
}
func
TestGetUserBreakdown_ResponseFormat
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{
result
:
[]
usagestats
.
UserBreakdownItem
{
{
UserID
:
1
,
Email
:
"alice@test.com"
,
Requests
:
100
,
TotalTokens
:
50000
,
Cost
:
1.5
,
ActualCost
:
1.2
},
{
UserID
:
2
,
Email
:
"bob@test.com"
,
Requests
:
50
,
TotalTokens
:
25000
,
Cost
:
0.8
,
ActualCost
:
0.6
},
},
}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=1"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
Users
[]
usagestats
.
UserBreakdownItem
`json:"users"`
StartDate
string
`json:"start_date"`
EndDate
string
`json:"end_date"`
}
`json:"data"`
}
err
:=
json
.
Unmarshal
(
w
.
Body
.
Bytes
(),
&
resp
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Users
,
2
)
require
.
Equal
(
t
,
int64
(
1
),
resp
.
Data
.
Users
[
0
]
.
UserID
)
require
.
Equal
(
t
,
"alice@test.com"
,
resp
.
Data
.
Users
[
0
]
.
Email
)
require
.
Equal
(
t
,
int64
(
100
),
resp
.
Data
.
Users
[
0
]
.
Requests
)
require
.
InDelta
(
t
,
1.2
,
resp
.
Data
.
Users
[
0
]
.
ActualCost
,
0.001
)
require
.
Equal
(
t
,
"2026-03-01"
,
resp
.
Data
.
StartDate
)
require
.
Equal
(
t
,
"2026-03-16"
,
resp
.
Data
.
EndDate
)
}
func
TestGetUserBreakdown_EmptyResult
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=999"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
var
resp
struct
{
Data
struct
{
Users
[]
usagestats
.
UserBreakdownItem
`json:"users"`
}
`json:"data"`
}
err
:=
json
.
Unmarshal
(
w
.
Body
.
Bytes
(),
&
resp
)
require
.
NoError
(
t
,
err
)
require
.
Empty
(
t
,
resp
.
Data
.
Users
)
}
func
TestGetUserBreakdown_NoFilters
(
t
*
testing
.
T
)
{
repo
:=
&
userBreakdownRepoCapture
{}
router
:=
newUserBreakdownRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16"
,
nil
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
int64
(
0
),
repo
.
capturedDim
.
GroupID
)
require
.
Empty
(
t
,
repo
.
capturedDim
.
Model
)
require
.
Empty
(
t
,
repo
.
capturedDim
.
Endpoint
)
}
backend/internal/handler/sora_gateway_handler_test.go
View file @
6cf77040
...
@@ -345,6 +345,9 @@ func (s *stubUsageLogRepo) GetUpstreamEndpointStatsWithFilters(ctx context.Conte
...
@@ -345,6 +345,9 @@ func (s *stubUsageLogRepo) GetUpstreamEndpointStatsWithFilters(ctx context.Conte
func
(
s
*
stubUsageLogRepo
)
GetGroupStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
GroupStat
,
error
)
{
func
(
s
*
stubUsageLogRepo
)
GetGroupStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
GroupStat
,
error
)
{
return
nil
,
nil
return
nil
,
nil
}
}
func
(
s
*
stubUsageLogRepo
)
GetUserBreakdownStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
)
([]
usagestats
.
UserBreakdownItem
,
error
)
{
return
nil
,
nil
}
func
(
s
*
stubUsageLogRepo
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
{
func
(
s
*
stubUsageLogRepo
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
{
return
nil
,
nil
return
nil
,
nil
}
}
...
...
backend/internal/pkg/usagestats/usage_log_types.go
View file @
6cf77040
...
@@ -129,6 +129,24 @@ type UserSpendingRankingResponse struct {
...
@@ -129,6 +129,24 @@ type UserSpendingRankingResponse struct {
TotalTokens
int64
`json:"total_tokens"`
TotalTokens
int64
`json:"total_tokens"`
}
}
// UserBreakdownItem represents per-user usage breakdown within a dimension (group, model, endpoint).
type
UserBreakdownItem
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Requests
int64
`json:"requests"`
TotalTokens
int64
`json:"total_tokens"`
Cost
float64
`json:"cost"`
// 标准计费
ActualCost
float64
`json:"actual_cost"`
// 实际扣除
}
// UserBreakdownDimension specifies the dimension to filter for user breakdown.
type
UserBreakdownDimension
struct
{
GroupID
int64
// filter by group_id (>0 to enable)
Model
string
// filter by model name (non-empty to enable)
Endpoint
string
// filter by endpoint value (non-empty to enable)
EndpointType
string
// "inbound", "upstream", or "path"
}
// 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 @
6cf77040
...
@@ -3000,6 +3000,85 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start
...
@@ -3000,6 +3000,85 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start
return
results
,
nil
return
results
,
nil
}
}
// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
func
(
r
*
usageLogRepository
)
GetUserBreakdownStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
)
(
results
[]
usagestats
.
UserBreakdownItem
,
err
error
)
{
query
:=
`
SELECT
COALESCE(ul.user_id, 0) as user_id,
COALESCE(u.email, '') as email,
COUNT(*) as requests,
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
COALESCE(SUM(ul.total_cost), 0) as cost,
COALESCE(SUM(ul.actual_cost), 0) as actual_cost
FROM usage_logs ul
LEFT JOIN users u ON u.id = ul.user_id
WHERE ul.created_at >= $1 AND ul.created_at < $2
`
args
:=
[]
any
{
startTime
,
endTime
}
if
dim
.
GroupID
>
0
{
query
+=
fmt
.
Sprintf
(
" AND ul.group_id = $%d"
,
len
(
args
)
+
1
)
args
=
append
(
args
,
dim
.
GroupID
)
}
if
dim
.
Model
!=
""
{
query
+=
fmt
.
Sprintf
(
" AND ul.model = $%d"
,
len
(
args
)
+
1
)
args
=
append
(
args
,
dim
.
Model
)
}
if
dim
.
Endpoint
!=
""
{
col
:=
resolveEndpointColumn
(
dim
.
EndpointType
)
query
+=
fmt
.
Sprintf
(
" AND %s = $%d"
,
col
,
len
(
args
)
+
1
)
args
=
append
(
args
,
dim
.
Endpoint
)
}
query
+=
" GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC"
if
limit
>
0
{
query
+=
fmt
.
Sprintf
(
" LIMIT %d"
,
limit
)
}
rows
,
err
:=
r
.
sql
.
QueryContext
(
ctx
,
query
,
args
...
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
if
closeErr
:=
rows
.
Close
();
closeErr
!=
nil
&&
err
==
nil
{
err
=
closeErr
results
=
nil
}
}()
results
=
make
([]
usagestats
.
UserBreakdownItem
,
0
)
for
rows
.
Next
()
{
var
row
usagestats
.
UserBreakdownItem
if
err
:=
rows
.
Scan
(
&
row
.
UserID
,
&
row
.
Email
,
&
row
.
Requests
,
&
row
.
TotalTokens
,
&
row
.
Cost
,
&
row
.
ActualCost
,
);
err
!=
nil
{
return
nil
,
err
}
results
=
append
(
results
,
row
)
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
results
,
nil
}
// resolveEndpointColumn maps endpoint type to the corresponding DB column name.
func
resolveEndpointColumn
(
endpointType
string
)
string
{
switch
endpointType
{
case
"upstream"
:
return
"ul.upstream_endpoint"
case
"path"
:
return
"ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint"
default
:
return
"ul.inbound_endpoint"
}
}
// GetGlobalStats gets usage statistics for all users within a time range
// GetGlobalStats gets usage statistics for all users within a time range
func
(
r
*
usageLogRepository
)
GetGlobalStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
func
(
r
*
usageLogRepository
)
GetGlobalStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
query
:=
`
query
:=
`
...
...
backend/internal/repository/usage_log_repo_breakdown_test.go
0 → 100644
View file @
6cf77040
//go:build unit
package
repository
import
(
"testing"
"github.com/stretchr/testify/require"
)
func
TestResolveEndpointColumn
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
endpointType
string
want
string
}{
{
"inbound"
,
"ul.inbound_endpoint"
},
{
"upstream"
,
"ul.upstream_endpoint"
},
{
"path"
,
"ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint"
},
{
""
,
"ul.inbound_endpoint"
},
// default
{
"unknown"
,
"ul.inbound_endpoint"
},
// fallback
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
endpointType
,
func
(
t
*
testing
.
T
)
{
got
:=
resolveEndpointColumn
(
tc
.
endpointType
)
require
.
Equal
(
t
,
tc
.
want
,
got
)
})
}
}
backend/internal/server/api_contract_test.go
View file @
6cf77040
...
@@ -1637,6 +1637,10 @@ func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi
...
@@ -1637,6 +1637,10 @@ func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
r
*
stubUsageLogRepo
)
GetUserBreakdownStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
)
([]
usagestats
.
UserBreakdownItem
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
r
*
stubUsageLogRepo
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
{
func
(
r
*
stubUsageLogRepo
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
...
...
backend/internal/server/routes/admin.go
View file @
6cf77040
...
@@ -198,6 +198,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -198,6 +198,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
dashboard
.
GET
(
"/users-ranking"
,
h
.
Admin
.
Dashboard
.
GetUserSpendingRanking
)
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
.
GET
(
"/user-breakdown"
,
h
.
Admin
.
Dashboard
.
GetUserBreakdown
)
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 @
6cf77040
...
@@ -48,6 +48,7 @@ type UsageLogRepository interface {
...
@@ -48,6 +48,7 @@ type UsageLogRepository interface {
GetEndpointStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
EndpointStat
,
error
)
GetEndpointStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
EndpointStat
,
error
)
GetUpstreamEndpointStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
EndpointStat
,
error
)
GetUpstreamEndpointStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
model
string
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
EndpointStat
,
error
)
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
)
GetUserBreakdownStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
)
([]
usagestats
.
UserBreakdownItem
,
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
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
...
...
backend/internal/service/dashboard_service.go
View file @
6cf77040
...
@@ -335,6 +335,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime
...
@@ -335,6 +335,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime
return
ranking
,
nil
return
ranking
,
nil
}
}
func
(
s
*
DashboardService
)
GetUserBreakdownStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
dim
usagestats
.
UserBreakdownDimension
,
limit
int
)
([]
usagestats
.
UserBreakdownItem
,
error
)
{
stats
,
err
:=
s
.
usageRepo
.
GetUserBreakdownStats
(
ctx
,
startTime
,
endTime
,
dim
,
limit
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get user breakdown stats: %w"
,
err
)
}
return
stats
,
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 @
6cf77040
...
@@ -12,6 +12,7 @@ import type {
...
@@ -12,6 +12,7 @@ import type {
ApiKeyUsageTrendPoint
,
ApiKeyUsageTrendPoint
,
UserUsageTrendPoint
,
UserUsageTrendPoint
,
UserSpendingRankingResponse
,
UserSpendingRankingResponse
,
UserBreakdownItem
,
UsageRequestType
UsageRequestType
}
from
'
@/types
'
}
from
'
@/types
'
...
@@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
...
@@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
return
data
return
data
}
}
export
interface
UserBreakdownParams
{
start_date
?:
string
end_date
?:
string
group_id
?:
number
model
?:
string
endpoint
?:
string
endpoint_type
?:
'
inbound
'
|
'
upstream
'
|
'
path
'
limit
?:
number
}
export
interface
UserBreakdownResponse
{
users
:
UserBreakdownItem
[]
start_date
:
string
end_date
:
string
}
export
async
function
getUserBreakdown
(
params
:
UserBreakdownParams
):
Promise
<
UserBreakdownResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserBreakdownResponse
>
(
'
/admin/dashboard/user-breakdown
'
,
{
params
})
return
data
}
/**
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/
*/
...
...
frontend/src/components/charts/EndpointDistributionChart.vue
View file @
6cf77040
...
@@ -87,27 +87,40 @@
...
@@ -87,27 +87,40 @@
</tr>
</tr>
</thead>
</thead>
<tbody>
<tbody>
<tr
<template
v-for=
"item in displayEndpointStats"
:key=
"item.endpoint"
>
v-for=
"item in displayEndpointStats"
<tr
:key=
"item.endpoint"
class=
"border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
class=
"border-t border-gray-100 dark:border-gray-700"
@
click=
"toggleBreakdown(item.endpoint)"
>
>
<td
class=
"max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"item.endpoint"
>
<td
class=
"max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
:title=
"item.endpoint"
>
{{
item
.
endpoint
}}
<span
class=
"inline-flex items-center gap-1"
>
</td>
<svg
v-if=
"expandedKey === item.endpoint"
class=
"h-3 w-3 shrink-0"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 9l-7 7-7-7"
/></svg>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
<svg
v-else
class=
"h-3 w-3 shrink-0"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 5l7 7-7 7"
/></svg>
{{
formatNumber
(
item
.
requests
)
}}
{{
item
.
endpoint
}}
</td>
</span>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
</td>
{{
formatTokens
(
item
.
total_tokens
)
}}
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
</td>
{{
formatNumber
(
item
.
requests
)
}}
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
</td>
$
{{
formatCost
(
item
.
actual_cost
)
}}
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
</td>
{{
formatTokens
(
item
.
total_tokens
)
}}
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
</td>
$
{{
formatCost
(
item
.
cost
)
}}
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
</td>
$
{{
formatCost
(
item
.
actual_cost
)
}}
</tr>
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
item
.
cost
)
}}
</td>
</tr>
<tr
v-if=
"expandedKey === item.endpoint"
>
<td
colspan=
"5"
class=
"p-0"
>
<UserBreakdownSubTable
:items=
"breakdownItems"
:loading=
"breakdownLoading"
/>
</td>
</tr>
</
template
>
</tbody>
</tbody>
</table>
</table>
</div>
</div>
...
@@ -119,12 +132,14 @@
...
@@ -119,12 +132,14 @@
</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
{
EndpointStat
}
from
'
@/types
'
import
UserBreakdownSubTable
from
'
./UserBreakdownSubTable.vue
'
import
type
{
EndpointStat
,
UserBreakdownItem
}
from
'
@/types
'
import
{
getUserBreakdown
}
from
'
@/api/admin/dashboard
'
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
...
@@ -144,6 +159,8 @@ const props = withDefaults(
...
@@ -144,6 +159,8 @@ const props = withDefaults(
source
?:
EndpointSource
source
?:
EndpointSource
showMetricToggle
?:
boolean
showMetricToggle
?:
boolean
showSourceToggle
?:
boolean
showSourceToggle
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
}
>
(),
{
{
upstreamEndpointStats
:
()
=>
[],
upstreamEndpointStats
:
()
=>
[],
...
@@ -162,6 +179,33 @@ const emit = defineEmits<{
...
@@ -162,6 +179,33 @@ const emit = defineEmits<{
'
update:source
'
:
[
value
:
EndpointSource
]
'
update:source
'
:
[
value
:
EndpointSource
]
}
>
()
}
>
()
const
expandedKey
=
ref
<
string
|
null
>
(
null
)
const
breakdownItems
=
ref
<
UserBreakdownItem
[]
>
([])
const
breakdownLoading
=
ref
(
false
)
const
toggleBreakdown
=
async
(
endpoint
:
string
)
=>
{
if
(
expandedKey
.
value
===
endpoint
)
{
expandedKey
.
value
=
null
return
}
expandedKey
.
value
=
endpoint
breakdownLoading
.
value
=
true
breakdownItems
.
value
=
[]
try
{
const
res
=
await
getUserBreakdown
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
endpoint
,
endpoint_type
:
props
.
source
,
})
breakdownItems
.
value
=
res
.
users
||
[]
}
catch
{
breakdownItems
.
value
=
[]
}
finally
{
breakdownLoading
.
value
=
false
}
}
const
chartColors
=
[
const
chartColors
=
[
'
#3b82f6
'
,
'
#3b82f6
'
,
'
#10b981
'
,
'
#10b981
'
,
...
...
frontend/src/components/charts/GroupDistributionChart.vue
View file @
6cf77040
...
@@ -49,30 +49,46 @@
...
@@ -49,30 +49,46 @@
</tr>
</tr>
</thead>
</thead>
<tbody>
<tbody>
<tr
<template
v-for=
"group in displayGroupStats"
:key=
"group.group_id"
>
v-for=
"group in displayGroupStats"
<tr
:key=
"group.group_id"
class=
"border-t border-gray-100 transition-colors dark:border-gray-700"
class=
"border-t border-gray-100 dark:border-gray-700"
:class=
"group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
>
@
click=
"group.group_id > 0 && toggleBreakdown('group', group.group_id)"
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"group.group_name || String(group.group_id)"
>
>
{{
group
.
group_name
||
t
(
'
admin.dashboard.noGroup
'
)
}}
<td
</td>
class=
"max-w-[100px] truncate py-1.5 font-medium"
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
:class=
"group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
{{
formatNumber
(
group
.
requests
)
}}
:title=
"group.group_name || String(group.group_id)"
</td>
>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
<span
class=
"inline-flex items-center gap-1"
>
{{
formatTokens
(
group
.
total_tokens
)
}}
<svg
v-if=
"group.group_id > 0 && expandedKey === `group-$
{group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 9l-7 7-7-7"
/></svg>
</td>
<svg
v-else-if=
"group.group_id > 0"
class=
"h-3 w-3 shrink-0"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 5l7 7-7 7"
/></svg>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
{{
group
.
group_name
||
t
(
'
admin.dashboard.noGroup
'
)
}}
$
{{
formatCost
(
group
.
actual_cost
)
}}
</span>
</td>
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
$
{{
formatCost
(
group
.
cost
)
}}
{{
formatNumber
(
group
.
requests
)
}}
</td>
</td>
</tr>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
group
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
group
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
group
.
cost
)
}}
</td>
</tr>
<!-- User breakdown sub-rows -->
<tr
v-if=
"expandedKey === `group-$
{group.group_id}`">
<td
colspan=
"5"
class=
"p-0"
>
<UserBreakdownSubTable
:items=
"breakdownItems"
:loading=
"breakdownLoading"
/>
</td>
</tr>
</
template
>
</tbody>
</tbody>
</table>
</table>
</div>
</div>
...
@@ -87,12 +103,14 @@
...
@@ -87,12 +103,14 @@
</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
{
GroupStat
}
from
'
@/types
'
import
UserBreakdownSubTable
from
'
./UserBreakdownSubTable.vue
'
import
type
{
GroupStat
,
UserBreakdownItem
}
from
'
@/types
'
import
{
getUserBreakdown
}
from
'
@/api/admin/dashboard
'
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
...
@@ -105,6 +123,8 @@ const props = withDefaults(defineProps<{
...
@@ -105,6 +123,8 @@ const props = withDefaults(defineProps<{
loading
?:
boolean
loading
?:
boolean
metric
?:
DistributionMetric
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
showMetricToggle
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
{
}
>
(),
{
loading
:
false
,
loading
:
false
,
metric
:
'
tokens
'
,
metric
:
'
tokens
'
,
...
@@ -115,6 +135,33 @@ const emit = defineEmits<{
...
@@ -115,6 +135,33 @@ const emit = defineEmits<{
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
update:metric
'
:
[
value
:
DistributionMetric
]
}
>
()
}
>
()
const
expandedKey
=
ref
<
string
|
null
>
(
null
)
const
breakdownItems
=
ref
<
UserBreakdownItem
[]
>
([])
const
breakdownLoading
=
ref
(
false
)
const
toggleBreakdown
=
async
(
type
:
string
,
id
:
number
|
string
)
=>
{
const
key
=
`
${
type
}
-
${
id
}
`
if
(
expandedKey
.
value
===
key
)
{
expandedKey
.
value
=
null
return
}
expandedKey
.
value
=
key
breakdownLoading
.
value
=
true
breakdownItems
.
value
=
[]
try
{
const
res
=
await
getUserBreakdown
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
group_id
:
Number
(
id
),
})
breakdownItems
.
value
=
res
.
users
||
[]
}
catch
{
breakdownItems
.
value
=
[]
}
finally
{
breakdownLoading
.
value
=
false
}
}
const
chartColors
=
[
const
chartColors
=
[
'
#3b82f6
'
,
'
#3b82f6
'
,
'
#10b981
'
,
'
#10b981
'
,
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
6cf77040
...
@@ -83,30 +83,43 @@
...
@@ -83,30 +83,43 @@
</tr>
</tr>
</thead>
</thead>
<tbody>
<tbody>
<tr
<template
v-for=
"model in displayModelStats"
:key=
"model.model"
>
v-for=
"model in displayModelStats"
<tr
:key=
"model.model"
class=
"border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
class=
"border-t border-gray-100 dark:border-gray-700"
@
click=
"toggleBreakdown('model', model.model)"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
>
{{
model
.
model
}}
<td
</td>
class=
"max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
:title=
"model.model"
{{
formatNumber
(
model
.
requests
)
}}
>
</td>
<span
class=
"inline-flex items-center gap-1"
>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
<svg
v-if=
"expandedKey === `model-$
{model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 9l-7 7-7-7"
/></svg>
{{
formatTokens
(
model
.
total_tokens
)
}}
<svg
v-else
class=
"h-3 w-3 shrink-0"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 5l7 7-7 7"
/></svg>
</td>
{{
model
.
model
}}
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
</span>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
{{
formatNumber
(
model
.
requests
)
}}
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
</tr>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
<tr
v-if=
"expandedKey === `model-$
{model.model}`">
<td
colspan=
"5"
class=
"p-0"
>
<UserBreakdownSubTable
:items=
"breakdownItems"
:loading=
"breakdownLoading"
/>
</td>
</tr>
</
template
>
</tbody>
</tbody>
</table>
</table>
</div>
</div>
...
@@ -193,7 +206,9 @@ import { useI18n } from 'vue-i18n'
...
@@ -193,7 +206,9 @@ 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
,
UserSpendingRankingItem
}
from
'
@/types
'
import
UserBreakdownSubTable
from
'
./UserBreakdownSubTable.vue
'
import
type
{
ModelStat
,
UserSpendingRankingItem
,
UserBreakdownItem
}
from
'
@/types
'
import
{
getUserBreakdown
}
from
'
@/api/admin/dashboard
'
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
...
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
...
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
showMetricToggle
?:
boolean
showMetricToggle
?:
boolean
rankingLoading
?:
boolean
rankingLoading
?:
boolean
rankingError
?:
boolean
rankingError
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
{
}
>
(),
{
enableRankingView
:
false
,
enableRankingView
:
false
,
rankingItems
:
()
=>
[],
rankingItems
:
()
=>
[],
...
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
...
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
rankingError
:
false
rankingError
:
false
})
})
const
expandedKey
=
ref
<
string
|
null
>
(
null
)
const
breakdownItems
=
ref
<
UserBreakdownItem
[]
>
([])
const
breakdownLoading
=
ref
(
false
)
const
toggleBreakdown
=
async
(
type
:
string
,
id
:
string
)
=>
{
const
key
=
`
${
type
}
-
${
id
}
`
if
(
expandedKey
.
value
===
key
)
{
expandedKey
.
value
=
null
return
}
expandedKey
.
value
=
key
breakdownLoading
.
value
=
true
breakdownItems
.
value
=
[]
try
{
const
res
=
await
getUserBreakdown
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
model
:
id
,
})
breakdownItems
.
value
=
res
.
users
||
[]
}
catch
{
breakdownItems
.
value
=
[]
}
finally
{
breakdownLoading
.
value
=
false
}
}
const
emit
=
defineEmits
<
{
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
ranking-click
'
:
[
item
:
UserSpendingRankingItem
]
'
ranking-click
'
:
[
item
:
UserSpendingRankingItem
]
...
...
frontend/src/components/charts/UserBreakdownSubTable.vue
0 → 100644
View file @
6cf77040
<
template
>
<div
class=
"bg-gray-50/50 dark:bg-dark-700/30"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-3"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"items.length === 0"
class=
"py-2 text-center text-xs text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
<table
v-else
class=
"w-full text-xs"
>
<tbody>
<tr
v-for=
"user in items"
:key=
"user.user_id"
class=
"border-t border-gray-100/50 dark:border-gray-700/50"
>
<td
class=
"max-w-[120px] truncate py-1 pl-6 text-gray-600 dark:text-gray-300"
:title=
"user.email"
>
{{
user
.
email
||
`User #${user.user_id
}
`
}}
<
/td
>
<
td
class
=
"
py-1 text-right text-gray-500 dark:text-gray-400
"
>
{{
user
.
requests
.
toLocaleString
()
}}
<
/td
>
<
td
class
=
"
py-1 text-right text-gray-500 dark:text-gray-400
"
>
{{
formatTokens
(
user
.
total_tokens
)
}}
<
/td
>
<
td
class
=
"
py-1 text-right text-green-600 dark:text-green-400
"
>
$
{{
formatCost
(
user
.
actual_cost
)
}}
<
/td
>
<
td
class
=
"
py-1 pr-1 text-right text-gray-400 dark:text-gray-500
"
>
$
{{
formatCost
(
user
.
cost
)
}}
<
/td
>
<
/tr
>
<
/tbody
>
<
/table
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
type
{
UserBreakdownItem
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
defineProps
<
{
items
:
UserBreakdownItem
[]
loading
?:
boolean
}
>
()
const
formatTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000_000
)
return
`${(value / 1_000_000_000).toFixed(2)
}
B`
if
(
value
>=
1
_000_000
)
return
`${(value / 1_000_000).toFixed(2)
}
M`
if
(
value
>=
1
_000
)
return
`${(value / 1_000).toFixed(2)
}
K`
return
value
.
toLocaleString
()
}
const
formatCost
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1000
)
return
(
value
/
1000
).
toFixed
(
2
)
+
'
K
'
if
(
value
>=
1
)
return
value
.
toFixed
(
2
)
if
(
value
>=
0.01
)
return
value
.
toFixed
(
3
)
return
value
.
toFixed
(
4
)
}
<
/script
>
frontend/src/types/index.ts
View file @
6cf77040
...
@@ -1202,6 +1202,15 @@ export interface GroupStat {
...
@@ -1202,6 +1202,15 @@ export interface GroupStat {
actual_cost
:
number
// 实际扣除
actual_cost
:
number
// 实际扣除
}
}
export
interface
UserBreakdownItem
{
user_id
:
number
email
:
string
requests
:
number
total_tokens
:
number
cost
:
number
actual_cost
:
number
}
export
interface
UserUsageTrendPoint
{
export
interface
UserUsageTrendPoint
{
date
:
string
date
:
string
user_id
:
number
user_id
:
number
...
...
frontend/src/views/admin/DashboardView.vue
View file @
6cf77040
...
@@ -246,6 +246,8 @@
...
@@ -246,6 +246,8 @@
:loading=
"chartsLoading"
:loading=
"chartsLoading"
:ranking-loading=
"rankingLoading"
:ranking-loading=
"rankingLoading"
:ranking-error=
"rankingError"
:ranking-error=
"rankingError"
:start-date=
"startDate"
:end-date=
"endDate"
@
ranking-click=
"goToUserUsage"
@
ranking-click=
"goToUserUsage"
/>
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
...
...
frontend/src/views/admin/UsageView.vue
View file @
6cf77040
...
@@ -28,12 +28,16 @@
...
@@ -28,12 +28,16 @@
:model-stats=
"modelStats"
:model-stats=
"modelStats"
:loading=
"chartsLoading"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
:show-metric-toggle=
"true"
:start-date=
"startDate"
:end-date=
"endDate"
/>
/>
<GroupDistributionChart
<GroupDistributionChart
v-model:metric=
"groupDistributionMetric"
v-model:metric=
"groupDistributionMetric"
:group-stats=
"groupStats"
:group-stats=
"groupStats"
:loading=
"chartsLoading"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
:show-metric-toggle=
"true"
:start-date=
"startDate"
:end-date=
"endDate"
/>
/>
</div>
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
...
@@ -47,6 +51,8 @@
...
@@ -47,6 +51,8 @@
:show-source-toggle=
"true"
:show-source-toggle=
"true"
:show-metric-toggle=
"true"
:show-metric-toggle=
"true"
:title=
"t('usage.endpointDistribution')"
:title=
"t('usage.endpointDistribution')"
:start-date=
"startDate"
:end-date=
"endDate"
/>
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
...
...
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