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 (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"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/gin-gonic/gin"
...
...
@@ -604,3 +605,41 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
c
.
Header
(
"X-Snapshot-Cache"
,
"miss"
)
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
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
}
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
)
{
return
nil
,
nil
}
...
...
backend/internal/pkg/usagestats/usage_log_types.go
View file @
6cf77040
...
...
@@ -129,6 +129,24 @@ type UserSpendingRankingResponse struct {
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
type
APIKeyUsageTrendPoint
struct
{
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
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
func
(
r
*
usageLogRepository
)
GetGlobalStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
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
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
)
{
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) {
dashboard
.
GET
(
"/users-ranking"
,
h
.
Admin
.
Dashboard
.
GetUserSpendingRanking
)
dashboard
.
POST
(
"/users-usage"
,
h
.
Admin
.
Dashboard
.
GetBatchUsersUsage
)
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
)
}
}
...
...
backend/internal/service/account_usage_service.go
View file @
6cf77040
...
...
@@ -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
)
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
)
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
)
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
)
...
...
backend/internal/service/dashboard_service.go
View file @
6cf77040
...
...
@@ -335,6 +335,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime
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
)
{
stats
,
err
:=
s
.
usageRepo
.
GetBatchUserUsageStats
(
ctx
,
userIDs
,
startTime
,
endTime
)
if
err
!=
nil
{
...
...
frontend/src/api/admin/dashboard.ts
View file @
6cf77040
...
...
@@ -12,6 +12,7 @@ import type {
ApiKeyUsageTrendPoint
,
UserUsageTrendPoint
,
UserSpendingRankingResponse
,
UserBreakdownItem
,
UsageRequestType
}
from
'
@/types
'
...
...
@@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
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).
*/
...
...
frontend/src/components/charts/EndpointDistributionChart.vue
View file @
6cf77040
...
...
@@ -87,27 +87,40 @@
</tr>
</thead>
<tbody>
<tr
v-for=
"item in displayEndpointStats"
:key=
"item.endpoint"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"item.endpoint"
>
{{
item
.
endpoint
}}
</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
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
item
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
item
.
cost
)
}}
</td>
</tr>
<template
v-for=
"item in displayEndpointStats"
:key=
"item.endpoint"
>
<tr
class=
"border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@
click=
"toggleBreakdown(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"
>
<span
class=
"inline-flex items-center gap-1"
>
<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>
<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>
{{
item
.
endpoint
}}
</span>
</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
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
item
.
actual_cost
)
}}
</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>
</table>
</div>
...
...
@@ -119,12 +132,14 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
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
)
...
...
@@ -144,6 +159,8 @@ const props = withDefaults(
source
?:
EndpointSource
showMetricToggle
?:
boolean
showSourceToggle
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
{
upstreamEndpointStats
:
()
=>
[],
...
...
@@ -162,6 +179,33 @@ const emit = defineEmits<{
'
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
=
[
'
#3b82f6
'
,
'
#10b981
'
,
...
...
frontend/src/components/charts/GroupDistributionChart.vue
View file @
6cf77040
...
...
@@ -49,30 +49,46 @@
</tr>
</thead>
<tbody>
<tr
v-for=
"group in displayGroupStats"
:key=
"group.group_id"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<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)"
<template
v-for=
"group in displayGroupStats"
:key=
"group.group_id"
>
<tr
class=
"border-t border-gray-100 transition-colors 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)"
>
{{
group
.
group_name
||
t
(
'
admin.dashboard.noGroup
'
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
group
.
requests
)
}}
</td>
<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>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium"
: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'"
:title=
"group.group_name || String(group.group_id)"
>
<span
class=
"inline-flex items-center gap-1"
>
<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>
<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>
{{
group
.
group_name
||
t
(
'
admin.dashboard.noGroup
'
)
}}
</span>
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
group
.
requests
)
}}
</td>
<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>
</table>
</div>
...
...
@@ -87,12 +103,14 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
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
)
...
...
@@ -105,6 +123,8 @@ const props = withDefaults(defineProps<{
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
{
loading
:
false
,
metric
:
'
tokens
'
,
...
...
@@ -115,6 +135,33 @@ const emit = defineEmits<{
'
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
=
[
'
#3b82f6
'
,
'
#10b981
'
,
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
6cf77040
...
...
@@ -83,30 +83,43 @@
</tr>
</thead>
<tbody>
<tr
v-for=
"model in displayModelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
<template
v-for=
"model in displayModelStats"
:key=
"model.model"
>
<tr
class=
"border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@
click=
"toggleBreakdown('model', model.model)"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
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>
<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"
:title=
"model.model"
>
<span
class=
"inline-flex items-center gap-1"
>
<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>
<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>
{{
model
.
model
}}
</span>
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
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>
</table>
</div>
...
...
@@ -193,7 +206,9 @@ import { useI18n } from 'vue-i18n'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
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
)
...
...
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
showMetricToggle
?:
boolean
rankingLoading
?:
boolean
rankingError
?:
boolean
startDate
?:
string
endDate
?:
string
}
>
(),
{
enableRankingView
:
false
,
rankingItems
:
()
=>
[],
...
...
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
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
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
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 {
actual_cost
:
number
// 实际扣除
}
export
interface
UserBreakdownItem
{
user_id
:
number
email
:
string
requests
:
number
total_tokens
:
number
cost
:
number
actual_cost
:
number
}
export
interface
UserUsageTrendPoint
{
date
:
string
user_id
:
number
...
...
frontend/src/views/admin/DashboardView.vue
View file @
6cf77040
...
...
@@ -246,6 +246,8 @@
:loading=
"chartsLoading"
:ranking-loading=
"rankingLoading"
:ranking-error=
"rankingError"
:start-date=
"startDate"
:end-date=
"endDate"
@
ranking-click=
"goToUserUsage"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
...
...
frontend/src/views/admin/UsageView.vue
View file @
6cf77040
...
...
@@ -28,12 +28,16 @@
:model-stats=
"modelStats"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
:start-date=
"startDate"
:end-date=
"endDate"
/>
<GroupDistributionChart
v-model:metric=
"groupDistributionMetric"
:group-stats=
"groupStats"
:loading=
"chartsLoading"
:show-metric-toggle=
"true"
:start-date=
"startDate"
:end-date=
"endDate"
/>
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
...
...
@@ -47,6 +51,8 @@
:show-source-toggle=
"true"
:show-metric-toggle=
"true"
:title=
"t('usage.endpointDistribution')"
:start-date=
"startDate"
:end-date=
"endDate"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</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