mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Merge pull request #1075 from touwaeriol/feat/dashboard-user-breakdown
feat(dashboard): add per-user drill-down for distribution charts
This commit is contained in:
@@ -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) {
|
|||||||
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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 := `
|
||||||
|
|||||||
29
backend/internal/repository/usage_log_repo_breakdown_test.go
Normal file
29
backend/internal/repository/usage_log_repo_breakdown_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|||||||
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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
</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(
|
|||||||
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<{
|
|||||||
'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',
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
</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<{
|
|||||||
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<{
|
|||||||
'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',
|
||||||
|
|||||||
@@ -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'
|
|||||||
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<{
|
|||||||
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<{
|
|||||||
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]
|
||||||
|
|||||||
62
frontend/src/components/charts/UserBreakdownSubTable.vue
Normal file
62
frontend/src/components/charts/UserBreakdownSubTable.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
: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>
|
||||||
|
|||||||
Reference in New Issue
Block a user