From 3619b01f748479388d598c4c8eb392f1d153870f Mon Sep 17 00:00:00 2001 From: Quaternijkon Date: Fri, 12 Jun 2026 23:16:58 +0800 Subject: [PATCH 1/2] feat(dashboard): add traffic flow sankey chart Add dashboard flow APIs and a Sankey-based flow view with user, optional API key, model, and channel layers.\n\nReuse the dashboard VChart palette, add precise link/node tooltips and interactions, and cover filtering, layer ordering, color stability, and error states with tests. --- controller/usedata.go | 41 + controller/usedata_flow_test.go | 87 ++ model/usedata_flow.go | 199 ++++ model/usedata_flow_test.go | 217 ++++ router/api-router.go | 2 + web/default/src/components/multi-select.tsx | 19 +- web/default/src/features/dashboard/api.ts | 24 +- .../dashboard/components/flow/flow-charts.tsx | 514 +++++++++ .../models/models-filter-dialog.tsx | 9 +- web/default/src/features/dashboard/index.tsx | 31 +- .../src/features/dashboard/lib/charts.ts | 11 +- .../dashboard/lib/flow-selection.test.ts | 166 +++ .../features/dashboard/lib/flow-selection.ts | 89 ++ .../src/features/dashboard/lib/flow.test.ts | 658 ++++++++++++ .../src/features/dashboard/lib/flow.ts | 995 ++++++++++++++++++ .../src/features/dashboard/lib/index.ts | 4 + .../features/dashboard/section-registry.tsx | 5 + web/default/src/features/dashboard/types.ts | 113 ++ web/default/src/i18n/locales/en.json | 11 + web/default/src/i18n/locales/fr.json | 11 + web/default/src/i18n/locales/ja.json | 11 + web/default/src/i18n/locales/ru.json | 11 + web/default/src/i18n/locales/vi.json | 11 + web/default/src/i18n/locales/zh.json | 11 + web/default/src/i18n/static-keys.ts | 4 + 25 files changed, 3245 insertions(+), 9 deletions(-) create mode 100644 controller/usedata_flow_test.go create mode 100644 model/usedata_flow.go create mode 100644 model/usedata_flow_test.go create mode 100644 web/default/src/features/dashboard/components/flow/flow-charts.tsx create mode 100644 web/default/src/features/dashboard/lib/flow-selection.test.ts create mode 100644 web/default/src/features/dashboard/lib/flow-selection.ts create mode 100644 web/default/src/features/dashboard/lib/flow.test.ts create mode 100644 web/default/src/features/dashboard/lib/flow.ts diff --git a/controller/usedata.go b/controller/usedata.go index 5e194c517506..df3f6273558e 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -66,3 +66,44 @@ func GetUserQuotaDates(c *gin.Context) { }) return } + +func GetAllFlowQuotaDates(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, username, 0) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} + +func GetUserFlowQuotaDates(c *gin.Context) { + userId := c.GetInt("id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + if endTimestamp-startTimestamp > 2592000 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "时间跨度不能超过 1 个月", + }) + return + } + dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} diff --git a/controller/usedata_flow_test.go b/controller/usedata_flow_test.go new file mode 100644 index 000000000000..1e9c51fb67e2 --- /dev/null +++ b/controller/usedata_flow_test.go @@ -0,0 +1,87 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type flowQuotaResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []model.FlowQuotaData `json:"data"` +} + +func setupFlowControllerTestDB(t *testing.T) { + t.Helper() + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, model.DB.Create(&model.Channel{Id: 1, Name: "east"}).Error) + require.NoError(t, model.LOG_DB.Create(&model.Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1100, + Type: model.LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + Group: "default", + Quota: 100, + }).Error) + require.NoError(t, model.LOG_DB.Create(&model.Log{ + UserId: 2, + Username: "bob", + CreatedAt: 1200, + Type: model.LogTypeConsume, + TokenId: 22, + TokenName: "backup", + ChannelId: 1, + Group: "vip", + Quota: 70, + }).Error) +} + +func decodeFlowQuotaResponse(t *testing.T, recorder *httptest.ResponseRecorder) flowQuotaResponse { + t.Helper() + require.Equal(t, http.StatusOK, recorder.Code) + var payload flowQuotaResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload)) + require.True(t, payload.Success, payload.Message) + return payload +} + +func TestGetAllFlowQuotaDatesUsesUsernameFilter(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/flow?start_timestamp=1000&end_timestamp=2000&username=bob", nil) + + GetAllFlowQuotaDates(ctx) + + payload := decodeFlowQuotaResponse(t, recorder) + require.Len(t, payload.Data, 1) + require.Equal(t, "bob", payload.Data[0].Username) + require.Equal(t, "backup", payload.Data[0].TokenName) +} + +func TestGetUserFlowQuotaDatesRestrictsToAuthenticatedUser(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("id", 1) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/flow/self?start_timestamp=1000&end_timestamp=2000", nil) + + GetUserFlowQuotaDates(ctx) + + payload := decodeFlowQuotaResponse(t, recorder) + require.Len(t, payload.Data, 1) + require.Equal(t, "alice", payload.Data[0].Username) + require.Equal(t, "primary", payload.Data[0].TokenName) +} diff --git a/model/usedata_flow.go b/model/usedata_flow.go new file mode 100644 index 000000000000..c39a7cae2bfc --- /dev/null +++ b/model/usedata_flow.go @@ -0,0 +1,199 @@ +package model + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" +) + +type FlowQuotaData struct { + UserID int `json:"user_id" gorm:"column:user_id"` + Username string `json:"username" gorm:"column:username"` + UserGroup string `json:"user_group" gorm:"column:user_group"` + TokenID int `json:"token_id" gorm:"column:token_id"` + TokenName string `json:"token_name" gorm:"column:token_name"` + ChannelID int `json:"channel_id" gorm:"column:channel_id"` + ChannelName string `json:"channel_name" gorm:"-"` + ModelName string `json:"model_name" gorm:"column:model_name"` + TokenUsed int `json:"token_used" gorm:"column:token_used"` + InputTokens int `json:"input_tokens" gorm:"column:input_tokens"` + Count int `json:"count" gorm:"column:count"` + Quota int `json:"quota" gorm:"column:quota"` + PromptTokens int `json:"prompt_tokens" gorm:"column:prompt_tokens"` + CompletionTokens int `json:"completion_tokens" gorm:"column:completion_tokens"` + CacheTokens int `json:"cache_tokens" gorm:"column:cache_tokens"` + CacheWriteTokens int `json:"cache_write_tokens" gorm:"column:cache_write_tokens"` +} + +func GetFlowQuotaData(startTime int64, endTime int64, username string, userID int) ([]*FlowQuotaData, error) { + groupCol := "logs." + logGroupCol + cacheTokensExpr := flowLogJSONNumberExpr("cache_tokens") + cacheWriteTokensExpr := flowLogCacheWriteTokensExpr() + inputTokensExpr := flowLogInputTokensExpr(cacheTokensExpr, cacheWriteTokensExpr) + rows := make([]*FlowQuotaData, 0) + query := LOG_DB.Table("logs"). + Select( + "logs.user_id, logs.username, logs.token_id, logs.token_name, logs.channel_id, logs.model_name, "+ + groupCol+" AS user_group, "+ + "COUNT(*) AS count, "+ + "COALESCE(SUM(logs.quota), 0) AS quota, "+ + "COALESCE(SUM(logs.prompt_tokens), 0) AS prompt_tokens, "+ + "COALESCE(SUM("+inputTokensExpr+"), 0) AS input_tokens, "+ + "COALESCE(SUM(logs.completion_tokens), 0) AS completion_tokens, "+ + "COALESCE(SUM("+cacheTokensExpr+"), 0) AS cache_tokens, "+ + "COALESCE(SUM("+cacheWriteTokensExpr+"), 0) AS cache_write_tokens", + ). + Where("logs.type = ?", LogTypeConsume) + + if startTime > 0 { + query = query.Where("logs.created_at >= ?", startTime) + } + if endTime > 0 { + query = query.Where("logs.created_at <= ?", endTime) + } + if username != "" { + query = query.Where("logs.username = ?", username) + } + if userID > 0 { + query = query.Where("logs.user_id = ?", userID) + } + + err := query. + Group("logs.user_id, logs.username, logs.token_id, logs.token_name, logs.channel_id, logs.model_name, " + groupCol). + Order("quota DESC"). + Find(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + row.TokenUsed = row.InputTokens + row.CompletionTokens + } + if err := fillFlowChannelNames(rows); err != nil { + return rows, err + } + return rows, nil +} + +func flowLogJSONValidGuard() string { + if common.UsingPostgreSQL { + return "COALESCE(logs.other, '') <> ''" + } + if common.UsingMySQL { + return "COALESCE(logs.other, '') <> '' AND JSON_VALID(logs.other)" + } + return "COALESCE(logs.other, '') <> '' AND json_valid(logs.other)" +} + +func flowLogJSONNumberExpr(key string) string { + guard := flowLogJSONValidGuard() + if common.UsingPostgreSQL { + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(NULLIF(logs.other::jsonb ->> '%s', '')::integer, 0) ELSE 0 END)", + guard, + key, + ) + } + if common.UsingMySQL { + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(logs.other, '$.%s')) AS SIGNED), 0) ELSE 0 END)", + guard, + key, + ) + } + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(CAST(json_extract(logs.other, '$.%s') AS INTEGER), 0) ELSE 0 END)", + guard, + key, + ) +} + +func flowLogJSONStringExpr(key string) string { + guard := flowLogJSONValidGuard() + if common.UsingPostgreSQL { + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(logs.other::jsonb ->> '%s', '') ELSE '' END)", + guard, + key, + ) + } + if common.UsingMySQL { + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(logs.other, '$.%s')), '') ELSE '' END)", + guard, + key, + ) + } + return fmt.Sprintf( + "(CASE WHEN %s THEN COALESCE(json_extract(logs.other, '$.%s'), '') ELSE '' END)", + guard, + key, + ) +} + +func flowLogCacheWriteTokensExpr() string { + cacheWriteTokens := flowLogJSONNumberExpr("cache_write_tokens") + cacheCreationTokens := flowLogJSONNumberExpr("cache_creation_tokens") + cacheCreation5mTokens := flowLogJSONNumberExpr("cache_creation_tokens_5m") + cacheCreation1hTokens := flowLogJSONNumberExpr("cache_creation_tokens_1h") + splitCacheCreationTokens := fmt.Sprintf("(%s + %s)", cacheCreation5mTokens, cacheCreation1hTokens) + return fmt.Sprintf( + "(CASE WHEN %s > 0 THEN %s WHEN %s > 0 THEN %s ELSE %s END)", + cacheWriteTokens, + cacheWriteTokens, + splitCacheCreationTokens, + splitCacheCreationTokens, + cacheCreationTokens, + ) +} + +func flowLogInputTokensExpr(cacheTokensExpr string, cacheWriteTokensExpr string) string { + usageSemanticExpr := flowLogJSONStringExpr("usage_semantic") + nonCacheInputExpr := fmt.Sprintf("(logs.prompt_tokens - %s - %s)", cacheTokensExpr, cacheWriteTokensExpr) + return fmt.Sprintf( + "(CASE WHEN %s = 'anthropic' THEN logs.prompt_tokens WHEN %s > 0 THEN %s ELSE 0 END)", + usageSemanticExpr, + nonCacheInputExpr, + nonCacheInputExpr, + ) +} + +func fillFlowChannelNames(rows []*FlowQuotaData) error { + channelIDSet := make(map[int]struct{}) + channelIDs := make([]int, 0) + for _, row := range rows { + if row.ChannelID == 0 { + continue + } + if _, ok := channelIDSet[row.ChannelID]; ok { + continue + } + channelIDSet[row.ChannelID] = struct{}{} + channelIDs = append(channelIDs, row.ChannelID) + } + if len(channelIDs) == 0 { + return nil + } + + var channels []struct { + Id int `gorm:"column:id"` + Name string `gorm:"column:name"` + } + if err := DB.Table("channels").Select("id, name").Where("id IN ?", channelIDs).Find(&channels).Error; err != nil { + return err + } + channelNameByID := make(map[int]string, len(channels)) + for _, channel := range channels { + channelNameByID[channel.Id] = channel.Name + } + for _, row := range rows { + if name := channelNameByID[row.ChannelID]; name != "" { + row.ChannelName = name + continue + } + if row.ChannelID > 0 { + row.ChannelName = fmt.Sprintf("channel-%d", row.ChannelID) + } + } + return nil +} diff --git a/model/usedata_flow_test.go b/model/usedata_flow_test.go new file mode 100644 index 000000000000..51365e659b8e --- /dev/null +++ b/model/usedata_flow_test.go @@ -0,0 +1,217 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/require" +) + +func seedFlowLog(t *testing.T, log Log) { + t.Helper() + require.NoError(t, LOG_DB.Create(&log).Error) +} + +func TestGetFlowQuotaDataAggregatesConsumeLogsByUserTokenAndChannel(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) + require.NoError(t, DB.Create(&Channel{Id: 2, Name: "west"}).Error) + + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1100, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + ModelName: "gpt-a", + Group: "default", + Quota: 100, + PromptTokens: 7, + CompletionTokens: 3, + }) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1200, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + ModelName: "gpt-a", + Group: "default", + Quota: 50, + PromptTokens: 5, + CompletionTokens: 1, + }) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1300, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 2, + ModelName: "gpt-a", + Group: "default", + Quota: 25, + PromptTokens: 2, + CompletionTokens: 2, + }) + seedFlowLog(t, Log{ + UserId: 2, + Username: "bob", + CreatedAt: 1400, + Type: LogTypeConsume, + TokenId: 22, + TokenName: "backup", + ChannelId: 1, + ModelName: "gpt-b", + Group: "vip", + Quota: 70, + PromptTokens: 4, + CompletionTokens: 6, + }) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1500, + Type: LogTypeError, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + Quota: 999, + }) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 900, + Type: LogTypeConsume, + TokenId: 33, + TokenName: "outside", + ChannelId: 2, + Quota: 500, + }) + + rows, err := GetFlowQuotaData(1000, 2000, "", 0) + require.NoError(t, err) + require.Len(t, rows, 3) + + require.Equal(t, FlowQuotaData{ + UserID: 1, + Username: "alice", + UserGroup: "default", + TokenID: 11, + TokenName: "primary", + ChannelID: 1, + ChannelName: "east", + ModelName: "gpt-a", + Count: 2, + Quota: 150, + PromptTokens: 12, + InputTokens: 12, + CompletionTokens: 4, + TokenUsed: 16, + }, *rows[0]) + require.Equal(t, "bob", rows[1].Username) + require.Equal(t, "backup", rows[1].TokenName) + require.Equal(t, "east", rows[1].ChannelName) + require.Equal(t, 10, rows[1].TokenUsed) + require.Equal(t, "west", rows[2].ChannelName) + require.Equal(t, 25, rows[2].Quota) +} + +func TestGetFlowQuotaDataFiltersByUsernameAndSelfUserID(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1100, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + Group: "default", + Quota: 100, + }) + seedFlowLog(t, Log{ + UserId: 2, + Username: "bob", + CreatedAt: 1200, + Type: LogTypeConsume, + TokenId: 22, + TokenName: "backup", + ChannelId: 1, + Group: "vip", + Quota: 70, + }) + + adminRows, err := GetFlowQuotaData(1000, 2000, "bob", 0) + require.NoError(t, err) + require.Len(t, adminRows, 1) + require.Equal(t, "bob", adminRows[0].Username) + + selfRows, err := GetFlowQuotaData(1000, 2000, "", 1) + require.NoError(t, err) + require.Len(t, selfRows, 1) + require.Equal(t, "alice", selfRows[0].Username) +} + +func TestGetFlowQuotaDataAggregatesCacheTokensAndNonCacheInput(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) + + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1100, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + ModelName: "gpt-a", + Group: "default", + Quota: 100, + PromptTokens: 100, + CompletionTokens: 10, + Other: common.MapToJsonStr(map[string]interface{}{ + "cache_tokens": 20, + "cache_write_tokens": 5, + }), + }) + seedFlowLog(t, Log{ + UserId: 1, + Username: "alice", + CreatedAt: 1200, + Type: LogTypeConsume, + TokenId: 11, + TokenName: "primary", + ChannelId: 1, + ModelName: "gpt-a", + Group: "default", + Quota: 50, + PromptTokens: 70, + CompletionTokens: 8, + Other: common.MapToJsonStr(map[string]interface{}{ + "cache_tokens": 30, + "cache_write_tokens": 4, + "usage_semantic": "anthropic", + }), + }) + + rows, err := GetFlowQuotaData(1000, 2000, "", 0) + require.NoError(t, err) + require.Len(t, rows, 1) + + require.Equal(t, 170, rows[0].PromptTokens) + require.Equal(t, 145, rows[0].InputTokens) + require.Equal(t, 18, rows[0].CompletionTokens) + require.Equal(t, 50, rows[0].CacheTokens) + require.Equal(t, 9, rows[0].CacheWriteTokens) + require.Equal(t, 163, rows[0].TokenUsed) +} diff --git a/router/api-router.go b/router/api-router.go index e98dc66ac048..1430e9c55913 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -317,6 +317,8 @@ func SetApiRouter(router *gin.Engine) { dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser) dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) + dataRoute.GET("/flow", middleware.AdminAuth(), controller.GetAllFlowQuotaDates) + dataRoute.GET("/flow/self", middleware.UserAuth(), controller.GetUserFlowQuotaDates) logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) { diff --git a/web/default/src/components/multi-select.tsx b/web/default/src/components/multi-select.tsx index d1f9f178e7c1..6396ba26d37c 100644 --- a/web/default/src/components/multi-select.tsx +++ b/web/default/src/components/multi-select.tsx @@ -64,6 +64,11 @@ interface MultiSelectProps { * Hidden values remain searchable/removable from the dropdown. */ maxVisibleChips?: number + /** + * Replaces individual chips with a compact summary while preserving the + * normal dropdown/search behaviour. + */ + renderSelectedSummary?: (values: string[]) => React.ReactNode } const COMMA_REGEX = /[,,\n]/ @@ -231,6 +236,14 @@ export function MultiSelect(props: MultiSelectProps) { > {(values: string[]) => { + if (props.renderSelectedSummary) { + return ( + + {props.renderSelectedSummary(values)} + + ) + } + const visibleValues = typeof props.maxVisibleChips === 'number' ? values.slice(0, props.maxVisibleChips) @@ -257,7 +270,11 @@ export function MultiSelect(props: MultiSelectProps) { diff --git a/web/default/src/features/dashboard/api.ts b/web/default/src/features/dashboard/api.ts index 29cbba92b179..18a7fe18b3a2 100644 --- a/web/default/src/features/dashboard/api.ts +++ b/web/default/src/features/dashboard/api.ts @@ -17,7 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' -import type { QuotaDataItem, UptimeGroupResult } from './types' +import type { + FlowQuotaDataItem, + QuotaDataItem, + UptimeGroupResult, +} from './types' // ============================================================================ // Dashboard APIs @@ -61,6 +65,24 @@ export async function getUserQuotaDataByUsers(params: { return res.data } +export async function getFlowQuotaDates( + params: { + start_timestamp: number + end_timestamp: number + default_time?: string + username?: string + }, + isAdmin = false +) { + const endpoint = isAdmin ? '/api/data/flow' : '/api/data/flow/self' + const res = await api.get<{ + success: boolean + data?: FlowQuotaDataItem[] + message?: string + }>(endpoint, { params }) + return res.data +} + // Get uptime monitoring status for all services export async function getUptimeStatus() { const res = await api.get<{ success: boolean; data: UptimeGroupResult[] }>( diff --git a/web/default/src/features/dashboard/components/flow/flow-charts.tsx b/web/default/src/features/dashboard/components/flow/flow-charts.tsx new file mode 100644 index 000000000000..f876cb104f93 --- /dev/null +++ b/web/default/src/features/dashboard/components/flow/flow-charts.tsx @@ -0,0 +1,514 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { VChart } from '@visactor/react-vchart' +import { + Activity, + CircleAlert, + ChevronDown, + GitBranch, + Hash, + KeyRound, + Loader2, + Route, + WalletCards, +} from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { useAuthStore } from '@/stores/auth-store' +import { formatNumber, formatQuota } from '@/lib/format' +import { computeTimeRange } from '@/lib/time' +import { useChartTheme } from '@/lib/use-chart-theme' +import { cn } from '@/lib/utils' +import { VCHART_OPTION } from '@/lib/vchart' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty' +import { + Popover, + PopoverContent, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from '@/components/ui/popover' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { MultiSelect } from '@/components/multi-select' +import { getFlowQuotaDates } from '@/features/dashboard/api' +import { + buildDashboardFlowData, + buildFlowSankeySpec, + buildQueryParams, + getDefaultDays, +} from '@/features/dashboard/lib' +import { + compactFlowSelectionLabel, + flowDisplayState, + requireSuccessfulFlowRows, + selectedTokenValuesForUser, + updateSelectedTokensForUser, + visibleFlowUsers, + type SelectedTokensByUser, +} from '@/features/dashboard/lib/flow-selection' +import type { + DashboardFilters, + FlowMetric, + FlowPathMode, + FlowUserFilterOption, + FlowSummary, +} from '@/features/dashboard/types' + +interface FlowChartsProps { + filters?: DashboardFilters +} + +interface FlowStatsProps { + summary: FlowSummary + loading?: boolean +} + +const FLOW_METRIC_OPTIONS = [ + { value: 'quota', labelKey: 'Quota', icon: WalletCards }, + { value: 'tokens', labelKey: 'Tokens', icon: Hash }, + { value: 'requests', labelKey: 'Requests', icon: Activity }, +] as const + +const FLOW_ENDPOINT_OPTIONS = [ + { value: 'model', labelKey: 'Model' }, + { value: 'channel', labelKey: 'Channel' }, +] as const + +type FlowEndpointMode = (typeof FLOW_ENDPOINT_OPTIONS)[number]['value'] + +function selectedPathMode( + endpointMode: FlowEndpointMode, + includeBothDimensions: boolean +): FlowPathMode { + return includeBothDimensions ? 'model-channel' : endpointMode +} + +function FlowStats(props: FlowStatsProps) { + const { t } = useTranslation() + const items = [ + { + key: 'quota', + title: 'Quota', + value: formatQuota(props.summary.quota), + icon: WalletCards, + }, + { + key: 'tokens', + title: 'Tokens', + value: formatNumber(props.summary.tokens), + icon: Hash, + }, + { + key: 'requests', + title: 'Requests', + value: formatNumber(props.summary.requests), + icon: Activity, + }, + ] + + return ( +
+
+ {items.map((item) => { + const Icon = item.icon + return ( +
+
+ +
+ {t(item.title)} +
+
+ {props.loading ? ( +
+ + +
+ ) : ( + <> +
+ {item.value} +
+ + )} +
+ ) + })} +
+
+ ) +} + +interface FlowUserTokenFiltersProps { + users: FlowUserFilterOption[] + selectedTokensByUser: SelectedTokensByUser + onUserTokensChange: (userID: string, tokenIDs: string[]) => void +} + +function FlowUserTokenFilters(props: FlowUserTokenFiltersProps) { + const { t } = useTranslation() + + if (props.users.length === 0) return null + + return ( +
+ {props.users.map((user) => { + const selectedTokens = selectedTokenValuesForUser( + props.selectedTokensByUser, + user.value + ) + const tokenOptions = user.tokens.map((token) => ({ + label: `${token.label} · ${token.valueLabel}`, + value: token.value, + })) + const tokenStateLabel = compactFlowSelectionLabel(selectedTokens.length) + + return ( + + + } + > + + + + + + +
+ + + props.onUserTokensChange(user.value, values) + } + placeholder={t('All API tokens')} + emptyText={t('No API tokens')} + maxVisibleChips={2} + renderSelectedSummary={(values) => + compactFlowSelectionLabel(values.length) + } + /> +
+
+
+ ) + })} +
+ ) +} + +export function FlowCharts(props: FlowChartsProps) { + const { t } = useTranslation() + const { resolvedTheme, themeReady } = useChartTheme() + const user = useAuthStore((state) => state.auth.user) + const isAdmin = !!(user?.role && user.role >= 10) + const [metric, setMetric] = useState('quota') + const [endpointMode, setEndpointMode] = useState('channel') + const [includeBothDimensions, setIncludeBothDimensions] = useState(false) + const [showTokenLayer, setShowTokenLayer] = useState(true) + const [selectedUsers, setSelectedUsers] = useState([]) + const [selectedTokensByUser, setSelectedTokensByUser] = + useState({}) + + const timeRange = useMemo( + () => + computeTimeRange( + getDefaultDays(props.filters?.time_granularity), + props.filters?.start_timestamp, + props.filters?.end_timestamp + ), + [ + props.filters?.end_timestamp, + props.filters?.start_timestamp, + props.filters?.time_granularity, + ] + ) + const flowQueryParams = useMemo( + () => buildQueryParams(timeRange, props.filters), + [props.filters, timeRange] + ) + + const { + data: flowRows, + error: flowError, + isError, + isLoading, + } = useQuery({ + queryKey: ['dashboard', 'flow', flowQueryParams, isAdmin], + queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin), + select: (res) => + requireSuccessfulFlowRows(res, t('Please try again later.')), + staleTime: 60_000, + }) + + const pathMode = selectedPathMode(endpointMode, includeBothDimensions) + const flowData = useMemo( + () => + buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, { + pathMode, + includeTokenLayer: showTokenLayer, + selectedUsers, + selectedTokensByUser, + }), + [ + flowRows, + isLoading, + metric, + pathMode, + selectedTokensByUser, + selectedUsers, + showTokenLayer, + ] + ) + const userFilterOptions = useMemo( + () => + flowData.filterOptions.users.map((user) => ({ + label: `${user.label} · ${user.valueLabel}`, + value: user.value, + })), + [flowData.filterOptions.users] + ) + const legendUsers = useMemo( + () => visibleFlowUsers(flowData.filterOptions.users, selectedUsers), + [flowData.filterOptions.users, selectedUsers] + ) + const chartTitle = t('Flow') + const flowSpec = useMemo( + () => + buildFlowSankeySpec(flowData.flow, chartTitle, formatQuota, { + quota: t('Quota'), + tokens: t('Tokens'), + inputTokens: t('Input Tokens'), + outputTokens: t('Output Tokens'), + cacheRead: t('Cache Read'), + cacheWrite: t('Cache Write'), + requests: t('Requests'), + share: t('Share'), + }), + [chartTitle, flowData.flow, t] + ) + const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light' + const chartKey = [ + metric, + pathMode, + showTokenLayer ? 'token' : 'direct', + selectedUsers.join(','), + Object.entries(selectedTokensByUser) + .map(([userID, tokenIDs]) => `${userID}:${tokenIDs.join('|')}`) + .join(','), + flowRows?.length ?? 0, + resolvedTheme, + ].join('-') + const displayState = flowDisplayState({ + isLoading, + isError, + linkCount: flowData.flow.links.length, + themeReady, + }) + const flowErrorMessage = + flowError instanceof Error + ? flowError.message + : t('Please try again later.') + + const handleUserTokenSelectionChange = ( + userID: string, + tokenIDs: string[] + ) => { + setSelectedTokensByUser((current) => + updateSelectedTokensForUser(current, userID, tokenIDs) + ) + } + + return ( +
+ + +
+
+ setMetric(value as FlowMetric)} + className='shrink-0' + > + + {FLOW_METRIC_OPTIONS.map((option) => ( + + {t(option.labelKey)} + + ))} + + + + + setEndpointMode(value as FlowEndpointMode) + } + className={cn( + 'shrink-0', + includeBothDimensions && 'pointer-events-none opacity-50' + )} + > + + {FLOW_ENDPOINT_OPTIONS.map((option) => ( + + {t(option.labelKey)} + + ))} + + + +
+
+ + compactFlowSelectionLabel(values.length) + } + /> +
+ {isLoading && ( + + )} +
+ +
+
+
+ +
{chartTitle}
+
+ +
+
+ {displayState === 'loading' ? ( + + ) : displayState === 'error' ? ( +
+ + + {t('Failed to load')} + {flowErrorMessage} + +
+ ) : displayState === 'empty' ? ( + + + + + + {t('No flow data available')} + + {t('No matching token and channel usage was found.')} + + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx b/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx index a1ffb51689c3..1cbf1332cfbc 100644 --- a/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx +++ b/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx @@ -53,6 +53,8 @@ interface ModelsFilterProps { preferences: DashboardChartPreferences onFilterChange: (filters: DashboardFilters) => void onReset: () => void + titleKey?: string + descriptionKey?: string } /** @@ -145,8 +147,11 @@ export function ModelsFilter(props: ModelsFilterProps) { {t('Filter')} } - title={t('Model Analytics Filters')} - description={t('Filter the model analytics view by time range and user.')} + title={t(props.titleKey ?? 'Model Analytics Filters')} + description={t( + props.descriptionKey ?? + 'Filter the model analytics view by time range and user.' + )} contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg' contentHeight='min(48vh, 460px)' footerClassName='grid grid-cols-2 gap-2 sm:flex' diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 3c20ad710687..bf4b71803d33 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -77,6 +77,12 @@ const LazyUserCharts = lazy(() => })) ) +const LazyFlowCharts = lazy(() => + import('./components/flow/flow-charts').then((m) => ({ + default: m.FlowCharts, + })) +) + function LogStatCardsFallback() { return (
@@ -137,6 +143,9 @@ const SECTION_META: Record = { models: { titleKey: 'Model Call Analytics', }, + flow: { + titleKey: 'Flow', + }, users: { titleKey: 'User Analytics', }, @@ -217,6 +226,17 @@ export function Dashboard() { /> ) : null + const flowActions = + activeSection === 'flow' ? ( + + ) : null + const sectionActions = modelActions ?? flowActions return ( @@ -238,9 +258,9 @@ export function Dashboard() { ) : (
)} - {modelActions != null && ( + {sectionActions != null && (
- {modelActions} + {sectionActions}
)}
@@ -298,6 +318,13 @@ export function Dashboard() { )} + {activeSection === 'flow' && ( + + }> + + + + )}
diff --git a/web/default/src/features/dashboard/lib/charts.ts b/web/default/src/features/dashboard/lib/charts.ts index 51b33ef76aa5..afec7869889e 100644 --- a/web/default/src/features/dashboard/lib/charts.ts +++ b/web/default/src/features/dashboard/lib/charts.ts @@ -60,7 +60,10 @@ function getThemeChartColors(themeKey?: string): string[] { }).filter(Boolean) } -function getVChartDefaultColors(domainLength: number, themeKey?: string) { +export function getDashboardChartColors( + domainLength: number, + themeKey?: string +): string[] { const themeColors = getThemeChartColors(themeKey) if (themeColors.length > 0) { return Array.from( @@ -74,7 +77,9 @@ function getVChartDefaultColors(domainLength: number, themeKey?: string) { (item) => !item.maxDomainLength || domainLength <= item.maxDomainLength ) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1] - return scheme.scheme + return scheme.scheme.filter( + (color): color is string => typeof color === 'string' + ) } function renderQuotaCompat(rawQuota: number, digits = 4): string { @@ -290,7 +295,7 @@ export function processChartData( const sortedTimes = Array.from(timeModelMap.keys()).sort() const sortedModels = [...allModels].sort() const modelColorDomain = Array.from(new Set([...sortedModels, otherLabel])) - const modelColorRange = getVChartDefaultColors( + const modelColorRange = getDashboardChartColors( modelColorDomain.length, themeKey ) diff --git a/web/default/src/features/dashboard/lib/flow-selection.test.ts b/web/default/src/features/dashboard/lib/flow-selection.test.ts new file mode 100644 index 000000000000..c4f18a78733b --- /dev/null +++ b/web/default/src/features/dashboard/lib/flow-selection.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import type { FlowUserFilterOption } from '../types' +import { + compactFlowSelectionLabel, + flowDisplayState, + requireSuccessfulFlowRows, + selectedTokenValuesForUser, + updateSelectedTokensForUser, + visibleFlowUsers, +} from './flow-selection' + +const users: FlowUserFilterOption[] = [ + { + value: 'user:1', + label: 'dry', + valueLabel: '100', + valueRaw: 100, + color: '#1664ff', + tokens: [ + { + value: 'token:11', + label: 'dry-primary', + valueLabel: '80', + valueRaw: 80, + }, + { + value: 'token:12', + label: 'dry-backup', + valueLabel: '20', + valueRaw: 20, + }, + ], + }, + { + value: 'user:2', + label: 'jrc', + valueLabel: '70', + valueRaw: 70, + color: '#1ac6ff', + tokens: [ + { + value: 'token:22', + label: 'jrc-key', + valueLabel: '70', + valueRaw: 70, + }, + ], + }, +] + +describe('dashboard flow selection helpers', () => { + test('limits user chips to currently visible users', () => { + assert.deepEqual( + visibleFlowUsers(users, []).map((user) => user.value), + ['user:1', 'user:2'] + ) + assert.deepEqual( + visibleFlowUsers(users, ['user:2']).map((user) => user.value), + ['user:2'] + ) + }) + + test('updates token selections independently per user', () => { + const selected = updateSelectedTokensForUser({}, 'user:1', ['token:12']) + assert.deepEqual(selected, { 'user:1': ['token:12'] }) + assert.deepEqual(selectedTokenValuesForUser(selected, 'user:1'), [ + 'token:12', + ]) + + const next = updateSelectedTokensForUser(selected, 'user:2', ['token:22']) + assert.deepEqual(next, { + 'user:1': ['token:12'], + 'user:2': ['token:22'], + }) + }) + + test('keeps hidden user token selections available for later visibility', () => { + const selected = { + 'user:1': ['token:12'], + 'user:2': ['token:22'], + } + + assert.deepEqual( + visibleFlowUsers(users, ['user:1']).map((user) => user.value), + ['user:1'] + ) + assert.deepEqual(selectedTokenValuesForUser(selected, 'user:2'), [ + 'token:22', + ]) + }) + + test('removes a user token override when no tokens are selected', () => { + const selected = { + 'user:1': ['token:12'], + 'user:2': ['token:22'], + } + const next = updateSelectedTokensForUser(selected, 'user:1', []) + + assert.deepEqual(next, { 'user:2': ['token:22'] }) + assert.deepEqual(selectedTokenValuesForUser(next, 'user:1'), []) + }) + + test('formats compact selected counts for flow multiselect summaries', () => { + assert.equal(compactFlowSelectionLabel(0), '*') + assert.equal(compactFlowSelectionLabel(1), '1') + assert.equal(compactFlowSelectionLabel(23), '23') + }) + + test('prioritizes loading and error states before empty flow data', () => { + assert.equal( + flowDisplayState({ + isLoading: true, + isError: true, + linkCount: 0, + themeReady: true, + }), + 'loading' + ) + assert.equal( + flowDisplayState({ + isLoading: false, + isError: true, + linkCount: 0, + themeReady: true, + }), + 'error' + ) + assert.equal( + flowDisplayState({ + isLoading: false, + isError: false, + linkCount: 0, + themeReady: true, + }), + 'empty' + ) + assert.equal( + flowDisplayState({ + isLoading: false, + isError: false, + linkCount: 1, + themeReady: false, + }), + 'loading' + ) + }) + + test('throws unsuccessful flow responses instead of treating them as empty data', () => { + assert.throws( + () => + requireSuccessfulFlowRows( + { success: false, data: [], message: 'database unavailable' }, + 'Failed to load' + ), + /database unavailable/ + ) + assert.deepEqual( + requireSuccessfulFlowRows( + { success: true, data: [{ user_id: 1, quota: 10 }] }, + 'Failed to load' + ), + [{ user_id: 1, quota: 10 }] + ) + }) +}) diff --git a/web/default/src/features/dashboard/lib/flow-selection.ts b/web/default/src/features/dashboard/lib/flow-selection.ts new file mode 100644 index 000000000000..c020cb97f084 --- /dev/null +++ b/web/default/src/features/dashboard/lib/flow-selection.ts @@ -0,0 +1,89 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { + FlowQuotaDataItem, + FlowUserFilterOption, +} from '@/features/dashboard/types' + +export type SelectedTokensByUser = Record +export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart' + +export interface FlowResponse { + success: boolean + data?: FlowQuotaDataItem[] + message?: string +} + +export function requireSuccessfulFlowRows( + response: FlowResponse, + fallbackMessage: string +): FlowQuotaDataItem[] { + if (!response.success) { + throw new Error(response.message || fallbackMessage) + } + return response.data ?? [] +} + +export function flowDisplayState(options: { + isLoading: boolean + isError: boolean + linkCount: number + themeReady: boolean +}): FlowDisplayState { + if (options.isLoading) return 'loading' + if (options.isError) return 'error' + if (options.linkCount === 0) return 'empty' + if (!options.themeReady) return 'loading' + return 'chart' +} + +export function compactFlowSelectionLabel(count: number): string { + return count > 0 ? String(count) : '*' +} + +export function visibleFlowUsers( + users: FlowUserFilterOption[], + selectedUsers: string[] +): FlowUserFilterOption[] { + if (selectedUsers.length === 0) return users + const selected = new Set(selectedUsers) + return users.filter((user) => selected.has(user.value)) +} + +export function selectedTokenValuesForUser( + selectedTokensByUser: SelectedTokensByUser, + userID: string +): string[] { + return selectedTokensByUser[userID] ?? [] +} + +export function updateSelectedTokensForUser( + selectedTokensByUser: SelectedTokensByUser, + userID: string, + tokenIDs: string[] +): SelectedTokensByUser { + const next = { ...selectedTokensByUser } + const uniqueTokenIDs = Array.from(new Set(tokenIDs)) + if (uniqueTokenIDs.length === 0) { + delete next[userID] + } else { + next[userID] = uniqueTokenIDs + } + return next +} diff --git a/web/default/src/features/dashboard/lib/flow.test.ts b/web/default/src/features/dashboard/lib/flow.test.ts new file mode 100644 index 000000000000..43f05e726436 --- /dev/null +++ b/web/default/src/features/dashboard/lib/flow.test.ts @@ -0,0 +1,658 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import type { FlowQuotaDataItem } from '../types' +import { getDashboardChartColors } from './charts' +import { + buildDashboardFlowData, + buildFlowFilterOptions, + buildFlowSankeySpec, +} from './flow' + +const rows: FlowQuotaDataItem[] = [ + { + user_id: 1, + username: 'alice', + token_id: 11, + token_name: 'primary', + channel_id: 101, + channel_name: 'east', + model_name: 'gpt-4.1', + quota: 100, + token_used: 40, + prompt_tokens: 25, + completion_tokens: 15, + count: 2, + }, + { + user_id: 1, + username: 'alice', + token_id: 11, + token_name: 'primary', + channel_id: 102, + channel_name: 'west', + model_name: 'gpt-4.1', + quota: 50, + token_used: 20, + prompt_tokens: 12, + completion_tokens: 8, + count: 1, + }, + { + user_id: 1, + username: 'alice', + token_id: 12, + token_name: 'secondary', + channel_id: 103, + channel_name: 'north', + model_name: 'claude-4-sonnet', + quota: 30, + token_used: 10, + prompt_tokens: 6, + completion_tokens: 4, + count: 1, + }, + { + user_id: 2, + username: 'bob', + token_id: 22, + token_name: 'backup', + channel_id: 101, + channel_name: 'east', + model_name: 'claude-4-sonnet', + quota: 70, + token_used: 30, + prompt_tokens: 18, + completion_tokens: 12, + count: 3, + }, +] + +const cacheRows: FlowQuotaDataItem[] = [ + { + user_id: 1, + username: 'dry', + token_id: 11, + token_name: 'dry-key', + channel_id: 101, + channel_name: 'east', + model_name: 'gpt-4.1', + quota: 100, + token_used: 90, + input_tokens: 70, + prompt_tokens: 100, + completion_tokens: 20, + cache_tokens: 25, + cache_write_tokens: 5, + count: 2, + }, + { + user_id: 2, + username: 'jrc', + token_id: 22, + token_name: 'jrc-key', + channel_id: 102, + channel_name: 'west', + model_name: 'claude-4-sonnet', + quota: 80, + token_used: 50, + input_tokens: 40, + prompt_tokens: 40, + completion_tokens: 10, + cache_tokens: 18, + cache_write_tokens: 2, + count: 1, + }, +] + +describe('dashboard flow data', () => { + test('builds user to token to channel links from quota rows', () => { + const result = buildDashboardFlowData(rows, 'quota') + + assert.equal(result.summary.quota, 250) + assert.equal(result.summary.tokens, 100) + assert.equal(result.summary.requests, 7) + assert.deepEqual( + result.flow.links.map((link) => [ + link.source, + link.target, + link.value, + link.requests, + ]), + [ + ['user:1', 'token:11', 150, 3], + ['user:1', 'token:12', 30, 1], + ['user:2', 'token:22', 70, 3], + ['token:11', 'channel:101', 100, 2], + ['token:11', 'channel:102', 50, 1], + ['token:12', 'channel:103', 30, 1], + ['token:22', 'channel:101', 70, 3], + ] + ) + }) + + test('builds user to token to model links when model mode is selected', () => { + const result = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model', + }) + + assert.deepEqual( + result.flow.nodes.map((node) => [node.id, node.kind, node.label]), + [ + ['user:1', 'user', 'alice'], + ['user:2', 'user', 'bob'], + ['token:11', 'token', 'primary'], + ['token:22', 'token', 'backup'], + ['token:12', 'token', 'secondary'], + ['model:gpt-4.1', 'model', 'gpt-4.1'], + ['model:claude-4-sonnet', 'model', 'claude-4-sonnet'], + ] + ) + assert.deepEqual( + result.flow.links.map((link) => [ + link.source, + link.target, + link.value, + link.requests, + ]), + [ + ['user:1', 'token:11', 150, 3], + ['user:1', 'token:12', 30, 1], + ['user:2', 'token:22', 70, 3], + ['token:11', 'model:gpt-4.1', 150, 3], + ['token:12', 'model:claude-4-sonnet', 30, 1], + ['token:22', 'model:claude-4-sonnet', 70, 3], + ] + ) + }) + + test('builds user to token to model to channel links when both dimensions are enabled', () => { + const result = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model-channel', + }) + + assert.deepEqual( + result.flow.links.map((link) => [ + link.source, + link.target, + link.value, + link.requests, + ]), + [ + ['user:1', 'token:11', 150, 3], + ['user:1', 'token:12', 30, 1], + ['user:2', 'token:22', 70, 3], + ['token:11', 'model:gpt-4.1', 150, 3], + ['token:12', 'model:claude-4-sonnet', 30, 1], + ['token:22', 'model:claude-4-sonnet', 70, 3], + ['model:claude-4-sonnet', 'channel:101', 70, 3], + ['model:claude-4-sonnet', 'channel:103', 30, 1], + ['model:gpt-4.1', 'channel:101', 100, 2], + ['model:gpt-4.1', 'channel:102', 50, 1], + ] + ) + }) + + test('can hide the token layer while preserving user-model-channel order', () => { + const result = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model-channel', + includeTokenLayer: false, + }) + + assert.deepEqual( + result.flow.nodes.map((node) => node.kind), + ['user', 'user', 'model', 'model', 'channel', 'channel', 'channel'] + ) + assert.equal( + result.flow.nodes.some((node) => node.kind === 'token'), + false + ) + assert.deepEqual( + result.flow.links.map((link) => [ + link.source, + link.target, + link.value, + link.requests, + ]), + [ + ['user:1', 'model:claude-4-sonnet', 30, 1], + ['user:1', 'model:gpt-4.1', 150, 3], + ['user:2', 'model:claude-4-sonnet', 70, 3], + ['model:claude-4-sonnet', 'channel:101', 70, 3], + ['model:claude-4-sonnet', 'channel:103', 30, 1], + ['model:gpt-4.1', 'channel:101', 100, 2], + ['model:gpt-4.1', 'channel:102', 50, 1], + ] + ) + }) + + test('keeps downstream node colors stable when the token layer is hidden', () => { + const palette = [ + '#101010', + '#202020', + '#303030', + '#404040', + '#505050', + '#606060', + '#707070', + '#808080', + '#909090', + '#a0a0a0', + ] + const withTokenLayer = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model-channel', + includeTokenLayer: true, + colorPalette: palette, + }) + const withoutTokenLayer = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model-channel', + includeTokenLayer: false, + colorPalette: palette, + }) + const colorByID = (result: typeof withTokenLayer, id: string) => { + const node = result.flow.nodes.find((item) => item.id === id) + assert.ok(node) + return node.color + } + + for (const nodeID of [ + 'user:1', + 'user:2', + 'model:claude-4-sonnet', + 'model:gpt-4.1', + 'channel:101', + 'channel:102', + 'channel:103', + ]) { + assert.equal( + colorByID(withTokenLayer, nodeID), + colorByID(withoutTokenLayer, nodeID) + ) + } + }) + + test('can hide the token layer for single downstream dimensions', () => { + const channelResult = buildDashboardFlowData(rows, 'quota', { + pathMode: 'channel', + includeTokenLayer: false, + }) + const modelResult = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model', + includeTokenLayer: false, + }) + + assert.deepEqual( + channelResult.flow.links.map((link) => [ + link.source, + link.target, + link.value, + ]), + [ + ['user:1', 'channel:101', 100], + ['user:1', 'channel:102', 50], + ['user:1', 'channel:103', 30], + ['user:2', 'channel:101', 70], + ] + ) + assert.deepEqual( + modelResult.flow.links.map((link) => [ + link.source, + link.target, + link.value, + ]), + [ + ['user:1', 'model:claude-4-sonnet', 30], + ['user:1', 'model:gpt-4.1', 150], + ['user:2', 'model:claude-4-sonnet', 70], + ] + ) + }) + + test('filters by selected users and per-user selected tokens', () => { + const result = buildDashboardFlowData(rows, 'quota', { + pathMode: 'model-channel', + selectedUsers: ['user:1'], + selectedTokensByUser: { + 'user:1': ['token:12'], + }, + }) + + assert.equal(result.summary.quota, 30) + assert.deepEqual( + result.flow.links.map((link) => [link.source, link.target, link.value]), + [ + ['user:1', 'token:12', 30], + ['token:12', 'model:claude-4-sonnet', 30], + ['model:claude-4-sonnet', 'channel:103', 30], + ] + ) + }) + + test('builds user and token filter options with stable colors', () => { + const options = buildFlowFilterOptions(rows, 'quota') + + assert.deepEqual( + options.users.map((user) => [ + user.value, + user.label, + user.valueLabel, + user.tokens.map((token) => [token.value, token.label]), + ]), + [ + [ + 'user:1', + 'alice', + '180', + [ + ['token:11', 'primary'], + ['token:12', 'secondary'], + ], + ], + ['user:2', 'bob', '70', [['token:22', 'backup']]], + ] + ) + assert.notEqual(options.users[0].color, options.users[1].color) + }) + + test('uses non-cache input plus output tokens for token metric', () => { + const result = buildDashboardFlowData(cacheRows, 'tokens', { + pathMode: 'model-channel', + }) + + assert.equal(result.summary.tokens, 140) + assert.equal(result.summary.inputTokens, 110) + assert.equal(result.summary.completionTokens, 30) + assert.equal(result.summary.cacheTokens, 43) + assert.equal(result.summary.cacheWriteTokens, 7) + assert.deepEqual( + result.flow.links.map((link) => [ + link.source, + link.target, + link.value, + link.inputTokens, + link.completionTokens, + link.cacheTokens, + link.cacheWriteTokens, + ]), + [ + ['user:1', 'token:11', 90, 70, 20, 25, 5], + ['user:2', 'token:22', 50, 40, 10, 18, 2], + ['token:11', 'model:gpt-4.1', 90, 70, 20, 25, 5], + ['token:22', 'model:claude-4-sonnet', 50, 40, 10, 18, 2], + ['model:claude-4-sonnet', 'channel:102', 50, 40, 10, 18, 2], + ['model:gpt-4.1', 'channel:101', 90, 70, 20, 25, 5], + ] + ) + }) + + test('assigns globally distinct node colors and source-colored links while the palette is large enough', () => { + const palette = [ + '#101010', + '#202020', + '#303030', + '#404040', + '#505050', + '#606060', + '#707070', + '#808080', + ] + const result = buildDashboardFlowData(cacheRows, 'quota', { + pathMode: 'model-channel', + colorPalette: palette, + }) + const nodeColors = result.flow.nodes.map((node) => node.color) + const userDry = result.flow.nodes.find((node) => node.id === 'user:1') + const tokenDry = result.flow.nodes.find((node) => node.id === 'token:11') + const modelGpt = result.flow.nodes.find( + (node) => node.id === 'model:gpt-4.1' + ) + const linkUserToken = result.flow.links.find( + (link) => link.source === 'user:1' && link.target === 'token:11' + ) + const linkTokenModel = result.flow.links.find( + (link) => link.source === 'token:11' && link.target === 'model:gpt-4.1' + ) + const linkModelChannel = result.flow.links.find( + (link) => link.source === 'model:gpt-4.1' && link.target === 'channel:101' + ) + + assert.ok(userDry) + assert.ok(tokenDry) + assert.ok(modelGpt) + assert.equal(new Set(nodeColors).size, nodeColors.length) + assert.equal(userDry.color, palette[0]) + assert.notEqual(userDry.color, tokenDry.color) + assert.notEqual(tokenDry.color, modelGpt.color) + assert.equal(linkUserToken?.color, userDry.color) + assert.equal(linkUserToken?.hoverColor, linkUserToken?.color) + assert.equal(linkTokenModel?.color, tokenDry.color) + assert.equal(linkModelChannel?.color, modelGpt.color) + }) + + test('builds an interactive Sankey spec with in-chart labels and nested datum metrics', () => { + const result = buildDashboardFlowData(cacheRows, 'quota', { + pathMode: 'model-channel', + }) + const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') + const values = flowSpec.data[0].values[0] + const dryNode = values.nodes.find( + (node: Record) => node.key === 'user:1' + ) + const dryLink = values.links.find( + (link: Record) => + link.source === 'user:1' && link.target === 'token:11' + ) + + assert.equal(flowSpec.type, 'sankey') + assert.equal(flowSpec.title.text, 'Flow') + assert.deepEqual(flowSpec.legends, { visible: false }) + assert.equal(flowSpec.label.visible, true) + assert.equal(flowSpec.label.position, 'outside') + assert.equal(flowSpec.node.interactive, true) + assert.equal(flowSpec.link.interactive, true) + assert.equal(flowSpec.link.state.hover.fillOpacity, 0.9) + assert.equal(flowSpec.emphasis.enable, false) + assert.equal(flowSpec.emphasis.trigger, 'hover') + assert.equal(flowSpec.emphasis.effect, 'self') + assert.equal(flowSpec.tooltip.trigger, 'hover') + assert.equal(flowSpec.tooltip.activeType, 'mark') + assert.equal(flowSpec.tooltip.dimension.visible, false) + assert.equal(flowSpec.tooltip.group.visible, false) + assert.equal(flowSpec.tooltip.mark.checkOverlap, true) + assert.equal(flowSpec.tooltip.mark.visible({ datum: dryNode }), true) + assert.equal(flowSpec.tooltip.mark.visible({ datum: dryLink }), true) + assert.equal(flowSpec.animation, false) + assert.equal(flowSpec.data[0].id, 'flow') + assert.equal(values.nodes.length, 8) + assert.equal(values.links.length, 6) + assert.ok(dryNode) + assert.ok(dryLink) + assert.equal(dryNode.name, 'dry') + assert.equal(dryNode.rawLabel, 'dry') + assert.equal( + values.nodes.some((node: Record) => + /^(User|Key|Model|Channel): /.test(String(node.name)) + ), + false + ) + assert.equal( + flowSpec.node.style.fill({ + datum: [dryNode], + depth: 0, + }), + dryNode.color + ) + assert.equal( + flowSpec.link.style.fill({ + datum: dryLink, + }), + dryLink.linkColor + ) + assert.equal(dryLink.color, dryNode.color) + assert.match(dryLink.linkColor, /^rgba\(/) + assert.notEqual(dryLink.linkColor, dryLink.color) + assert.equal( + flowSpec.link.state.hover.fill({ + datum: dryLink, + }), + dryLink.color + ) + assert.equal(flowSpec.label.style.fill, '#475569') + assert.equal(flowSpec.link.style.pickMode, 'accurate') + assert.equal(flowSpec.link.style.boundsMode, 'accurate') + assert.equal('pickStrokeBuffer' in flowSpec.link.style, false) + assert.equal(flowSpec.linkSortBy({ value: 1 }, { value: 5 }), 4) + assert.equal( + flowSpec.link.style.zIndex({ datum: { value: 30 } }) > + flowSpec.link.style.zIndex({ datum: { value: 150 } }), + true + ) + + const tooltipRows = flowSpec.tooltip.mark.content + assert.deepEqual( + tooltipRows + .filter((row: Record) => + typeof row.visible === 'function' + ? row.visible({ datum: dryNode }) + : true + ) + .map((row: Record) => [ + row.key, + typeof row.value === 'function' + ? row.value({ datum: dryNode }) + : row.value, + ]), + [ + ['Quota', '100'], + ['Tokens', '90'], + ['Input Tokens', '70'], + ['Output Tokens', '20'], + ['Cache Read', '25'], + ['Cache Write', '5'], + ['Requests', '2'], + ] + ) + assert.deepEqual( + tooltipRows + .filter((row: Record) => + typeof row.visible === 'function' + ? row.visible({ datum: dryLink }) + : true + ) + .map((row: Record) => [ + row.key, + typeof row.value === 'function' + ? row.value({ datum: dryLink }) + : row.value, + ]), + [ + ['Quota', '100'], + ['Tokens', '90'], + ['Input Tokens', '70'], + ['Output Tokens', '20'], + ['Cache Read', '25'], + ['Cache Write', '5'], + ['Requests', '2'], + ['Share', '55.6%'], + ] + ) + }) + + test('uses the shared dashboard VChart color palette', () => { + const result = buildDashboardFlowData(cacheRows, 'quota', { + pathMode: 'model-channel', + }) + const expectedPalette = getDashboardChartColors(result.flow.nodes.length) + const colors = Array.from( + new Set(result.flow.nodes.map((node) => node.color)) + ) + + assert.ok(colors.length > 1) + assert.deepEqual( + [...colors].sort(), + expectedPalette.slice(0, colors.length).sort() + ) + assert.equal(flowLabelFill(), '#475569') + }) + + test('orders Sankey links so thinner links are drawn after thicker links', () => { + const result = buildDashboardFlowData(rows, 'quota') + const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') + const values = flowSpec.data[0].values[0] + const linkValues = values.links.map( + (link: Record) => link.value + ) + + assert.deepEqual(linkValues, [150, 100, 70, 70, 50, 30, 30]) + assert.equal(values.links.at(-1).value, 30) + }) + + test('makes thinner branches from the same source darker than thicker branches', () => { + const branchRows: FlowQuotaDataItem[] = [ + { + user_id: 1, + username: 'source', + token_id: 11, + token_name: 'z-heavy', + channel_id: 101, + channel_name: 'east', + model_name: 'gpt-4.1', + quota: 100, + prompt_tokens: 60, + completion_tokens: 40, + count: 10, + }, + { + user_id: 1, + username: 'source', + token_id: 12, + token_name: 'a-light', + channel_id: 102, + channel_name: 'west', + model_name: 'gpt-4.1', + quota: 10, + prompt_tokens: 6, + completion_tokens: 4, + count: 1, + }, + ] + const result = buildDashboardFlowData(branchRows, 'quota') + const heavy = result.flow.links.find( + (link) => link.source === 'user:1' && link.target === 'token:11' + ) + const light = result.flow.links.find( + (link) => link.source === 'user:1' && link.target === 'token:12' + ) + + assert.ok(heavy) + assert.ok(light) + assert.equal(heavy.color, light.color) + assert.ok(alphaFromRgba(heavy.linkColor) < alphaFromRgba(light.linkColor)) + }) + + test('assigns deterministic z-indexes for equal-value links', () => { + const result = buildDashboardFlowData(rows, 'quota') + const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') + const values = flowSpec.data[0].values[0] + const equalValueLinks = values.links.filter( + (link: Record) => link.value === 30 + ) + const zIndexes = equalValueLinks.map((link: Record) => + flowSpec.link.style.zIndex({ datum: link }) + ) + + assert.equal(equalValueLinks.length, 2) + assert.equal(new Set(zIndexes).size, equalValueLinks.length) + }) +}) + +function alphaFromRgba(color: string): number { + const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec( + color + ) + assert.ok(match) + return Number(match[1]) +} + +function flowLabelFill(): string { + return buildFlowSankeySpec({ nodes: [], links: [] }, 'Flow').label.style.fill +} diff --git a/web/default/src/features/dashboard/lib/flow.ts b/web/default/src/features/dashboard/lib/flow.ts new file mode 100644 index 000000000000..3a8a82690853 --- /dev/null +++ b/web/default/src/features/dashboard/lib/flow.ts @@ -0,0 +1,995 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { + DashboardFlowGraph, + DashboardFlowLink, + DashboardFlowNode, + FlowBuildOptions, + FlowFilterOptions, + FlowMetric, + FlowNodeKind, + FlowPathMode, + FlowQuotaDataItem, + FlowSummary, + ProcessedFlowData, +} from '@/features/dashboard/types' +import { getDashboardChartColors } from './charts' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type VChartSpec = Record + +type FlowMetrics = { + quota: number + tokens: number + inputTokens: number + promptTokens: number + completionTokens: number + cacheTokens: number + cacheWriteTokens: number + requests: number +} + +type FlowSankeyLabels = { + quota: string + tokens: string + inputTokens: string + outputTokens: string + cacheRead: string + cacheWrite: string + requests: string + share: string +} + +const DEFAULT_FLOW_PATH_MODE: FlowPathMode = 'channel' + +const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = { + quota: 'Quota', + tokens: 'Tokens', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheRead: 'Cache Read', + cacheWrite: 'Cache Write', + requests: 'Requests', + share: 'Share', +} + +const DEFAULT_FLOW_CHART_COLOR = '#1664FF' + +function numberValue(value: unknown): number { + const n = Number(value) + return Number.isFinite(n) ? n : 0 +} + +function requestCount(row: FlowQuotaDataItem): number { + return numberValue(row.count) +} + +function tokenCount(row: FlowQuotaDataItem): number { + const computed = inputTokenCount(row) + numberValue(row.completion_tokens) + if (computed > 0) return computed + return numberValue(row.token_used) +} + +function cacheReadTokenCount(row: FlowQuotaDataItem): number { + return numberValue(row.cache_tokens) +} + +function cacheWriteTokenCount(row: FlowQuotaDataItem): number { + return numberValue(row.cache_write_tokens) +} + +function inputTokenCount(row: FlowQuotaDataItem): number { + const explicitInputTokens = numberValue(row.input_tokens) + if (explicitInputTokens > 0) return explicitInputTokens + + const promptTokens = numberValue(row.prompt_tokens) + const cacheTokens = cacheReadTokenCount(row) + const cacheWriteTokens = cacheWriteTokenCount(row) + if (cacheTokens > 0 || cacheWriteTokens > 0) { + return Math.max(promptTokens - cacheTokens - cacheWriteTokens, 0) + } + return promptTokens +} + +function rowMetrics(row: FlowQuotaDataItem): FlowMetrics { + const promptTokens = numberValue(row.prompt_tokens) + const completionTokens = numberValue(row.completion_tokens) + const inputTokens = inputTokenCount(row) + const cacheTokens = cacheReadTokenCount(row) + const cacheWriteTokens = cacheWriteTokenCount(row) + return { + quota: numberValue(row.quota), + tokens: tokenCount(row), + inputTokens, + promptTokens, + completionTokens, + cacheTokens, + cacheWriteTokens, + requests: requestCount(row), + } +} + +function metricValue(metrics: FlowMetrics, metric: FlowMetric): number { + if (metric === 'requests') return metrics.requests + if (metric === 'tokens') return metrics.tokens + return metrics.quota +} + +function userNodeId(row: FlowQuotaDataItem): string { + return `user:${numberValue(row.user_id)}` +} + +function tokenNodeId(row: FlowQuotaDataItem): string { + const tokenID = numberValue(row.token_id) + if (tokenID > 0) return `token:${tokenID}` + return `token:${row.token_name || 'unknown'}` +} + +function modelNodeId(row: FlowQuotaDataItem): string { + return `model:${row.model_name || 'unknown'}` +} + +function channelNodeId(row: FlowQuotaDataItem): string { + const channelID = numberValue(row.channel_id) + if (channelID > 0) return `channel:${channelID}` + return `channel:${row.channel_name || 'unknown'}` +} + +function userLabel(row: FlowQuotaDataItem): string { + const userID = numberValue(row.user_id) + return row.username || (userID > 0 ? `user-${userID}` : 'Unknown User') +} + +function tokenLabel(row: FlowQuotaDataItem): string { + const tokenID = numberValue(row.token_id) + return row.token_name || (tokenID > 0 ? `token-${tokenID}` : 'Unknown Token') +} + +function modelLabel(row: FlowQuotaDataItem): string { + return row.model_name || 'Unknown Model' +} + +function channelLabel(row: FlowQuotaDataItem): string { + const channelID = numberValue(row.channel_id) + return ( + row.channel_name || (channelID > 0 ? `channel-${channelID}` : 'Unknown') + ) +} + +function formatNumber(value: number): string { + return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( + value + ) +} + +function colorAt(index: number, palette?: readonly string[]): string { + const colors = + palette && palette.length > 0 ? palette : getDashboardChartColors(index + 1) + if (colors.length === 0) return DEFAULT_FLOW_CHART_COLOR + return colors[index % colors.length] ?? DEFAULT_FLOW_CHART_COLOR +} + +function colorPalette( + colorCount: number, + palette?: readonly string[] +): readonly string[] { + if (palette && palette.length > 0) return palette + const colors = getDashboardChartColors(colorCount) + return colors.length > 0 ? colors : [DEFAULT_FLOW_CHART_COLOR] +} + +function alphaColor( + color: string, + alpha: number +): { color: string; alpha: number } { + const normalized = color.trim() + const hex = normalized.startsWith('#') ? normalized.slice(1) : normalized + if (!/^[0-9a-f]{6}$/i.test(hex)) { + return { color: normalized, alpha } + } + + const value = Number.parseInt(hex, 16) + const red = (value >> 16) & 255 + const green = (value >> 8) & 255 + const blue = value & 255 + return { + color: `rgba(${red}, ${green}, ${blue}, ${alpha.toFixed(2)})`, + alpha: 1, + } +} + +function stableColorMap( + keys: string[], + palette?: readonly string[] +): Map { + const map = new Map() + const uniqueKeys = Array.from(new Set(keys)) + const colors = colorPalette(uniqueKeys.length, palette) + uniqueKeys.forEach((key, index) => { + map.set(key, colorAt(index, colors)) + }) + return map +} + +function userColorMap( + rows: FlowQuotaDataItem[], + palette?: readonly string[] +): Map { + return stableColorMap(sortedUniqueNodeIds(rows, userNodeId), palette) +} + +function sortedUniqueNodeIds( + rows: FlowQuotaDataItem[], + idForRow: (row: FlowQuotaDataItem) => string +): string[] { + return Array.from(new Set(rows.map(idForRow))).sort((a, b) => + a.localeCompare(b) + ) +} + +function flowColorMap( + rows: FlowQuotaDataItem[], + palette?: readonly string[] +): Map { + const keys = [ + ...sortedUniqueNodeIds(rows, userNodeId), + ...sortedUniqueNodeIds(rows, tokenNodeId), + ...sortedUniqueNodeIds(rows, modelNodeId), + ...sortedUniqueNodeIds(rows, channelNodeId), + ] + return stableColorMap(keys, palette) +} + +function filterRows( + rows: FlowQuotaDataItem[], + options: FlowBuildOptions = {} +): FlowQuotaDataItem[] { + const selectedUsers = new Set(options.selectedUsers ?? []) + const selectedTokensByUser = options.selectedTokensByUser ?? {} + + return rows.filter((row) => { + const userID = userNodeId(row) + const tokenID = tokenNodeId(row) + if (selectedUsers.size > 0 && !selectedUsers.has(userID)) return false + + const selectedTokens = selectedTokensByUser[userID] ?? [] + if (selectedTokens.length > 0 && !selectedTokens.includes(tokenID)) { + return false + } + return true + }) +} + +function addNode( + map: Map, + id: string, + label: string, + kind: FlowNodeKind, + metrics: FlowMetrics, + metric: FlowMetric, + color: string, + colorKey: string +): void { + const previous = map.get(id) ?? { + id, + label, + kind, + value: 0, + requests: 0, + quota: 0, + tokens: 0, + inputTokens: 0, + promptTokens: 0, + completionTokens: 0, + cacheTokens: 0, + cacheWriteTokens: 0, + color, + colorKey, + } + previous.value += metricValue(metrics, metric) + previous.requests += metrics.requests + previous.quota += metrics.quota + previous.tokens += metrics.tokens + previous.inputTokens += metrics.inputTokens + previous.promptTokens += metrics.promptTokens + previous.completionTokens += metrics.completionTokens + previous.cacheTokens += metrics.cacheTokens + previous.cacheWriteTokens += metrics.cacheWriteTokens + map.set(id, previous) +} + +function addLink( + map: Map, + source: string, + target: string, + sourceLabel: string, + targetLabel: string, + metrics: FlowMetrics, + metric: FlowMetric, + color: string, + colorKey: string +): void { + const key = `${source}\u0000${target}` + const previous = map.get(key) ?? { + source, + target, + value: 0, + requests: 0, + quota: 0, + tokens: 0, + inputTokens: 0, + promptTokens: 0, + completionTokens: 0, + cacheTokens: 0, + cacheWriteTokens: 0, + sourceLabel, + targetLabel, + color, + linkColor: color, + linkAlpha: 1, + hoverColor: color, + colorKey, + share: 0, + } + previous.value += metricValue(metrics, metric) + previous.requests += metrics.requests + previous.quota += metrics.quota + previous.tokens += metrics.tokens + previous.inputTokens += metrics.inputTokens + previous.promptTokens += metrics.promptTokens + previous.completionTokens += metrics.completionTokens + previous.cacheTokens += metrics.cacheTokens + previous.cacheWriteTokens += metrics.cacheWriteTokens + map.set(key, previous) +} + +function assignLinkDisplayColors(links: DashboardFlowLink[]): void { + const linksBySource = new Map() + for (const link of links) { + const sourceLinks = linksBySource.get(link.source) ?? [] + sourceLinks.push(link) + linksBySource.set(link.source, sourceLinks) + } + + for (const sourceLinks of linksBySource.values()) { + const sortedLinks = [...sourceLinks].sort( + (a, b) => + b.value - a.value || linkStableKey(a).localeCompare(linkStableKey(b)) + ) + const denominator = Math.max(sortedLinks.length - 1, 1) + sortedLinks.forEach((link, index) => { + const alpha = + sortedLinks.length === 1 ? 0.34 : 0.24 + (index / denominator) * 0.2 + const displayColor = alphaColor(link.color, alpha) + link.linkColor = displayColor.color + link.linkAlpha = displayColor.alpha + link.hoverColor = link.color + }) + } +} + +function byValueThenLabel( + a: T, + b: T +): number { + return b.value - a.value || a.label.localeCompare(b.label) +} + +function linkStableKey(link: Pick) { + return `${link.source}\u0000${link.target}` +} + +function byLinkDrawPriority( + a: DashboardFlowLink, + b: DashboardFlowLink +): number { + return b.value - a.value || linkStableKey(a).localeCompare(linkStableKey(b)) +} + +function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary { + return rows.reduce( + (summary, row) => { + const metrics = rowMetrics(row) + summary.quota += metrics.quota + summary.tokens += metrics.tokens + summary.inputTokens += metrics.inputTokens + summary.completionTokens += metrics.completionTokens + summary.cacheTokens += metrics.cacheTokens + summary.cacheWriteTokens += metrics.cacheWriteTokens + summary.requests += metrics.requests + return summary + }, + { + quota: 0, + tokens: 0, + inputTokens: 0, + completionTokens: 0, + cacheTokens: 0, + cacheWriteTokens: 0, + requests: 0, + } + ) +} + +function buildFlowGraph( + rows: FlowQuotaDataItem[], + metric: FlowMetric, + pathMode: FlowPathMode = DEFAULT_FLOW_PATH_MODE, + includeTokenLayer = true, + palette?: readonly string[] +): DashboardFlowGraph { + const userNodes = new Map() + const tokenNodes = new Map() + const modelNodes = new Map() + const channelNodes = new Map() + const userTokenLinks = new Map() + const userModelLinks = new Map() + const userChannelLinks = new Map() + const tokenModelLinks = new Map() + const tokenChannelLinks = new Map() + const modelChannelLinks = new Map() + const colors = flowColorMap(rows, palette) + + for (const row of rows) { + const metrics = rowMetrics(row) + const userID = userNodeId(row) + const tokenID = tokenNodeId(row) + const modelID = modelNodeId(row) + const channelID = channelNodeId(row) + const userColor = colors.get(userID) ?? colorAt(0, palette) + const tokenColor = colors.get(tokenID) ?? userColor + const modelColor = colors.get(modelID) ?? userColor + const channelColor = colors.get(channelID) ?? modelColor + + addNode( + userNodes, + userID, + userLabel(row), + 'user', + metrics, + metric, + userColor, + userID + ) + + if (includeTokenLayer) { + addNode( + tokenNodes, + tokenID, + tokenLabel(row), + 'token', + metrics, + metric, + tokenColor, + tokenID + ) + addLink( + userTokenLinks, + userID, + tokenID, + userLabel(row), + tokenLabel(row), + metrics, + metric, + userColor, + userID + ) + } + + if (pathMode === 'model' || pathMode === 'model-channel') { + addNode( + modelNodes, + modelID, + modelLabel(row), + 'model', + metrics, + metric, + modelColor, + modelID + ) + const modelSourceID = includeTokenLayer ? tokenID : userID + const modelSourceLabel = includeTokenLayer + ? tokenLabel(row) + : userLabel(row) + const modelSourceColor = includeTokenLayer ? tokenColor : userColor + const modelSourceColorKey = includeTokenLayer ? tokenID : userID + addLink( + includeTokenLayer ? tokenModelLinks : userModelLinks, + modelSourceID, + modelID, + modelSourceLabel, + modelLabel(row), + metrics, + metric, + modelSourceColor, + modelSourceColorKey + ) + } + + if (pathMode === 'channel' || pathMode === 'model-channel') { + addNode( + channelNodes, + channelID, + channelLabel(row), + 'channel', + metrics, + metric, + channelColor, + channelID + ) + } + + if (pathMode === 'channel') { + const channelSourceID = includeTokenLayer ? tokenID : userID + const channelSourceLabel = includeTokenLayer + ? tokenLabel(row) + : userLabel(row) + const channelSourceColor = includeTokenLayer ? tokenColor : userColor + const channelSourceColorKey = includeTokenLayer ? tokenID : userID + addLink( + includeTokenLayer ? tokenChannelLinks : userChannelLinks, + channelSourceID, + channelID, + channelSourceLabel, + channelLabel(row), + metrics, + metric, + channelSourceColor, + channelSourceColorKey + ) + } + + if (pathMode === 'model-channel') { + addLink( + modelChannelLinks, + modelID, + channelID, + modelLabel(row), + channelLabel(row), + metrics, + metric, + modelColor, + modelID + ) + } + } + + const links = [ + ...Array.from(userTokenLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ...Array.from(userModelLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ...Array.from(userChannelLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ...Array.from(tokenModelLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ...Array.from(tokenChannelLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ...Array.from(modelChannelLinks.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ), + ] + const total = links + .filter((link) => link.source.startsWith('user:')) + .reduce((sum, link) => sum + link.value, 0) + for (const link of links) { + link.share = total > 0 ? link.value / total : 0 + } + assignLinkDisplayColors(links) + + return { + nodes: [ + ...Array.from(userNodes.values()).sort((a, b) => + a.label.localeCompare(b.label) + ), + ...Array.from(tokenNodes.values()).sort(byValueThenLabel), + ...Array.from(modelNodes.values()).sort(byValueThenLabel), + ...Array.from(channelNodes.values()).sort(byValueThenLabel), + ], + links, + } +} + +export function buildFlowFilterOptions( + rows: FlowQuotaDataItem[], + metric: FlowMetric = 'quota', + palette?: readonly string[] +): FlowFilterOptions { + const users = new Map< + string, + { + label: string + value: number + color: string + tokens: Map + } + >() + const colors = userColorMap(rows, palette) + + for (const row of rows) { + const userID = userNodeId(row) + const tokenID = tokenNodeId(row) + const metrics = rowMetrics(row) + const value = metricValue(metrics, metric) + const user = users.get(userID) ?? { + label: userLabel(row), + value: 0, + color: colors.get(userID) ?? colorAt(0, palette), + tokens: new Map(), + } + user.value += value + const token = user.tokens.get(tokenID) ?? { + label: tokenLabel(row), + value: 0, + } + token.value += value + user.tokens.set(tokenID, token) + users.set(userID, user) + } + + return { + users: Array.from(users.entries()) + .map(([value, user]) => ({ + value, + label: user.label, + valueLabel: formatNumber(user.value), + valueRaw: user.value, + color: user.color, + tokens: Array.from(user.tokens.entries()) + .map(([tokenValue, token]) => ({ + value: tokenValue, + label: token.label, + valueLabel: formatNumber(token.value), + valueRaw: token.value, + })) + .sort( + (a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label) + ), + })) + .sort( + (a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label) + ), + } +} + +export function buildDashboardFlowData( + rows: FlowQuotaDataItem[], + metric: FlowMetric = 'quota', + options: FlowBuildOptions = {} +): ProcessedFlowData { + const pathMode = options.pathMode ?? DEFAULT_FLOW_PATH_MODE + const includeTokenLayer = options.includeTokenLayer ?? true + const filteredRows = filterRows(rows, options) + const palette = options.colorPalette + + return { + summary: buildSummary(filteredRows), + flow: buildFlowGraph( + filteredRows, + metric, + pathMode, + includeTokenLayer, + palette + ), + filterOptions: buildFlowFilterOptions(rows, metric, palette), + } +} + +function recordValue(value: unknown): Record | undefined { + return value && typeof value === 'object' + ? (value as Record) + : undefined +} + +function sankeyDatumSource( + datum: Record +): Record { + const nested = datum.datum + if (Array.isArray(nested)) { + const depth = numberValue(datum.depth) + return recordValue(nested[depth]) ?? recordValue(nested[0]) ?? datum + } + return recordValue(nested) ?? datum +} + +function sankeyDatumValue( + datum: Record, + key: string +): unknown { + if (datum[key] !== undefined) return datum[key] + return sankeyDatumSource(datum)[key] +} + +function isSankeyLinkDatum(datum: Record): boolean { + return ( + sankeyDatumValue(datum, 'source') !== undefined && + sankeyDatumValue(datum, 'target') !== undefined + ) +} + +function tooltipMetricLines( + valueFormatter: (value: number) => string, + labels: FlowSankeyLabels +) { + const metricValue = (datum: Record, key: string) => + numberValue(sankeyDatumValue(datum, key)) + const formattedNumber = (datum: Record, key: string) => + formatNumber(metricValue(datum, key)) + const hasMetric = (datum: Record, key: string) => + metricValue(datum, key) > 0 + + return [ + { + key: labels.quota, + value: (datum: Record) => + valueFormatter(metricValue(datum, 'quota')), + }, + { + key: labels.tokens, + value: (datum: Record) => + formattedNumber(datum, 'tokens'), + }, + { + key: labels.inputTokens, + value: (datum: Record) => + formattedNumber(datum, 'inputTokens'), + }, + { + key: labels.outputTokens, + value: (datum: Record) => + formattedNumber(datum, 'completionTokens'), + }, + { + key: labels.cacheRead, + value: (datum: Record) => + formattedNumber(datum, 'cacheTokens'), + visible: (datum: Record) => + hasMetric(datum, 'cacheTokens'), + }, + { + key: labels.cacheWrite, + value: (datum: Record) => + formattedNumber(datum, 'cacheWriteTokens'), + visible: (datum: Record) => + hasMetric(datum, 'cacheWriteTokens'), + }, + { + key: labels.requests, + value: (datum: Record) => + formattedNumber(datum, 'requests'), + }, + { + key: labels.share, + value: (datum: Record) => + `${(metricValue(datum, 'share') * 100).toFixed(1)}%`, + visible: (datum: Record) => hasMetric(datum, 'share'), + }, + ] +} + +export function buildFlowSankeySpec( + flow: DashboardFlowGraph, + title: string, + valueFormatter: (value: number) => string = formatNumber, + labels: FlowSankeyLabels = DEFAULT_FLOW_SANKEY_LABELS +): VChartSpec { + return { + type: 'sankey', + data: [ + { + id: 'flow', + values: [ + { + nodes: flow.nodes.map((node) => ({ + key: node.id, + name: node.label, + rawLabel: node.label, + kind: node.kind, + value: node.value, + requests: node.requests, + quota: node.quota, + tokens: node.tokens, + inputTokens: node.inputTokens, + promptTokens: node.promptTokens, + completionTokens: node.completionTokens, + cacheTokens: node.cacheTokens, + cacheWriteTokens: node.cacheWriteTokens, + color: node.color, + colorKey: node.colorKey, + })), + links: flow.links + .filter((link) => link.value > 0) + .sort(byLinkDrawPriority) + .map((link, index) => ({ + source: link.source, + target: link.target, + linkKey: linkStableKey(link), + sourceLabel: link.sourceLabel, + targetLabel: link.targetLabel, + value: link.value, + requests: link.requests, + quota: link.quota, + tokens: link.tokens, + inputTokens: link.inputTokens, + promptTokens: link.promptTokens, + completionTokens: link.completionTokens, + cacheTokens: link.cacheTokens, + cacheWriteTokens: link.cacheWriteTokens, + color: link.color, + linkColor: link.linkColor, + linkAlpha: link.linkAlpha, + hoverColor: link.hoverColor, + colorKey: link.colorKey, + share: link.share, + zIndex: index, + })), + }, + ], + }, + ], + categoryField: 'name', + sourceField: 'source', + targetField: 'target', + valueField: 'value', + nodeKey: 'key', + direction: 'horizontal', + nodeAlign: 'justify', + crossNodeAlign: 'middle', + linkSortBy: ( + a: { value?: number; source?: string; target?: string; index?: number }, + b: { value?: number; source?: string; target?: string; index?: number } + ) => + numberValue(b.value) - numberValue(a.value) || + `${a.source ?? ''}\u0000${a.target ?? ''}`.localeCompare( + `${b.source ?? ''}\u0000${b.target ?? ''}` + ) || + numberValue(a.index) - numberValue(b.index), + nodeGap: 14, + nodeWidth: 16, + minLinkHeight: 2, + minNodeHeight: 8, + title: { + visible: false, + text: title, + }, + legends: { visible: false }, + label: { + visible: true, + position: 'outside', + limit: 220, + interactive: false, + style: { + fill: '#475569', + fontSize: 11, + fontWeight: 600, + }, + }, + node: { + interactive: true, + style: { + fill: (datum: Record) => + String(sankeyDatumValue(datum, 'color') ?? colorAt(0)), + fillOpacity: 0.92, + stroke: 'rgba(148, 163, 184, 0.45)', + lineWidth: 1, + cursor: 'pointer', + pickMode: 'accurate', + }, + state: { + hover: { + fillOpacity: 1, + stroke: 'rgba(15, 23, 42, 0.68)', + lineWidth: 1.5, + }, + selected: { + fillOpacity: 1, + stroke: 'rgba(15, 23, 42, 0.68)', + lineWidth: 1.5, + }, + blur: { + fillOpacity: 0.22, + }, + }, + }, + link: { + interactive: true, + style: { + fill: (datum: Record) => + String( + sankeyDatumValue(datum, 'linkColor') ?? + sankeyDatumValue(datum, 'color') ?? + colorAt(0) + ), + fillOpacity: (datum: Record) => + numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1, + cursor: 'pointer', + pickMode: 'accurate', + boundsMode: 'accurate', + zIndex: (datum: Record) => { + const zIndex = sankeyDatumValue(datum, 'zIndex') + if (zIndex !== undefined) return numberValue(zIndex) + return 1_000_000_000 - numberValue(sankeyDatumValue(datum, 'value')) + }, + }, + state: { + hover: { + fill: (datum: Record) => + String( + sankeyDatumValue(datum, 'hoverColor') ?? + sankeyDatumValue(datum, 'color') ?? + colorAt(0) + ), + fillOpacity: 0.9, + }, + selected: { + fill: (datum: Record) => + String( + sankeyDatumValue(datum, 'hoverColor') ?? + sankeyDatumValue(datum, 'color') ?? + colorAt(0) + ), + fillOpacity: 0.9, + }, + blur: { + fillOpacity: 0.22, + }, + }, + }, + emphasis: { enable: false, trigger: 'hover', effect: 'self' }, + tooltip: { + trigger: 'hover', + activeType: 'mark', + dimension: { visible: false }, + group: { visible: false }, + mark: { + checkOverlap: true, + positionMode: 'pointer', + visible: (datum: Record) => + isSankeyLinkDatum(datum) || + sankeyDatumValue(datum, 'key') !== undefined, + title: { + value: (datum: Record) => { + const source = sankeyDatumValue(datum, 'source') + const target = sankeyDatumValue(datum, 'target') + if (source && target) { + const sourceLabel = sankeyDatumValue(datum, 'sourceLabel') + const targetLabel = sankeyDatumValue(datum, 'targetLabel') + return `${sourceLabel ?? source} -> ${targetLabel ?? target}` + } + return `${sankeyDatumValue(datum, 'name') ?? sankeyDatumValue(datum, 'rawLabel') ?? ''}` + }, + }, + content: tooltipMetricLines(valueFormatter, labels), + }, + }, + background: { fill: 'transparent' }, + animation: false, + } +} diff --git a/web/default/src/features/dashboard/lib/index.ts b/web/default/src/features/dashboard/lib/index.ts index c0a652993655..8114371bca39 100644 --- a/web/default/src/features/dashboard/lib/index.ts +++ b/web/default/src/features/dashboard/lib/index.ts @@ -33,5 +33,9 @@ export { getDefaultPingStatus, } from './api-info' export { processChartData, processUserChartData } from './charts' +export { + buildDashboardFlowData, + buildFlowSankeySpec, +} from './flow' export { safeDivide, calculateDashboardStats } from './stats' export { getPreviewText } from './text' diff --git a/web/default/src/features/dashboard/section-registry.tsx b/web/default/src/features/dashboard/section-registry.tsx index db670f0c4222..c99017885840 100644 --- a/web/default/src/features/dashboard/section-registry.tsx +++ b/web/default/src/features/dashboard/section-registry.tsx @@ -33,6 +33,11 @@ const DASHBOARD_SECTIONS = [ titleKey: 'Model Call Analytics', build: () => null, }, + { + id: 'flow', + titleKey: 'Flow', + build: () => null, + }, { id: 'users', titleKey: 'User Analytics', diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index ad002e3c3045..c267ac8eb053 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -33,6 +33,119 @@ export interface QuotaDataItem { quota?: number } +export interface FlowQuotaDataItem { + user_id?: number + username?: string + user_group?: string + token_id?: number + token_name?: string + channel_id?: number + channel_name?: string + model_name?: string + token_used?: number + count?: number + quota?: number + input_tokens?: number + prompt_tokens?: number + completion_tokens?: number + cache_tokens?: number + cache_write_tokens?: number +} + +export type FlowMetric = 'quota' | 'tokens' | 'requests' + +export type FlowPathMode = 'model' | 'channel' | 'model-channel' + +export type FlowNodeKind = 'user' | 'token' | 'model' | 'channel' + +export interface FlowBuildOptions { + pathMode?: FlowPathMode + includeTokenLayer?: boolean + selectedUsers?: string[] + selectedTokensByUser?: Record + colorPalette?: readonly string[] +} + +export interface DashboardFlowNode { + id: string + label: string + kind: FlowNodeKind + value: number + requests: number + quota: number + tokens: number + inputTokens: number + promptTokens: number + completionTokens: number + cacheTokens: number + cacheWriteTokens: number + color: string + colorKey: string +} + +export interface DashboardFlowLink { + source: string + target: string + value: number + requests: number + quota: number + tokens: number + inputTokens: number + promptTokens: number + completionTokens: number + cacheTokens: number + cacheWriteTokens: number + sourceLabel: string + targetLabel: string + color: string + linkColor: string + linkAlpha: number + hoverColor: string + colorKey: string + share: number +} + +export interface DashboardFlowGraph { + nodes: DashboardFlowNode[] + links: DashboardFlowLink[] +} + +export interface FlowTokenFilterOption { + value: string + label: string + valueLabel: string + valueRaw: number +} + +export interface FlowUserFilterOption { + value: string + label: string + valueLabel: string + valueRaw: number + color: string + tokens: FlowTokenFilterOption[] +} + +export interface FlowFilterOptions { + users: FlowUserFilterOption[] +} + +export interface FlowSummary { + quota: number + tokens: number + inputTokens: number + completionTokens: number + cacheTokens: number + cacheWriteTokens: number + requests: number +} + +export interface ProcessedFlowData { + summary: FlowSummary + flow: DashboardFlowGraph + filterOptions: FlowFilterOptions +} + // ============================================================================ // Uptime Monitoring Types // ============================================================================ diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 24b690400c9d..5c81741352ca 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -250,6 +250,7 @@ "Ali": "Ali", "Alipay": "Alipay", "All": "All", + "All API tokens": "All API tokens", "All categories": "All categories", "All conditions must match before this tier is used.": "All conditions must match before this tier is used.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.", @@ -265,6 +266,7 @@ "All Tags": "All Tags", "All Types": "All Types", "All upstream data is trusted": "All upstream data is trusted", + "All users": "All users", "All Vendors": "All Vendors", "All-time": "All-time", "Allocated Memory": "Allocated Memory", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "Filter models by type, endpoint, vendor, group and tags", "Filter models...": "Filter models...", "Filter the model analytics view by time range and user.": "Filter the model analytics view by time range and user.", + "Filter the traffic flow view by time range and user.": "Filter the traffic flow view by time range and user.", "Filter...": "Filter...", "Filters": "Filters", "Filters active": "Filters active", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "Fixed price (USD)", "Fixed request price": "Fixed request price", "Floating": "Floating", + "Flow": "Flow", + "Flow Filters": "Flow Filters", "FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.", "Flush interval (minutes)": "Flush interval (minutes)", "Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.", @@ -2332,6 +2337,7 @@ "Mode": "Mode", "model": "model", "Model": "Model", + "Model + Channel": "Model + Channel", "Model Access": "Model Access", "Model Analytics": "Model Analytics", "Model Analytics Defaults": "Model Analytics Defaults", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "No API keys available. Create your first API key to get started.", "No API Keys Found": "No API Keys Found", "No API routes configured": "No API routes configured", + "No API tokens": "No API tokens", "No app usage data available for this model.": "No app usage data available for this model.", "No apps match the selected filters": "No apps match the selected filters", "No available models": "No available models", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "No FAQ entries available", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "No FAQ entries yet. Click \"Add FAQ\" to create one.", "No files match the accepted types.": "No files match the accepted types.", + "No flow data available": "No flow data available", "No group found.": "No group found.", "No group-based rate limits configured. Click \"Add group\" to get started.": "No group-based rate limits configured. Click \"Add group\" to get started.", "No groups match your search": "No groups match your search", @@ -2570,6 +2578,7 @@ "No matching items": "No matching items", "No matching results": "No matching results", "No matching rules": "No matching rules", + "No matching token and channel usage was found.": "No matching token and channel usage was found.", "No messages yet": "No messages yet", "No missing models found.": "No missing models found.", "No model found.": "No model found.", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "No usage logs available. Logs will appear here once API calls are made.", "No user information available": "No user information available", "No user selected": "No user selected", + "No users": "No users", "No users available. Try adjusting your search or filters.": "No users available. Try adjusting your search or filters.", "No Users Found": "No Users Found", "No vendor data available": "No vendor data available", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Request success rate; {{incidents}} incident buckets in the last 24 hours", "Request timed out, please refresh and restart GitHub login": "Request timed out, please refresh and restart GitHub login", "Request-based": "Request-based", + "Requests": "Requests", "Requests (24h)": "Requests (24h)", "Requests / 24h": "Requests / 24h", "Requests per minute": "Requests per minute", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 72c8aac7c038..6e12aa519d87 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -250,6 +250,7 @@ "Ali": "Ali", "Alipay": "Alipay", "All": "Tout", + "All API tokens": "Tous les jetons API", "All categories": "Toutes catégories", "All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Toutes les modifications sont des opérations d'écrasement. Laissez les champs vides pour conserver les valeurs actuelles inchangées.", @@ -265,6 +266,7 @@ "All Tags": "Tous les tags", "All Types": "Tous les types", "All upstream data is trusted": "Toutes les données en amont sont fiables", + "All users": "Tous les utilisateurs", "All Vendors": "Tous les fournisseurs", "All-time": "Tous temps", "Allocated Memory": "Mémoire allouée", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "Filtrer les modèles par type, point d'accès, fournisseur, groupe et tags", "Filter models...": "Filtrer les modèles...", "Filter the model analytics view by time range and user.": "Filtrez la vue d’analyse des modèles par période et utilisateur.", + "Filter the traffic flow view by time range and user.": "Filtrez la vue du flux de trafic par plage horaire et utilisateur.", "Filter...": "Filtrer...", "Filters": "Filtres", "Filters active": "Filtres actifs", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "Prix fixe (USD)", "Fixed request price": "Prix fixe par requête", "Floating": "Flottant", + "Flow": "Flux", + "Flow Filters": "Filtres de flux", "FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.", "Flush interval (minutes)": "Intervalle d’écriture (minutes)", "Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.", @@ -2332,6 +2337,7 @@ "Mode": "Mode", "model": "modèle", "Model": "Modèle", + "Model + Channel": "Modèle + canal", "Model Access": "Accès au modèle", "Model Analytics": "Analyse des modèles", "Model Analytics Defaults": "Paramètres par défaut de l’analyse des modèles", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "Aucune clé API disponible. Créez votre première clé API pour commencer.", "No API Keys Found": "Aucune clé API trouvée", "No API routes configured": "Aucune route API configurée", + "No API tokens": "Aucun jeton API", "No app usage data available for this model.": "Aucune donnée d'utilisation d'application n'est disponible pour ce modèle.", "No apps match the selected filters": "Aucune application ne correspond aux filtres", "No available models": "Aucun modèle disponible", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "Aucune entrée FAQ disponible", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "Aucune entrée FAQ pour l'instant. Cliquez sur \"Ajouter une FAQ\" pour en créer une.", "No files match the accepted types.": "Aucun fichier ne correspond aux types acceptés.", + "No flow data available": "Aucune donnée de flux disponible", "No group found.": "Aucun groupe trouvé.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Aucune limite de taux basée sur les groupes configurée. Cliquez sur \"Ajouter un groupe\" pour commencer.", "No groups match your search": "Aucun groupe ne correspond à votre recherche", @@ -2570,6 +2578,7 @@ "No matching items": "Aucun élément correspondant", "No matching results": "Aucun résultat correspondant", "No matching rules": "Aucune règle correspondante", + "No matching token and channel usage was found.": "Aucune utilisation correspondante par jeton et canal n'a été trouvée.", "No messages yet": "Pas encore de messages", "No missing models found.": "Aucun modèle manquant trouvé.", "No model found.": "Aucun modèle trouvé.", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "Aucun journal d'utilisation. Les journaux apparaîtront ici une fois les appels API effectués.", "No user information available": "Aucune information utilisateur disponible", "No user selected": "Aucun utilisateur sélectionné", + "No users": "Aucun utilisateur", "No users available. Try adjusting your search or filters.": "Aucun utilisateur disponible. Essayez d'ajuster votre recherche ou vos filtres.", "No Users Found": "Aucun utilisateur trouvé", "No vendor data available": "Aucune donnée de fournisseur disponible", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Taux de réussite des requêtes ; {{incidents}} créneaux avec incident sur les dernières 24 heures", "Request timed out, please refresh and restart GitHub login": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub", "Request-based": "Selon la requête", + "Requests": "Requêtes", "Requests (24h)": "Requêtes (24 h)", "Requests / 24h": "Requêtes / 24 h", "Requests per minute": "Requêtes par minute", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 9b8a1bb95a40..44f4b78eb4d4 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -250,6 +250,7 @@ "Ali": "Ali", "Alipay": "Alipay", "All": "すべて", + "All API tokens": "すべての API キー", "All categories": "すべてのカテゴリ", "All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。", @@ -265,6 +266,7 @@ "All Tags": "すべてのタグ", "All Types": "すべてのタイプ", "All upstream data is trusted": "すべてのアップストリームデータは信頼されています", + "All users": "すべてのユーザー", "All Vendors": "すべてのベンダー", "All-time": "全期間", "Allocated Memory": "割り当て済みメモリ", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "タイプ、エンドポイント、ベンダー、グループ、タグでモデルをフィルタリング", "Filter models...": "モデルをフィルタリング...", "Filter the model analytics view by time range and user.": "時間範囲とユーザーでモデル分析ビューを絞り込みます。", + "Filter the traffic flow view by time range and user.": "時間範囲とユーザーでトラフィックフロー表示を絞り込みます。", "Filter...": "フィルター…", "Filters": "フィルター", "Filters active": "フィルター有効", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "固定価格 (USD)", "Fixed request price": "固定リクエスト価格", "Floating": "フローティング", + "Flow": "フロー", + "Flow Filters": "フローフィルター", "FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。", "Flush interval (minutes)": "書き込み間隔(分)", "Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。", @@ -2332,6 +2337,7 @@ "Mode": "モード", "model": "モデル", "Model": "モデル", + "Model + Channel": "モデル + チャネル", "Model Access": "モデルアクセス", "Model Analytics": "モデル分析", "Model Analytics Defaults": "モデル分析のデフォルト設定", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "利用可能なAPIキーがありません。最初のAPIキーを作成して開始してください。", "No API Keys Found": "APIキーが見つかりません", "No API routes configured": "APIルートが設定されていません", + "No API tokens": "API キーなし", "No app usage data available for this model.": "このモデルのアプリ利用データはまだありません。", "No apps match the selected filters": "条件に一致するアプリはありません", "No available models": "利用可能なモデルがありません", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "FAQエントリがありません", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "FAQエントリはまだありません。「FAQを追加」をクリックして作成してください。", "No files match the accepted types.": "許可された種類に一致するファイルがありません。", + "No flow data available": "フローデータがありません", "No group found.": "グループが見つかりません。", "No group-based rate limits configured. Click \"Add group\" to get started.": "グループベースのレート制限が設定されていません。\"グループを追加\" をクリックして開始してください。", "No groups match your search": "検索に一致するグループがありません", @@ -2570,6 +2578,7 @@ "No matching items": "一致する項目がありません", "No matching results": "一致する結果がありません", "No matching rules": "一致するルールがありません", + "No matching token and channel usage was found.": "一致するトークンとチャネルの使用量が見つかりませんでした。", "No messages yet": "まだメッセージがありません", "No missing models found.": "不足しているモデルは見つかりません。", "No model found.": "モデルが見つかりません。", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "使用ログはありません。API呼び出し後にログがここに表示されます。", "No user information available": "ユーザー情報はありません", "No user selected": "ユーザーが選択されていません", + "No users": "ユーザーなし", "No users available. Try adjusting your search or filters.": "利用可能なユーザーがいません。検索またはフィルターを調整してみてください。", "No Users Found": "ユーザーが見つかりません", "No vendor data available": "ベンダーデータがありません", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "リクエスト成功率;過去 24 時間に {{incidents}} 個のインシデント時間枠", "Request timed out, please refresh and restart GitHub login": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください", "Request-based": "リクエスト条件あり", + "Requests": "リクエスト", "Requests (24h)": "リクエスト (24h)", "Requests / 24h": "リクエスト / 24h", "Requests per minute": "1分あたりのリクエスト数", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index e0fbe93f85c6..fdb2203ef92a 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -250,6 +250,7 @@ "Ali": "Ali", "Alipay": "Alipay", "All": "Все", + "All API tokens": "Все API-ключи", "All categories": "Все категории", "All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.", @@ -265,6 +266,7 @@ "All Tags": "Все теги", "All Types": "Все типы", "All upstream data is trusted": "Все вышестоящие данные являются доверенными", + "All users": "Все пользователи", "All Vendors": "Все поставщики", "All-time": "За всё время", "Allocated Memory": "Выделенная память", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "Фильтровать модели по типу, точке доступа, поставщику, группе и тегам", "Filter models...": "Фильтровать модели...", "Filter the model analytics view by time range and user.": "Фильтруйте представление аналитики моделей по периоду и пользователю.", + "Filter the traffic flow view by time range and user.": "Фильтруйте представление потока трафика по диапазону времени и пользователю.", "Filter...": "Фильтр...", "Filters": "Фильтры", "Filters active": "Фильтры активны", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "Фиксированная цена (USD)", "Fixed request price": "Фиксированная цена запроса", "Floating": "Плавающая", + "Flow": "Поток", + "Flow Filters": "Фильтры потока", "FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.", "Flush interval (minutes)": "Интервал записи (минуты)", "Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.", @@ -2332,6 +2337,7 @@ "Mode": "Режим", "model": "модель", "Model": "Модель", + "Model + Channel": "Модель + канал", "Model Access": "Доступ к моделям", "Model Analytics": "Аналитика моделей", "Model Analytics Defaults": "Настройки аналитики моделей по умолчанию", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "Нет доступных ключей API. Создайте свой первый ключ API, чтобы начать.", "No API Keys Found": "Ключи API не найдены", "No API routes configured": "Нет настроенных маршрутов API", + "No API tokens": "Нет API-ключей", "No app usage data available for this model.": "Данные об использовании приложений для этой модели пока недоступны.", "No apps match the selected filters": "Нет приложений, соответствующих фильтрам", "No available models": "Нет доступных моделей", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "Нет доступных записей FAQ", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "Пока нет записей FAQ. Нажмите \"Добавить FAQ\", чтобы создать одну.", "No files match the accepted types.": "Нет файлов, соответствующих допустимым типам.", + "No flow data available": "Нет данных потока", "No group found.": "Группа не найдена.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Групповые лимиты скорости не настроены. Нажмите \"Добавить группу\", чтобы начать.", "No groups match your search": "Нет групп, соответствующих вашему поиску", @@ -2570,6 +2578,7 @@ "No matching items": "Нет подходящих элементов", "No matching results": "Нет совпадений", "No matching rules": "Нет совпадающих правил", + "No matching token and channel usage was found.": "Подходящее использование токенов и каналов не найдено.", "No messages yet": "Сообщений пока нет", "No missing models found.": "Недостающие модели не найдены.", "No model found.": "Модель не найдена.", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "Нет логов использования. Они появятся после вызовов API.", "No user information available": "Нет данных пользователя", "No user selected": "Пользователь не выбран", + "No users": "Нет пользователей", "No users available. Try adjusting your search or filters.": "Нет доступных пользователей. Попробуйте изменить параметры поиска или фильтры.", "No Users Found": "Пользователи не найдены", "No vendor data available": "Данных по поставщикам нет", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Доля успешных запросов; {{incidents}} интервалов с инцидентами за последние 24 часа", "Request timed out, please refresh and restart GitHub login": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub", "Request-based": "Зависит от запроса", + "Requests": "Запросы", "Requests (24h)": "Запросы (24 ч)", "Requests / 24h": "Запросы / 24 ч", "Requests per minute": "Запросов в минуту", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 9de7436b23cd..47147e0cd82d 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -250,6 +250,7 @@ "Ali": "Ali", "Alipay": "Alipay", "All": "All", + "All API tokens": "Tất cả khóa API", "All categories": "Tất cả danh mục", "All conditions must match before this tier is used.": "Tất cả điều kiện phải khớp trước khi tầng này được sử dụng.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Tất cả các chỉnh sửa đều là thao tác ghi đè. Để trống các trường để giữ nguyên giá trị hiện tại.", @@ -265,6 +266,7 @@ "All Tags": "Tất cả Thẻ", "All Types": "All types", "All upstream data is trusted": "Tất cả dữ liệu thượng nguồn đều được tin cậy", + "All users": "Tất cả người dùng", "All Vendors": "Tất cả Nhà cung cấp", "All-time": "Mọi thời điểm", "Allocated Memory": "Bộ nhớ đã cấp phát", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "Lọc mô hình theo loại, endpoint, nhà cung cấp, nhóm và thẻ", "Filter models...": "Lọc mô hình...", "Filter the model analytics view by time range and user.": "Lọc chế độ xem phân tích mô hình theo khoảng thời gian và người dùng.", + "Filter the traffic flow view by time range and user.": "Lọc chế độ xem luồng lưu lượng theo khoảng thời gian và người dùng.", "Filter...": "Lọc...", "Filters": "Bộ lọc", "Filters active": "Bộ lọc đang bật", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "Giá cố định (USD)", "Fixed request price": "Giá cố định theo yêu cầu", "Floating": "Nổi", + "Flow": "Luồng", + "Flow Filters": "Bộ lọc luồng", "FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.", "Flush interval (minutes)": "Khoảng ghi xuống DB (phút)", "Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.", @@ -2332,6 +2337,7 @@ "Mode": "Chế độ", "model": "mô hình", "Model": "Mô hình", + "Model + Channel": "Mô hình + kênh", "Model Access": "Truy cập mô hình", "Model Analytics": "Phân tích mô hình", "Model Analytics Defaults": "Mặc định phân tích mô hình", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "Không có khóa API nào khả dụng. Tạo khóa API đầu tiên của bạn để bắt đầu.", "No API Keys Found": "Không tìm thấy khóa API", "No API routes configured": "Chưa có tuyến API nào được cấu hình", + "No API tokens": "Không có khóa API", "No app usage data available for this model.": "Chưa có dữ liệu sử dụng ứng dụng cho mô hình này.", "No apps match the selected filters": "Không có ứng dụng phù hợp bộ lọc", "No available models": "Không có mô hình khả dụng", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "Không có mục FAQ nào.", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "Chưa có mục FAQ nào. Nhấp vào \"Thêm FAQ\" để tạo một mục.", "No files match the accepted types.": "Không có tệp nào khớp với các loại được chấp nhận.", + "No flow data available": "Không có dữ liệu luồng", "No group found.": "Không tìm thấy nhóm.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Chưa cấu hình giới hạn tốc độ dựa trên nhóm. Nhấp \"Add group\" để bắt đầu.", "No groups match your search": "Không có nhóm nào khớp với tìm kiếm của bạn", @@ -2570,6 +2578,7 @@ "No matching items": "Không có mục phù hợp", "No matching results": "Không có kết quả phù hợp", "No matching rules": "Không có quy tắc phù hợp", + "No matching token and channel usage was found.": "Không tìm thấy mức sử dụng token và kênh phù hợp.", "No messages yet": "Chưa có tin nhắn", "No missing models found.": "Không tìm thấy mô hình nào bị thiếu.", "No model found.": "Không tìm thấy mô hình.", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "Chưa có nhật ký sử dụng. Nhật ký sẽ hiển thị sau khi có gọi API.", "No user information available": "Không có thông tin người dùng", "No user selected": "Chưa chọn người dùng", + "No users": "Không có người dùng", "No users available. Try adjusting your search or filters.": "Không có người dùng nào. Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc của bạn.", "No Users Found": "Không tìm thấy người dùng nào", "No vendor data available": "Không có dữ liệu nhà cung cấp", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "Tỷ lệ yêu cầu thành công; {{incidents}} khoảng có sự cố trong 24 giờ qua", "Request timed out, please refresh and restart GitHub login": "Yêu cầu đã hết thời gian chờ, vui lòng làm mới và đăng nhập lại GitHub", "Request-based": "Theo yêu cầu", + "Requests": "Yêu cầu", "Requests (24h)": "Yêu cầu (24h)", "Requests / 24h": "Yêu cầu / 24h", "Requests per minute": "Yêu cầu mỗi phút", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index ad3d66b22d32..fc1ed67a64c4 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -250,6 +250,7 @@ "Ali": "阿里", "Alipay": "支付宝", "All": "全部", + "All API tokens": "全部 API 密钥", "All categories": "全部分类", "All conditions must match before this tier is used.": "所有条件都匹配后才会使用此阶梯。", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。", @@ -265,6 +266,7 @@ "All Tags": "所有标签", "All Types": "所有类型", "All upstream data is trusted": "所有上游数据均受信任", + "All users": "全部用户", "All Vendors": "所有供应商", "All-time": "全部时间", "Allocated Memory": "已分配内存", @@ -1742,6 +1744,7 @@ "Filter models by type, endpoint, vendor, group and tags": "按类型、端点、供应商、分组和标签筛选模型", "Filter models...": "筛选模型...", "Filter the model analytics view by time range and user.": "按时间范围和用户筛选模型分析视图。", + "Filter the traffic flow view by time range and user.": "按时间范围和用户筛选分流图视图。", "Filter...": "筛选...", "Filters": "筛选器", "Filters active": "筛选已启用", @@ -1758,6 +1761,8 @@ "Fixed price (USD)": "固定价格 (USD)", "Fixed request price": "固定按次价格", "Floating": "浮动", + "Flow": "分流", + "Flow Filters": "分流筛选", "FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。", "Flush interval (minutes)": "刷库间隔(分钟)", "Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。", @@ -2332,6 +2337,7 @@ "Mode": "模式", "model": "模型", "Model": "模型", + "Model + Channel": "模型 + 渠道", "Model Access": "模型访问", "Model Analytics": "模型数据分析", "Model Analytics Defaults": "模型分析默认设置", @@ -2513,6 +2519,7 @@ "No API keys available. Create your first API key to get started.": "没有可用的 API 密钥。创建您的第一个 API 密钥即可开始使用。", "No API Keys Found": "未找到 API 密钥", "No API routes configured": "未配置 API 路由", + "No API tokens": "无 API 密钥", "No app usage data available for this model.": "该模型暂无应用使用数据。", "No apps match the selected filters": "没有匹配筛选条件的应用", "No available models": "没有可用模型", @@ -2551,6 +2558,7 @@ "No FAQ entries available": "暂无 FAQ 条目", "No FAQ entries yet. Click \"Add FAQ\" to create one.": "暂无常见问题条目。点击“添加常见问题”来创建一个。", "No files match the accepted types.": "没有文件匹配接受的类型。", + "No flow data available": "暂无分流数据", "No group found.": "未找到分组。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未配置基于组的速率限制。点击“添加组”开始使用。", "No groups match your search": "没有组匹配您的搜索", @@ -2570,6 +2578,7 @@ "No matching items": "没有匹配项", "No matching results": "无匹配结果", "No matching rules": "没有匹配的规则", + "No matching token and channel usage was found.": "未找到匹配的令牌与渠道用量。", "No messages yet": "暂无消息", "No missing models found.": "未找到缺失的模型。", "No model found.": "未找到模型。", @@ -2642,6 +2651,7 @@ "No usage logs available. Logs will appear here once API calls are made.": "暂无使用日志。发起 API 调用后日志将显示在此处。", "No user information available": "暂无用户信息", "No user selected": "未选择用户", + "No users": "无用户", "No users available. Try adjusting your search or filters.": "没有可用的用户。请尝试调整您的搜索或筛选条件。", "No Users Found": "未找到用户", "No vendor data available": "暂无厂商数据", @@ -3325,6 +3335,7 @@ "Request success rate; {{incidents}} incident buckets in the last 24 hours": "请求成功率;最近 24 小时 {{incidents}} 个异常桶", "Request timed out, please refresh and restart GitHub login": "请求超时,请刷新页面后重新发起 GitHub 登录", "Request-based": "含请求条件", + "Requests": "请求数", "Requests (24h)": "请求数(24 小时)", "Requests / 24h": "请求 / 24 小时", "Requests per minute": "每分钟请求数", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index d3482b438796..92e78a4e23b4 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -423,6 +423,10 @@ export const STATIC_I18N_KEYS = [ 'Data management and log viewing', 'Dashboard', 'System data statistics', + 'Flow', + 'Flow Filters', + 'Filter the traffic flow view by time range and user.', + 'Requests', 'Token Management', 'API token management', 'Usage Logs', From ab8c3a89b9ec7afb13521bbdef16672b7673bee8 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Jun 2026 17:19:43 +0800 Subject: [PATCH 2/2] feat: build flow chart from quota data --- controller/usedata.go | 34 +- controller/usedata_flow_test.go | 84 ++- model/log.go | 34 +- model/task_cas_test.go | 2 + model/usedata.go | 107 +++- model/usedata_flow.go | 229 +++---- model/usedata_flow_test.go | 333 +++++----- .../dashboard/components/flow/flow-charts.tsx | 259 +------- .../dashboard/lib/flow-selection.test.ts | 65 +- .../features/dashboard/lib/flow-selection.ts | 23 - .../src/features/dashboard/lib/flow.test.ts | 603 ++---------------- .../src/features/dashboard/lib/flow.ts | 552 ++++------------ web/default/src/features/dashboard/types.ts | 44 +- 13 files changed, 711 insertions(+), 1658 deletions(-) diff --git a/controller/usedata.go b/controller/usedata.go index df3f6273558e..52c6287dcee0 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -10,6 +10,24 @@ import ( "github.com/gin-gonic/gin" ) +func parseFlowQuotaTimeRange(c *gin.Context) (int64, int64, bool) { + startTimestamp, err := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + if err != nil || startTimestamp <= 0 { + common.ApiErrorMsg(c, "invalid start_timestamp") + return 0, 0, false + } + endTimestamp, err := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + if err != nil || endTimestamp <= 0 { + common.ApiErrorMsg(c, "invalid end_timestamp") + return 0, 0, false + } + if endTimestamp < startTimestamp { + common.ApiErrorMsg(c, "invalid time range") + return 0, 0, false + } + return startTimestamp, endTimestamp, true +} + func GetAllQuotaDates(c *gin.Context) { startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) @@ -68,10 +86,12 @@ func GetUserQuotaDates(c *gin.Context) { } func GetAllFlowQuotaDates(c *gin.Context) { - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + startTimestamp, endTimestamp, ok := parseFlowQuotaTimeRange(c) + if !ok { + return + } username := c.Query("username") - dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, username, 0) + dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, username, 0, c.GetInt("role")) if err != nil { common.ApiError(c, err) return @@ -86,8 +106,10 @@ func GetAllFlowQuotaDates(c *gin.Context) { func GetUserFlowQuotaDates(c *gin.Context) { userId := c.GetInt("id") - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + startTimestamp, endTimestamp, ok := parseFlowQuotaTimeRange(c) + if !ok { + return + } if endTimestamp-startTimestamp > 2592000 { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -95,7 +117,7 @@ func GetUserFlowQuotaDates(c *gin.Context) { }) return } - dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId) + dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId, common.RoleCommonUser) if err != nil { common.ApiError(c, err) return diff --git a/controller/usedata_flow_test.go b/controller/usedata_flow_test.go index 1e9c51fb67e2..a5bc50113dac 100644 --- a/controller/usedata_flow_test.go +++ b/controller/usedata_flow_test.go @@ -20,29 +20,35 @@ type flowQuotaResponse struct { func setupFlowControllerTestDB(t *testing.T) { t.Helper() db := setupModelListControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.AutoMigrate(&model.Token{}, &model.QuotaData{})) require.NoError(t, model.DB.Create(&model.Channel{Id: 1, Name: "east"}).Error) - require.NoError(t, model.LOG_DB.Create(&model.Log{ - UserId: 1, + require.NoError(t, model.DB.Create(&model.Token{Id: 11, UserId: 1, Key: "sk-primary", Name: "primary"}).Error) + require.NoError(t, model.DB.Create(&model.Token{Id: 22, UserId: 2, Key: "sk-backup", Name: "backup"}).Error) + require.NoError(t, model.DB.Create(&model.QuotaData{ + UserID: 1, Username: "alice", + NodeName: "node-a", + TokenID: 11, + UseGroup: "default", + ChannelID: 1, + ModelName: "gpt-a", CreatedAt: 1100, - Type: model.LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - Group: "default", + Count: 2, Quota: 100, + TokenUsed: 40, }).Error) - require.NoError(t, model.LOG_DB.Create(&model.Log{ - UserId: 2, + require.NoError(t, model.DB.Create(&model.QuotaData{ + UserID: 2, Username: "bob", + NodeName: "node-b", + TokenID: 22, + UseGroup: "vip", + ChannelID: 1, + ModelName: "gpt-b", CreatedAt: 1200, - Type: model.LogTypeConsume, - TokenId: 22, - TokenName: "backup", - ChannelId: 1, - Group: "vip", + Count: 1, Quota: 70, + TokenUsed: 30, }).Error) } @@ -55,11 +61,12 @@ func decodeFlowQuotaResponse(t *testing.T, recorder *httptest.ResponseRecorder) return payload } -func TestGetAllFlowQuotaDatesUsesUsernameFilter(t *testing.T) { +func TestGetAllFlowQuotaDatesUsesAdminDimensions(t *testing.T) { setupFlowControllerTestDB(t) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("role", common.RoleAdminUser) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/flow?start_timestamp=1000&end_timestamp=2000&username=bob", nil) GetAllFlowQuotaDates(ctx) @@ -67,7 +74,29 @@ func TestGetAllFlowQuotaDatesUsesUsernameFilter(t *testing.T) { payload := decodeFlowQuotaResponse(t, recorder) require.Len(t, payload.Data, 1) require.Equal(t, "bob", payload.Data[0].Username) - require.Equal(t, "backup", payload.Data[0].TokenName) + require.Equal(t, "vip", payload.Data[0].UseGroup) + require.Equal(t, "east", payload.Data[0].ChannelName) + require.Empty(t, payload.Data[0].TokenName) + require.Empty(t, payload.Data[0].NodeName) +} + +func TestGetAllFlowQuotaDatesUsesRootDimensions(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("role", common.RoleRootUser) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/flow?start_timestamp=1000&end_timestamp=2000&username=alice", nil) + + GetAllFlowQuotaDates(ctx) + + payload := decodeFlowQuotaResponse(t, recorder) + require.Len(t, payload.Data, 1) + require.Equal(t, "alice", payload.Data[0].Username) + require.Equal(t, "node-a", payload.Data[0].NodeName) + require.Equal(t, "primary", payload.Data[0].TokenName) + require.Equal(t, "default", payload.Data[0].UseGroup) + require.Equal(t, "east", payload.Data[0].ChannelName) } func TestGetUserFlowQuotaDatesRestrictsToAuthenticatedUser(t *testing.T) { @@ -82,6 +111,25 @@ func TestGetUserFlowQuotaDatesRestrictsToAuthenticatedUser(t *testing.T) { payload := decodeFlowQuotaResponse(t, recorder) require.Len(t, payload.Data, 1) - require.Equal(t, "alice", payload.Data[0].Username) + require.Empty(t, payload.Data[0].Username) require.Equal(t, "primary", payload.Data[0].TokenName) + require.Equal(t, "default", payload.Data[0].UseGroup) + require.Empty(t, payload.Data[0].ChannelName) +} + +func TestGetUserFlowQuotaDatesRejectsInvalidTimeRange(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("id", 1) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/flow/self?start_timestamp=bad&end_timestamp=2000", nil) + + GetUserFlowQuotaDates(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var payload flowQuotaResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload)) + require.False(t, payload.Success) + require.Equal(t, "invalid start_timestamp", payload.Message) } diff --git a/model/log.go b/model/log.go index cbcc3983d924..a786e397dad2 100644 --- a/model/log.go +++ b/model/log.go @@ -227,6 +227,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) username := c.GetString("username") requestId := c.GetString(common.RequestIdKey) upstreamRequestId := c.GetString(common.UpstreamRequestIdKey) + createdAt := common.GetTimestamp() otherStr := common.MapToJsonStr(params.Other) // 判断是否需要记录 IP needRecordIp := false @@ -238,7 +239,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) log := &Log{ UserId: userId, Username: username, - CreatedAt: common.GetTimestamp(), + CreatedAt: createdAt, Type: LogTypeConsume, Content: params.Content, PromptTokens: params.PromptTokens, @@ -267,7 +268,18 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } if common.DataExportEnabled { gopool.Go(func() { - LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) + LogQuotaData(QuotaDataLogParams{ + UserID: userId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + TokenUsed: params.PromptTokens + params.CompletionTokens, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: common.NodeName, + }) }) } } @@ -295,10 +307,11 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { tokenName = token.Name } } + createdAt := common.GetTimestamp() log := &Log{ UserId: params.UserId, Username: username, - CreatedAt: common.GetTimestamp(), + CreatedAt: createdAt, Type: params.LogType, Content: params.Content, TokenName: tokenName, @@ -313,6 +326,21 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { if err != nil { common.SysLog("failed to record task billing log: " + err.Error()) } + if params.LogType == LogTypeConsume && common.DataExportEnabled { + gopool.Go(func() { + LogQuotaData(QuotaDataLogParams{ + UserID: params.UserId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: common.NodeName, + }) + }) + } } func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 052bf638f989..5d9b6a23c4be 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -40,6 +40,7 @@ func TestMain(m *testing.M) { &Token{}, &Log{}, &Channel{}, + &QuotaData{}, &Ability{}, &TopUp{}, &SubscriptionPlan{}, @@ -61,6 +62,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM tokens") DB.Exec("DELETE FROM logs") DB.Exec("DELETE FROM channels") + DB.Exec("DELETE FROM quota_data") DB.Exec("DELETE FROM abilities") DB.Exec("DELETE FROM top_ups") DB.Exec("DELETE FROM subscription_orders") diff --git a/model/usedata.go b/model/usedata.go index f0ea055ae395..4190235bc2d9 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -16,11 +16,28 @@ type QuotaData struct { Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"` ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"` CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"` + UseGroup string `json:"use_group" gorm:"index;size:64;default:''"` + TokenID int `json:"token_id" gorm:"index;default:0"` + ChannelID int `json:"channel_id" gorm:"index;default:0"` + NodeName string `json:"node_name" gorm:"index;size:64;default:''"` TokenUsed int `json:"token_used" gorm:"default:0"` Count int `json:"count" gorm:"default:0"` Quota int `json:"quota" gorm:"default:0"` } +type QuotaDataLogParams struct { + UserID int + Username string + ModelName string + Quota int + CreatedAt int64 + TokenUsed int + UseGroup string + TokenID int + ChannelID int + NodeName string +} + func UpdateQuotaData() { for { if common.DataExportEnabled { @@ -34,34 +51,50 @@ func UpdateQuotaData() { var CacheQuotaData = make(map[string]*QuotaData) var CacheQuotaDataLock = sync.Mutex{} -func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { - key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt) - quotaData, ok := CacheQuotaData[key] +func logQuotaDataCache(quotaData *QuotaData) { + key := fmt.Sprintf("%d\x00%s\x00%s\x00%d\x00%s\x00%d\x00%d\x00%s", + quotaData.UserID, + quotaData.Username, + quotaData.ModelName, + quotaData.CreatedAt, + quotaData.UseGroup, + quotaData.TokenID, + quotaData.ChannelID, + quotaData.NodeName, + ) + count := quotaData.Count + quota := quotaData.Quota + tokenUsed := quotaData.TokenUsed + cachedQuotaData, ok := CacheQuotaData[key] if ok { - quotaData.Count += 1 - quotaData.Quota += quota - quotaData.TokenUsed += tokenUsed - } else { - quotaData = &QuotaData{ - UserID: userId, - Username: username, - ModelName: modelName, - CreatedAt: createdAt, - Count: 1, - Quota: quota, - TokenUsed: tokenUsed, - } + cachedQuotaData.Count += count + cachedQuotaData.Quota += quota + cachedQuotaData.TokenUsed += tokenUsed + quotaData = cachedQuotaData } CacheQuotaData[key] = quotaData } -func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { +func LogQuotaData(params QuotaDataLogParams) { // 只精确到小时 - createdAt = createdAt - (createdAt % 3600) + createdAt := params.CreatedAt - (params.CreatedAt % 3600) + quotaData := &QuotaData{ + UserID: params.UserID, + Username: params.Username, + ModelName: params.ModelName, + CreatedAt: createdAt, + UseGroup: params.UseGroup, + TokenID: params.TokenID, + ChannelID: params.ChannelID, + NodeName: params.NodeName, + Count: 1, + Quota: params.Quota, + TokenUsed: params.TokenUsed, + } CacheQuotaDataLock.Lock() defer CacheQuotaDataLock.Unlock() - logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed) + logQuotaDataCache(quotaData) } func SaveQuotaDataCache() { @@ -74,13 +107,15 @@ func SaveQuotaDataCache() { // 3. 如果没有数据,就插入数据 for _, quotaData := range CacheQuotaData { quotaDataDB := &QuotaData{} - DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt).First(quotaDataDB) + DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + First(quotaDataDB) if quotaDataDB.Id > 0 { //quotaDataDB.Count += quotaData.Count //quotaDataDB.Quota += quotaData.Quota //DB.Table("quota_data").Save(quotaDataDB) - increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed) + increaseQuotaData(quotaData) } else { DB.Table("quota_data").Create(quotaData) } @@ -89,13 +124,15 @@ func SaveQuotaDataCache() { common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) } -func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) { - err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - userId, username, modelName, createdAt).Updates(map[string]interface{}{ - "count": gorm.Expr("count + ?", count), - "quota": gorm.Expr("quota + ?", quota), - "token_used": gorm.Expr("token_used + ?", tokenUsed), - }).Error +func increaseQuotaData(quotaData *QuotaData) { + err := DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + Updates(map[string]interface{}{ + "count": gorm.Expr("count + ?", quotaData.Count), + "quota": gorm.Expr("quota + ?", quotaData.Quota), + "token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed), + }).Error if err != nil { common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) } @@ -104,14 +141,22 @@ func increaseQuotaData(userId int, username string, modelName string, count int, func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } diff --git a/model/usedata_flow.go b/model/usedata_flow.go index c39a7cae2bfc..b3ffa5fa6110 100644 --- a/model/usedata_flow.go +++ b/model/usedata_flow.go @@ -4,158 +4,131 @@ import ( "fmt" "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" ) type FlowQuotaData struct { - UserID int `json:"user_id" gorm:"column:user_id"` - Username string `json:"username" gorm:"column:username"` - UserGroup string `json:"user_group" gorm:"column:user_group"` - TokenID int `json:"token_id" gorm:"column:token_id"` - TokenName string `json:"token_name" gorm:"column:token_name"` - ChannelID int `json:"channel_id" gorm:"column:channel_id"` - ChannelName string `json:"channel_name" gorm:"-"` - ModelName string `json:"model_name" gorm:"column:model_name"` - TokenUsed int `json:"token_used" gorm:"column:token_used"` - InputTokens int `json:"input_tokens" gorm:"column:input_tokens"` - Count int `json:"count" gorm:"column:count"` - Quota int `json:"quota" gorm:"column:quota"` - PromptTokens int `json:"prompt_tokens" gorm:"column:prompt_tokens"` - CompletionTokens int `json:"completion_tokens" gorm:"column:completion_tokens"` - CacheTokens int `json:"cache_tokens" gorm:"column:cache_tokens"` - CacheWriteTokens int `json:"cache_write_tokens" gorm:"column:cache_write_tokens"` + UserID int `json:"user_id,omitempty" gorm:"column:user_id"` + Username string `json:"username,omitempty" gorm:"column:username"` + NodeName string `json:"node_name,omitempty" gorm:"column:node_name"` + TokenID int `json:"token_id,omitempty" gorm:"column:token_id"` + TokenName string `json:"token_name,omitempty" gorm:"-"` + UseGroup string `json:"use_group" gorm:"column:use_group"` + ChannelID int `json:"channel_id,omitempty" gorm:"column:channel_id"` + ChannelName string `json:"channel_name,omitempty" gorm:"-"` + ModelName string `json:"model_name" gorm:"column:model_name"` + TokenUsed int `json:"token_used" gorm:"column:token_used"` + Count int `json:"count" gorm:"column:count"` + Quota int `json:"quota" gorm:"column:quota"` } -func GetFlowQuotaData(startTime int64, endTime int64, username string, userID int) ([]*FlowQuotaData, error) { - groupCol := "logs." + logGroupCol - cacheTokensExpr := flowLogJSONNumberExpr("cache_tokens") - cacheWriteTokensExpr := flowLogCacheWriteTokensExpr() - inputTokensExpr := flowLogInputTokensExpr(cacheTokensExpr, cacheWriteTokensExpr) - rows := make([]*FlowQuotaData, 0) - query := LOG_DB.Table("logs"). - Select( - "logs.user_id, logs.username, logs.token_id, logs.token_name, logs.channel_id, logs.model_name, "+ - groupCol+" AS user_group, "+ - "COUNT(*) AS count, "+ - "COALESCE(SUM(logs.quota), 0) AS quota, "+ - "COALESCE(SUM(logs.prompt_tokens), 0) AS prompt_tokens, "+ - "COALESCE(SUM("+inputTokensExpr+"), 0) AS input_tokens, "+ - "COALESCE(SUM(logs.completion_tokens), 0) AS completion_tokens, "+ - "COALESCE(SUM("+cacheTokensExpr+"), 0) AS cache_tokens, "+ - "COALESCE(SUM("+cacheWriteTokensExpr+"), 0) AS cache_write_tokens", - ). - Where("logs.type = ?", LogTypeConsume) - - if startTime > 0 { - query = query.Where("logs.created_at >= ?", startTime) +func GetFlowQuotaData(startTime int64, endTime int64, username string, userID int, role int) ([]*FlowQuotaData, error) { + switch { + case role >= common.RoleRootUser: + return getRootFlowQuotaData(startTime, endTime, username) + case role >= common.RoleAdminUser: + return getAdminFlowQuotaData(startTime, endTime, username) + default: + return getSelfFlowQuotaData(startTime, endTime, userID) } - if endTime > 0 { - query = query.Where("logs.created_at <= ?", endTime) +} + +func flowQuotaBaseQuery(startTime int64, endTime int64) *gorm.DB { + query := DB.Table("quota_data"). + Where("use_group <> ''"). + Where("created_at >= ? and created_at <= ?", startTime, endTime) + return query +} + +func getSelfFlowQuotaData(startTime int64, endTime int64, userID int) ([]*FlowQuotaData, error) { + rows := make([]*FlowQuotaData, 0) + err := flowQuotaBaseQuery(startTime, endTime). + Select("token_id, use_group, model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("user_id = ?", userID). + Group("token_id, use_group, model_name"). + Order("quota DESC"). + Find(&rows).Error + if err != nil { + return nil, err } + return rows, fillFlowTokenNames(rows) +} + +func getAdminFlowQuotaData(startTime int64, endTime int64, username string) ([]*FlowQuotaData, error) { + rows := make([]*FlowQuotaData, 0) + query := flowQuotaBaseQuery(startTime, endTime). + Select("user_id, username, use_group, model_name, channel_id, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used") if username != "" { - query = query.Where("logs.username = ?", username) - } - if userID > 0 { - query = query.Where("logs.user_id = ?", userID) + query = query.Where("username = ?", username) } - err := query. - Group("logs.user_id, logs.username, logs.token_id, logs.token_name, logs.channel_id, logs.model_name, " + groupCol). + Group("user_id, username, use_group, model_name, channel_id"). Order("quota DESC"). Find(&rows).Error if err != nil { return nil, err } + return rows, fillFlowChannelNames(rows) +} - for _, row := range rows { - row.TokenUsed = row.InputTokens + row.CompletionTokens +func getRootFlowQuotaData(startTime int64, endTime int64, username string) ([]*FlowQuotaData, error) { + rows := make([]*FlowQuotaData, 0) + query := flowQuotaBaseQuery(startTime, endTime). + Select("user_id, username, node_name, token_id, use_group, model_name, channel_id, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used") + if username != "" { + query = query.Where("username = ?", username) + } + err := query. + Group("user_id, username, node_name, token_id, use_group, model_name, channel_id"). + Order("quota DESC"). + Find(&rows).Error + if err != nil { + return nil, err } - if err := fillFlowChannelNames(rows); err != nil { + if err := fillFlowTokenNames(rows); err != nil { return rows, err } - return rows, nil + return rows, fillFlowChannelNames(rows) } -func flowLogJSONValidGuard() string { - if common.UsingPostgreSQL { - return "COALESCE(logs.other, '') <> ''" +func fillFlowTokenNames(rows []*FlowQuotaData) error { + tokenIDSet := make(map[int]struct{}) + tokenIDs := make([]int, 0) + for _, row := range rows { + if row.TokenID == 0 { + continue + } + if _, ok := tokenIDSet[row.TokenID]; ok { + continue + } + tokenIDSet[row.TokenID] = struct{}{} + tokenIDs = append(tokenIDs, row.TokenID) } - if common.UsingMySQL { - return "COALESCE(logs.other, '') <> '' AND JSON_VALID(logs.other)" + if len(tokenIDs) == 0 { + return nil } - return "COALESCE(logs.other, '') <> '' AND json_valid(logs.other)" -} - -func flowLogJSONNumberExpr(key string) string { - guard := flowLogJSONValidGuard() - if common.UsingPostgreSQL { - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(NULLIF(logs.other::jsonb ->> '%s', '')::integer, 0) ELSE 0 END)", - guard, - key, - ) - } - if common.UsingMySQL { - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(logs.other, '$.%s')) AS SIGNED), 0) ELSE 0 END)", - guard, - key, - ) - } - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(CAST(json_extract(logs.other, '$.%s') AS INTEGER), 0) ELSE 0 END)", - guard, - key, - ) -} - -func flowLogJSONStringExpr(key string) string { - guard := flowLogJSONValidGuard() - if common.UsingPostgreSQL { - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(logs.other::jsonb ->> '%s', '') ELSE '' END)", - guard, - key, - ) - } - if common.UsingMySQL { - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(logs.other, '$.%s')), '') ELSE '' END)", - guard, - key, - ) - } - return fmt.Sprintf( - "(CASE WHEN %s THEN COALESCE(json_extract(logs.other, '$.%s'), '') ELSE '' END)", - guard, - key, - ) -} - -func flowLogCacheWriteTokensExpr() string { - cacheWriteTokens := flowLogJSONNumberExpr("cache_write_tokens") - cacheCreationTokens := flowLogJSONNumberExpr("cache_creation_tokens") - cacheCreation5mTokens := flowLogJSONNumberExpr("cache_creation_tokens_5m") - cacheCreation1hTokens := flowLogJSONNumberExpr("cache_creation_tokens_1h") - splitCacheCreationTokens := fmt.Sprintf("(%s + %s)", cacheCreation5mTokens, cacheCreation1hTokens) - return fmt.Sprintf( - "(CASE WHEN %s > 0 THEN %s WHEN %s > 0 THEN %s ELSE %s END)", - cacheWriteTokens, - cacheWriteTokens, - splitCacheCreationTokens, - splitCacheCreationTokens, - cacheCreationTokens, - ) -} -func flowLogInputTokensExpr(cacheTokensExpr string, cacheWriteTokensExpr string) string { - usageSemanticExpr := flowLogJSONStringExpr("usage_semantic") - nonCacheInputExpr := fmt.Sprintf("(logs.prompt_tokens - %s - %s)", cacheTokensExpr, cacheWriteTokensExpr) - return fmt.Sprintf( - "(CASE WHEN %s = 'anthropic' THEN logs.prompt_tokens WHEN %s > 0 THEN %s ELSE 0 END)", - usageSemanticExpr, - nonCacheInputExpr, - nonCacheInputExpr, - ) + var tokens []struct { + Id int `gorm:"column:id"` + Name string `gorm:"column:name"` + } + if err := DB.Unscoped().Model(&Token{}).Select("id, name").Where("id IN ?", tokenIDs).Find(&tokens).Error; err != nil { + return err + } + tokenNameByID := make(map[int]string, len(tokens)) + for _, token := range tokens { + tokenNameByID[token.Id] = token.Name + } + for _, row := range rows { + if name := tokenNameByID[row.TokenID]; name != "" { + row.TokenName = name + continue + } + if row.TokenID > 0 { + row.TokenName = fmt.Sprintf("token-%d", row.TokenID) + } + } + return nil } func fillFlowChannelNames(rows []*FlowQuotaData) error { diff --git a/model/usedata_flow_test.go b/model/usedata_flow_test.go index 51365e659b8e..87cf81aaa8d0 100644 --- a/model/usedata_flow_test.go +++ b/model/usedata_flow_test.go @@ -7,211 +7,182 @@ import ( "github.com/stretchr/testify/require" ) -func seedFlowLog(t *testing.T, log Log) { +func seedFlowQuotaData(t *testing.T, quotaData QuotaData) { t.Helper() - require.NoError(t, LOG_DB.Create(&log).Error) + require.NoError(t, DB.Create("aData).Error) } -func TestGetFlowQuotaDataAggregatesConsumeLogsByUserTokenAndChannel(t *testing.T) { - truncateTables(t) - +func seedFlowLookupData(t *testing.T) { + t.Helper() require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) require.NoError(t, DB.Create(&Channel{Id: 2, Name: "west"}).Error) - - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1100, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - ModelName: "gpt-a", - Group: "default", - Quota: 100, - PromptTokens: 7, - CompletionTokens: 3, - }) - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1200, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - ModelName: "gpt-a", - Group: "default", - Quota: 50, - PromptTokens: 5, - CompletionTokens: 1, - }) - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1300, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 2, - ModelName: "gpt-a", - Group: "default", - Quota: 25, - PromptTokens: 2, - CompletionTokens: 2, - }) - seedFlowLog(t, Log{ - UserId: 2, - Username: "bob", - CreatedAt: 1400, - Type: LogTypeConsume, - TokenId: 22, - TokenName: "backup", - ChannelId: 1, - ModelName: "gpt-b", - Group: "vip", - Quota: 70, - PromptTokens: 4, - CompletionTokens: 6, - }) - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1500, - Type: LogTypeError, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - Quota: 999, - }) - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 900, - Type: LogTypeConsume, - TokenId: 33, - TokenName: "outside", - ChannelId: 2, - Quota: 500, - }) - - rows, err := GetFlowQuotaData(1000, 2000, "", 0) - require.NoError(t, err) - require.Len(t, rows, 3) - - require.Equal(t, FlowQuotaData{ - UserID: 1, - Username: "alice", - UserGroup: "default", - TokenID: 11, - TokenName: "primary", - ChannelID: 1, - ChannelName: "east", - ModelName: "gpt-a", - Count: 2, - Quota: 150, - PromptTokens: 12, - InputTokens: 12, - CompletionTokens: 4, - TokenUsed: 16, - }, *rows[0]) - require.Equal(t, "bob", rows[1].Username) - require.Equal(t, "backup", rows[1].TokenName) - require.Equal(t, "east", rows[1].ChannelName) - require.Equal(t, 10, rows[1].TokenUsed) - require.Equal(t, "west", rows[2].ChannelName) - require.Equal(t, 25, rows[2].Quota) + require.NoError(t, DB.Create(&Token{Id: 11, UserId: 1, Key: "sk-primary", Name: "primary"}).Error) + require.NoError(t, DB.Create(&Token{Id: 22, UserId: 2, Key: "sk-backup", Name: "backup"}).Error) + require.NoError(t, DB.Delete(&Token{Id: 11}).Error) } -func TestGetFlowQuotaDataFiltersByUsernameAndSelfUserID(t *testing.T) { +func TestGetFlowQuotaDataUsesQuotaDataRoleSpecificDimensions(t *testing.T) { truncateTables(t) + seedFlowLookupData(t) - require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) - seedFlowLog(t, Log{ - UserId: 1, + seedFlowQuotaData(t, QuotaData{ + UserID: 1, Username: "alice", - CreatedAt: 1100, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - Group: "default", + NodeName: "node-a", + TokenID: 11, + UseGroup: "vip", + ModelName: "gpt-a", + ChannelID: 1, + CreatedAt: 1000, + Count: 2, Quota: 100, + TokenUsed: 40, }) - seedFlowLog(t, Log{ - UserId: 2, - Username: "bob", + seedFlowQuotaData(t, QuotaData{ + UserID: 1, + Username: "alice", + NodeName: "node-a", + TokenID: 11, + UseGroup: "vip", + ModelName: "gpt-a", + ChannelID: 1, + CreatedAt: 1100, + Count: 1, + Quota: 50, + TokenUsed: 20, + }) + seedFlowQuotaData(t, QuotaData{ + UserID: 1, + Username: "alice", + NodeName: "node-a", + TokenID: 11, + UseGroup: "vip", + ModelName: "gpt-a", + ChannelID: 2, CreatedAt: 1200, - Type: LogTypeConsume, - TokenId: 22, - TokenName: "backup", - ChannelId: 1, - Group: "vip", + Count: 1, + Quota: 25, + TokenUsed: 10, + }) + seedFlowQuotaData(t, QuotaData{ + UserID: 2, + Username: "bob", + NodeName: "node-b", + TokenID: 22, + UseGroup: "default", + ModelName: "gpt-b", + ChannelID: 1, + CreatedAt: 1300, + Count: 3, Quota: 70, + TokenUsed: 30, + }) + seedFlowQuotaData(t, QuotaData{ + UserID: 1, + Username: "alice", + ModelName: "legacy", + CreatedAt: 1400, + Count: 99, + Quota: 999, + TokenUsed: 999, }) - adminRows, err := GetFlowQuotaData(1000, 2000, "bob", 0) + rootRows, err := GetFlowQuotaData(900, 2000, "", 0, common.RoleRootUser) require.NoError(t, err) - require.Len(t, adminRows, 1) - require.Equal(t, "bob", adminRows[0].Username) - - selfRows, err := GetFlowQuotaData(1000, 2000, "", 1) + require.Len(t, rootRows, 3) + require.Equal(t, FlowQuotaData{ + UserID: 1, + Username: "alice", + NodeName: "node-a", + TokenID: 11, + TokenName: "primary", + UseGroup: "vip", + ChannelID: 1, + ChannelName: "east", + ModelName: "gpt-a", + TokenUsed: 60, + Count: 3, + Quota: 150, + }, *rootRows[0]) + + adminRows, err := GetFlowQuotaData(900, 2000, "alice", 0, common.RoleAdminUser) + require.NoError(t, err) + require.Len(t, adminRows, 2) + require.Equal(t, 0, adminRows[0].TokenID) + require.Empty(t, adminRows[0].TokenName) + require.Empty(t, adminRows[0].NodeName) + require.Equal(t, "alice", adminRows[0].Username) + require.Equal(t, "vip", adminRows[0].UseGroup) + require.Equal(t, "east", adminRows[0].ChannelName) + require.Equal(t, 150, adminRows[0].Quota) + + selfRows, err := GetFlowQuotaData(900, 2000, "", 1, common.RoleCommonUser) require.NoError(t, err) require.Len(t, selfRows, 1) - require.Equal(t, "alice", selfRows[0].Username) + require.Empty(t, selfRows[0].Username) + require.Equal(t, 0, selfRows[0].ChannelID) + require.Empty(t, selfRows[0].ChannelName) + require.Equal(t, "primary", selfRows[0].TokenName) + require.Equal(t, "vip", selfRows[0].UseGroup) + require.Equal(t, 175, selfRows[0].Quota) } -func TestGetFlowQuotaDataAggregatesCacheTokensAndNonCacheInput(t *testing.T) { +func TestLogQuotaDataSplitsRowsByUseGroupTokenChannelAndNode(t *testing.T) { truncateTables(t) + CacheQuotaDataLock.Lock() + CacheQuotaData = make(map[string]*QuotaData) + CacheQuotaDataLock.Unlock() - require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error) - - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1100, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - ModelName: "gpt-a", - Group: "default", - Quota: 100, - PromptTokens: 100, - CompletionTokens: 10, - Other: common.MapToJsonStr(map[string]interface{}{ - "cache_tokens": 20, - "cache_write_tokens": 5, - }), + LogQuotaData(QuotaDataLogParams{ + UserID: 1, + Username: "alice", + ModelName: "gpt-a", + CreatedAt: 3661, + UseGroup: "vip", + TokenID: 11, + ChannelID: 1, + NodeName: "node-a", + Quota: 100, + TokenUsed: 40, }) - seedFlowLog(t, Log{ - UserId: 1, - Username: "alice", - CreatedAt: 1200, - Type: LogTypeConsume, - TokenId: 11, - TokenName: "primary", - ChannelId: 1, - ModelName: "gpt-a", - Group: "default", - Quota: 50, - PromptTokens: 70, - CompletionTokens: 8, - Other: common.MapToJsonStr(map[string]interface{}{ - "cache_tokens": 30, - "cache_write_tokens": 4, - "usage_semantic": "anthropic", - }), + LogQuotaData(QuotaDataLogParams{ + UserID: 1, + Username: "alice", + ModelName: "gpt-a", + CreatedAt: 3700, + UseGroup: "vip", + TokenID: 11, + ChannelID: 1, + NodeName: "node-a", + Quota: 50, + TokenUsed: 20, + }) + LogQuotaData(QuotaDataLogParams{ + UserID: 1, + Username: "alice", + ModelName: "gpt-a", + CreatedAt: 3700, + UseGroup: "default", + TokenID: 11, + ChannelID: 1, + NodeName: "node-a", + Quota: 25, + TokenUsed: 10, }) - rows, err := GetFlowQuotaData(1000, 2000, "", 0) - require.NoError(t, err) - require.Len(t, rows, 1) - - require.Equal(t, 170, rows[0].PromptTokens) - require.Equal(t, 145, rows[0].InputTokens) - require.Equal(t, 18, rows[0].CompletionTokens) - require.Equal(t, 50, rows[0].CacheTokens) - require.Equal(t, 9, rows[0].CacheWriteTokens) - require.Equal(t, 163, rows[0].TokenUsed) + SaveQuotaDataCache() + + var rows []QuotaData + require.NoError(t, DB.Order("quota DESC").Find(&rows).Error) + require.Len(t, rows, 2) + require.Equal(t, int64(3600), rows[0].CreatedAt) + require.Equal(t, "vip", rows[0].UseGroup) + require.Equal(t, 11, rows[0].TokenID) + require.Equal(t, 1, rows[0].ChannelID) + require.Equal(t, "node-a", rows[0].NodeName) + require.Equal(t, 2, rows[0].Count) + require.Equal(t, 150, rows[0].Quota) + require.Equal(t, 60, rows[0].TokenUsed) + require.Equal(t, "default", rows[1].UseGroup) + require.Equal(t, 25, rows[1].Quota) } diff --git a/web/default/src/features/dashboard/components/flow/flow-charts.tsx b/web/default/src/features/dashboard/components/flow/flow-charts.tsx index f876cb104f93..d16f6b2eea13 100644 --- a/web/default/src/features/dashboard/components/flow/flow-charts.tsx +++ b/web/default/src/features/dashboard/components/flow/flow-charts.tsx @@ -22,10 +22,8 @@ import { VChart } from '@visactor/react-vchart' import { Activity, CircleAlert, - ChevronDown, GitBranch, Hash, - KeyRound, Loader2, Route, WalletCards, @@ -33,12 +31,11 @@ import { import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { formatNumber, formatQuota } from '@/lib/format' +import { ROLE } from '@/lib/roles' import { computeTimeRange } from '@/lib/time' import { useChartTheme } from '@/lib/use-chart-theme' -import { cn } from '@/lib/utils' import { VCHART_OPTION } from '@/lib/vchart' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' import { Empty, EmptyDescription, @@ -46,13 +43,6 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' -import { - Popover, - PopoverContent, - PopoverHeader, - PopoverTitle, - PopoverTrigger, -} from '@/components/ui/popover' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { MultiSelect } from '@/components/multi-select' @@ -67,16 +57,11 @@ import { compactFlowSelectionLabel, flowDisplayState, requireSuccessfulFlowRows, - selectedTokenValuesForUser, - updateSelectedTokensForUser, - visibleFlowUsers, - type SelectedTokensByUser, } from '@/features/dashboard/lib/flow-selection' import type { DashboardFilters, FlowMetric, - FlowPathMode, - FlowUserFilterOption, + FlowRole, FlowSummary, } from '@/features/dashboard/types' @@ -95,20 +80,6 @@ const FLOW_METRIC_OPTIONS = [ { value: 'requests', labelKey: 'Requests', icon: Activity }, ] as const -const FLOW_ENDPOINT_OPTIONS = [ - { value: 'model', labelKey: 'Model' }, - { value: 'channel', labelKey: 'Channel' }, -] as const - -type FlowEndpointMode = (typeof FLOW_ENDPOINT_OPTIONS)[number]['value'] - -function selectedPathMode( - endpointMode: FlowEndpointMode, - includeBothDimensions: boolean -): FlowPathMode { - return includeBothDimensions ? 'model-channel' : endpointMode -} - function FlowStats(props: FlowStatsProps) { const { t } = useTranslation() const items = [ @@ -151,11 +122,9 @@ function FlowStats(props: FlowStatsProps) { ) : ( - <> -
- {item.value} -
- +
+ {item.value} +
)} ) @@ -165,108 +134,15 @@ function FlowStats(props: FlowStatsProps) { ) } -interface FlowUserTokenFiltersProps { - users: FlowUserFilterOption[] - selectedTokensByUser: SelectedTokensByUser - onUserTokensChange: (userID: string, tokenIDs: string[]) => void -} - -function FlowUserTokenFilters(props: FlowUserTokenFiltersProps) { - const { t } = useTranslation() - - if (props.users.length === 0) return null - - return ( -
- {props.users.map((user) => { - const selectedTokens = selectedTokenValuesForUser( - props.selectedTokensByUser, - user.value - ) - const tokenOptions = user.tokens.map((token) => ({ - label: `${token.label} · ${token.valueLabel}`, - value: token.value, - })) - const tokenStateLabel = compactFlowSelectionLabel(selectedTokens.length) - - return ( - - - } - > - - - - - - -
- - - props.onUserTokensChange(user.value, values) - } - placeholder={t('All API tokens')} - emptyText={t('No API tokens')} - maxVisibleChips={2} - renderSelectedSummary={(values) => - compactFlowSelectionLabel(values.length) - } - /> -
-
-
- ) - })} -
- ) -} - export function FlowCharts(props: FlowChartsProps) { const { t } = useTranslation() const { resolvedTheme, themeReady } = useChartTheme() const user = useAuthStore((state) => state.auth.user) - const isAdmin = !!(user?.role && user.role >= 10) + const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN) + const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN) + const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user' const [metric, setMetric] = useState('quota') - const [endpointMode, setEndpointMode] = useState('channel') - const [includeBothDimensions, setIncludeBothDimensions] = useState(false) - const [showTokenLayer, setShowTokenLayer] = useState(true) const [selectedUsers, setSelectedUsers] = useState([]) - const [selectedTokensByUser, setSelectedTokensByUser] = - useState({}) const timeRange = useMemo( () => @@ -292,31 +168,20 @@ export function FlowCharts(props: FlowChartsProps) { isError, isLoading, } = useQuery({ - queryKey: ['dashboard', 'flow', flowQueryParams, isAdmin], + queryKey: ['dashboard', 'flow', flowQueryParams, flowRole], queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin), select: (res) => requireSuccessfulFlowRows(res, t('Please try again later.')), staleTime: 60_000, }) - const pathMode = selectedPathMode(endpointMode, includeBothDimensions) const flowData = useMemo( () => buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, { - pathMode, - includeTokenLayer: showTokenLayer, + role: flowRole, selectedUsers, - selectedTokensByUser, }), - [ - flowRows, - isLoading, - metric, - pathMode, - selectedTokensByUser, - selectedUsers, - showTokenLayer, - ] + [flowRole, flowRows, isLoading, metric, selectedUsers] ) const userFilterOptions = useMemo( () => @@ -326,20 +191,12 @@ export function FlowCharts(props: FlowChartsProps) { })), [flowData.filterOptions.users] ) - const legendUsers = useMemo( - () => visibleFlowUsers(flowData.filterOptions.users, selectedUsers), - [flowData.filterOptions.users, selectedUsers] - ) const chartTitle = t('Flow') const flowSpec = useMemo( () => buildFlowSankeySpec(flowData.flow, chartTitle, formatQuota, { quota: t('Quota'), tokens: t('Tokens'), - inputTokens: t('Input Tokens'), - outputTokens: t('Output Tokens'), - cacheRead: t('Cache Read'), - cacheWrite: t('Cache Write'), requests: t('Requests'), share: t('Share'), }), @@ -348,12 +205,8 @@ export function FlowCharts(props: FlowChartsProps) { const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light' const chartKey = [ metric, - pathMode, - showTokenLayer ? 'token' : 'direct', + flowRole, selectedUsers.join(','), - Object.entries(selectedTokensByUser) - .map(([userID, tokenIDs]) => `${userID}:${tokenIDs.join('|')}`) - .join(','), flowRows?.length ?? 0, resolvedTheme, ].join('-') @@ -368,15 +221,6 @@ export function FlowCharts(props: FlowChartsProps) { ? flowError.message : t('Please try again later.') - const handleUserTokenSelectionChange = ( - userID: string, - tokenIDs: string[] - ) => { - setSelectedTokensByUser((current) => - updateSelectedTokensForUser(current, userID, tokenIDs) - ) - } - return (
@@ -400,62 +244,22 @@ export function FlowCharts(props: FlowChartsProps) { ))} - - - setEndpointMode(value as FlowEndpointMode) - } - className={cn( - 'shrink-0', - includeBothDimensions && 'pointer-events-none opacity-50' - )} - > - - {FLOW_ENDPOINT_OPTIONS.map((option) => ( - - {t(option.labelKey)} - - ))} - - - -
-
- - compactFlowSelectionLabel(values.length) - } - />
+ {isAdmin && ( +
+ + compactFlowSelectionLabel(values.length) + } + /> +
+ )} {isLoading && ( )} @@ -467,11 +271,6 @@ export function FlowCharts(props: FlowChartsProps) {
{chartTitle}
-
{displayState === 'loading' ? ( @@ -491,9 +290,7 @@ export function FlowCharts(props: FlowChartsProps) { {t('No flow data available')} - - {t('No matching token and channel usage was found.')} - + {t('No data available')} ) : ( diff --git a/web/default/src/features/dashboard/lib/flow-selection.test.ts b/web/default/src/features/dashboard/lib/flow-selection.test.ts index c4f18a78733b..8b06309b7f33 100644 --- a/web/default/src/features/dashboard/lib/flow-selection.test.ts +++ b/web/default/src/features/dashboard/lib/flow-selection.test.ts @@ -5,8 +5,6 @@ import { compactFlowSelectionLabel, flowDisplayState, requireSuccessfulFlowRows, - selectedTokenValuesForUser, - updateSelectedTokensForUser, visibleFlowUsers, } from './flow-selection' @@ -17,20 +15,6 @@ const users: FlowUserFilterOption[] = [ valueLabel: '100', valueRaw: 100, color: '#1664ff', - tokens: [ - { - value: 'token:11', - label: 'dry-primary', - valueLabel: '80', - valueRaw: 80, - }, - { - value: 'token:12', - label: 'dry-backup', - valueLabel: '20', - valueRaw: 20, - }, - ], }, { value: 'user:2', @@ -38,14 +22,6 @@ const users: FlowUserFilterOption[] = [ valueLabel: '70', valueRaw: 70, color: '#1ac6ff', - tokens: [ - { - value: 'token:22', - label: 'jrc-key', - valueLabel: '70', - valueRaw: 70, - }, - ], }, ] @@ -61,44 +37,17 @@ describe('dashboard flow selection helpers', () => { ) }) - test('updates token selections independently per user', () => { - const selected = updateSelectedTokensForUser({}, 'user:1', ['token:12']) - assert.deepEqual(selected, { 'user:1': ['token:12'] }) - assert.deepEqual(selectedTokenValuesForUser(selected, 'user:1'), [ - 'token:12', - ]) - - const next = updateSelectedTokensForUser(selected, 'user:2', ['token:22']) - assert.deepEqual(next, { - 'user:1': ['token:12'], - 'user:2': ['token:22'], - }) - }) - - test('keeps hidden user token selections available for later visibility', () => { - const selected = { - 'user:1': ['token:12'], - 'user:2': ['token:22'], - } + test('filters visible users without mutating the source options', () => { + const visible = visibleFlowUsers(users, ['user:1']) assert.deepEqual( - visibleFlowUsers(users, ['user:1']).map((user) => user.value), + visible.map((user) => user.value), ['user:1'] ) - assert.deepEqual(selectedTokenValuesForUser(selected, 'user:2'), [ - 'token:22', - ]) - }) - - test('removes a user token override when no tokens are selected', () => { - const selected = { - 'user:1': ['token:12'], - 'user:2': ['token:22'], - } - const next = updateSelectedTokensForUser(selected, 'user:1', []) - - assert.deepEqual(next, { 'user:2': ['token:22'] }) - assert.deepEqual(selectedTokenValuesForUser(next, 'user:1'), []) + assert.deepEqual( + users.map((user) => user.value), + ['user:1', 'user:2'] + ) }) test('formats compact selected counts for flow multiselect summaries', () => { diff --git a/web/default/src/features/dashboard/lib/flow-selection.ts b/web/default/src/features/dashboard/lib/flow-selection.ts index c020cb97f084..c12bb34320bc 100644 --- a/web/default/src/features/dashboard/lib/flow-selection.ts +++ b/web/default/src/features/dashboard/lib/flow-selection.ts @@ -21,7 +21,6 @@ import type { FlowUserFilterOption, } from '@/features/dashboard/types' -export type SelectedTokensByUser = Record export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart' export interface FlowResponse { @@ -65,25 +64,3 @@ export function visibleFlowUsers( const selected = new Set(selectedUsers) return users.filter((user) => selected.has(user.value)) } - -export function selectedTokenValuesForUser( - selectedTokensByUser: SelectedTokensByUser, - userID: string -): string[] { - return selectedTokensByUser[userID] ?? [] -} - -export function updateSelectedTokensForUser( - selectedTokensByUser: SelectedTokensByUser, - userID: string, - tokenIDs: string[] -): SelectedTokensByUser { - const next = { ...selectedTokensByUser } - const uniqueTokenIDs = Array.from(new Set(tokenIDs)) - if (uniqueTokenIDs.length === 0) { - delete next[userID] - } else { - next[userID] = uniqueTokenIDs - } - return next -} diff --git a/web/default/src/features/dashboard/lib/flow.test.ts b/web/default/src/features/dashboard/lib/flow.test.ts index 43f05e726436..76016ec3773b 100644 --- a/web/default/src/features/dashboard/lib/flow.test.ts +++ b/web/default/src/features/dashboard/lib/flow.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' import type { FlowQuotaDataItem } from '../types' -import { getDashboardChartColors } from './charts' import { buildDashboardFlowData, buildFlowFilterOptions, @@ -12,647 +11,185 @@ const rows: FlowQuotaDataItem[] = [ { user_id: 1, username: 'alice', + node_name: 'node-a', token_id: 11, token_name: 'primary', + use_group: 'vip', channel_id: 101, channel_name: 'east', model_name: 'gpt-4.1', quota: 100, token_used: 40, - prompt_tokens: 25, - completion_tokens: 15, count: 2, }, { user_id: 1, username: 'alice', + node_name: 'node-a', token_id: 11, token_name: 'primary', + use_group: 'vip', channel_id: 102, channel_name: 'west', model_name: 'gpt-4.1', quota: 50, token_used: 20, - prompt_tokens: 12, - completion_tokens: 8, - count: 1, - }, - { - user_id: 1, - username: 'alice', - token_id: 12, - token_name: 'secondary', - channel_id: 103, - channel_name: 'north', - model_name: 'claude-4-sonnet', - quota: 30, - token_used: 10, - prompt_tokens: 6, - completion_tokens: 4, count: 1, }, { user_id: 2, username: 'bob', + node_name: 'node-b', token_id: 22, token_name: 'backup', + use_group: 'default', channel_id: 101, channel_name: 'east', model_name: 'claude-4-sonnet', quota: 70, token_used: 30, - prompt_tokens: 18, - completion_tokens: 12, count: 3, }, ] -const cacheRows: FlowQuotaDataItem[] = [ - { - user_id: 1, - username: 'dry', - token_id: 11, - token_name: 'dry-key', - channel_id: 101, - channel_name: 'east', - model_name: 'gpt-4.1', - quota: 100, - token_used: 90, - input_tokens: 70, - prompt_tokens: 100, - completion_tokens: 20, - cache_tokens: 25, - cache_write_tokens: 5, - count: 2, - }, - { - user_id: 2, - username: 'jrc', - token_id: 22, - token_name: 'jrc-key', - channel_id: 102, - channel_name: 'west', - model_name: 'claude-4-sonnet', - quota: 80, - token_used: 50, - input_tokens: 40, - prompt_tokens: 40, - completion_tokens: 10, - cache_tokens: 18, - cache_write_tokens: 2, - count: 1, - }, -] - describe('dashboard flow data', () => { - test('builds user to token to channel links from quota rows', () => { - const result = buildDashboardFlowData(rows, 'quota') - - assert.equal(result.summary.quota, 250) - assert.equal(result.summary.tokens, 100) - assert.equal(result.summary.requests, 7) - assert.deepEqual( - result.flow.links.map((link) => [ - link.source, - link.target, - link.value, - link.requests, - ]), - [ - ['user:1', 'token:11', 150, 3], - ['user:1', 'token:12', 30, 1], - ['user:2', 'token:22', 70, 3], - ['token:11', 'channel:101', 100, 2], - ['token:11', 'channel:102', 50, 1], - ['token:12', 'channel:103', 30, 1], - ['token:22', 'channel:101', 70, 3], - ] - ) - }) - - test('builds user to token to model links when model mode is selected', () => { - const result = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model', + test('builds normal user token-group-model flow', () => { + const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', { + role: 'user', }) + assert.equal(result.summary.quota, 150) + assert.equal(result.summary.tokens, 60) + assert.equal(result.summary.requests, 3) assert.deepEqual( - result.flow.nodes.map((node) => [node.id, node.kind, node.label]), - [ - ['user:1', 'user', 'alice'], - ['user:2', 'user', 'bob'], - ['token:11', 'token', 'primary'], - ['token:22', 'token', 'backup'], - ['token:12', 'token', 'secondary'], - ['model:gpt-4.1', 'model', 'gpt-4.1'], - ['model:claude-4-sonnet', 'model', 'claude-4-sonnet'], - ] - ) - assert.deepEqual( - result.flow.links.map((link) => [ - link.source, - link.target, - link.value, - link.requests, - ]), + result.flow.links.map((link) => [link.source, link.target, link.value]), [ - ['user:1', 'token:11', 150, 3], - ['user:1', 'token:12', 30, 1], - ['user:2', 'token:22', 70, 3], - ['token:11', 'model:gpt-4.1', 150, 3], - ['token:12', 'model:claude-4-sonnet', 30, 1], - ['token:22', 'model:claude-4-sonnet', 70, 3], + ['group:vip', 'model:gpt-4.1', 150], + ['token:11', 'group:vip', 150], ] ) - }) - - test('builds user to token to model to channel links when both dimensions are enabled', () => { - const result = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model-channel', - }) - - assert.deepEqual( - result.flow.links.map((link) => [ - link.source, - link.target, - link.value, - link.requests, - ]), - [ - ['user:1', 'token:11', 150, 3], - ['user:1', 'token:12', 30, 1], - ['user:2', 'token:22', 70, 3], - ['token:11', 'model:gpt-4.1', 150, 3], - ['token:12', 'model:claude-4-sonnet', 30, 1], - ['token:22', 'model:claude-4-sonnet', 70, 3], - ['model:claude-4-sonnet', 'channel:101', 70, 3], - ['model:claude-4-sonnet', 'channel:103', 30, 1], - ['model:gpt-4.1', 'channel:101', 100, 2], - ['model:gpt-4.1', 'channel:102', 50, 1], - ] + assert.equal( + result.flow.nodes.some((node) => node.kind === 'channel'), + false ) }) - test('can hide the token layer while preserving user-model-channel order', () => { + test('builds admin user-group-model-channel flow', () => { const result = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model-channel', - includeTokenLayer: false, + role: 'admin', }) assert.deepEqual( - result.flow.nodes.map((node) => node.kind), - ['user', 'user', 'model', 'model', 'channel', 'channel', 'channel'] - ) - assert.equal( - result.flow.nodes.some((node) => node.kind === 'token'), - false - ) - assert.deepEqual( - result.flow.links.map((link) => [ - link.source, - link.target, - link.value, - link.requests, - ]), + result.flow.links.map((link) => [link.source, link.target, link.value]), [ - ['user:1', 'model:claude-4-sonnet', 30, 1], - ['user:1', 'model:gpt-4.1', 150, 3], - ['user:2', 'model:claude-4-sonnet', 70, 3], - ['model:claude-4-sonnet', 'channel:101', 70, 3], - ['model:claude-4-sonnet', 'channel:103', 30, 1], - ['model:gpt-4.1', 'channel:101', 100, 2], - ['model:gpt-4.1', 'channel:102', 50, 1], + ['group:default', 'model:claude-4-sonnet', 70], + ['group:vip', 'model:gpt-4.1', 150], + ['model:claude-4-sonnet', 'channel:101', 70], + ['model:gpt-4.1', 'channel:101', 100], + ['model:gpt-4.1', 'channel:102', 50], + ['user:1', 'group:vip', 150], + ['user:2', 'group:default', 70], ] ) }) - test('keeps downstream node colors stable when the token layer is hidden', () => { - const palette = [ - '#101010', - '#202020', - '#303030', - '#404040', - '#505050', - '#606060', - '#707070', - '#808080', - '#909090', - '#a0a0a0', - ] - const withTokenLayer = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model-channel', - includeTokenLayer: true, - colorPalette: palette, - }) - const withoutTokenLayer = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model-channel', - includeTokenLayer: false, - colorPalette: palette, - }) - const colorByID = (result: typeof withTokenLayer, id: string) => { - const node = result.flow.nodes.find((item) => item.id === id) - assert.ok(node) - return node.color - } - - for (const nodeID of [ - 'user:1', - 'user:2', - 'model:claude-4-sonnet', - 'model:gpt-4.1', - 'channel:101', - 'channel:102', - 'channel:103', - ]) { - assert.equal( - colorByID(withTokenLayer, nodeID), - colorByID(withoutTokenLayer, nodeID) - ) - } - }) - - test('can hide the token layer for single downstream dimensions', () => { - const channelResult = buildDashboardFlowData(rows, 'quota', { - pathMode: 'channel', - includeTokenLayer: false, - }) - const modelResult = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model', - includeTokenLayer: false, + test('builds root user-node-token-group-model-channel flow', () => { + const result = buildDashboardFlowData(rows, 'requests', { + role: 'root', }) assert.deepEqual( - channelResult.flow.links.map((link) => [ - link.source, - link.target, - link.value, - ]), - [ - ['user:1', 'channel:101', 100], - ['user:1', 'channel:102', 50], - ['user:1', 'channel:103', 30], - ['user:2', 'channel:101', 70], - ] - ) - assert.deepEqual( - modelResult.flow.links.map((link) => [ - link.source, - link.target, - link.value, - ]), + result.flow.links.map((link) => [link.source, link.target, link.value]), [ - ['user:1', 'model:claude-4-sonnet', 30], - ['user:1', 'model:gpt-4.1', 150], - ['user:2', 'model:claude-4-sonnet', 70], + ['group:default', 'model:claude-4-sonnet', 3], + ['group:vip', 'model:gpt-4.1', 3], + ['model:claude-4-sonnet', 'channel:101', 3], + ['model:gpt-4.1', 'channel:101', 2], + ['model:gpt-4.1', 'channel:102', 1], + ['node:node-a', 'token:11', 3], + ['node:node-b', 'token:22', 3], + ['token:11', 'group:vip', 3], + ['token:22', 'group:default', 3], + ['user:1', 'node:node-a', 3], + ['user:2', 'node:node-b', 3], ] ) }) - test('filters by selected users and per-user selected tokens', () => { + test('filters by selected users', () => { const result = buildDashboardFlowData(rows, 'quota', { - pathMode: 'model-channel', - selectedUsers: ['user:1'], - selectedTokensByUser: { - 'user:1': ['token:12'], - }, + role: 'admin', + selectedUsers: ['user:2'], }) - assert.equal(result.summary.quota, 30) + assert.equal(result.summary.quota, 70) assert.deepEqual( result.flow.links.map((link) => [link.source, link.target, link.value]), [ - ['user:1', 'token:12', 30], - ['token:12', 'model:claude-4-sonnet', 30], - ['model:claude-4-sonnet', 'channel:103', 30], + ['group:default', 'model:claude-4-sonnet', 70], + ['model:claude-4-sonnet', 'channel:101', 70], + ['user:2', 'group:default', 70], ] ) }) - test('builds user and token filter options with stable colors', () => { + test('builds user filter options with stable values', () => { const options = buildFlowFilterOptions(rows, 'quota') assert.deepEqual( - options.users.map((user) => [ - user.value, - user.label, - user.valueLabel, - user.tokens.map((token) => [token.value, token.label]), - ]), + options.users.map((user) => [user.value, user.label, user.valueLabel]), [ - [ - 'user:1', - 'alice', - '180', - [ - ['token:11', 'primary'], - ['token:12', 'secondary'], - ], - ], - ['user:2', 'bob', '70', [['token:22', 'backup']]], + ['user:1', 'alice', '150'], + ['user:2', 'bob', '70'], ] ) assert.notEqual(options.users[0].color, options.users[1].color) }) - test('uses non-cache input plus output tokens for token metric', () => { - const result = buildDashboardFlowData(cacheRows, 'tokens', { - pathMode: 'model-channel', - }) - - assert.equal(result.summary.tokens, 140) - assert.equal(result.summary.inputTokens, 110) - assert.equal(result.summary.completionTokens, 30) - assert.equal(result.summary.cacheTokens, 43) - assert.equal(result.summary.cacheWriteTokens, 7) - assert.deepEqual( - result.flow.links.map((link) => [ - link.source, - link.target, - link.value, - link.inputTokens, - link.completionTokens, - link.cacheTokens, - link.cacheWriteTokens, - ]), - [ - ['user:1', 'token:11', 90, 70, 20, 25, 5], - ['user:2', 'token:22', 50, 40, 10, 18, 2], - ['token:11', 'model:gpt-4.1', 90, 70, 20, 25, 5], - ['token:22', 'model:claude-4-sonnet', 50, 40, 10, 18, 2], - ['model:claude-4-sonnet', 'channel:102', 50, 40, 10, 18, 2], - ['model:gpt-4.1', 'channel:101', 90, 70, 20, 25, 5], - ] - ) - }) - - test('assigns globally distinct node colors and source-colored links while the palette is large enough', () => { - const palette = [ - '#101010', - '#202020', - '#303030', - '#404040', - '#505050', - '#606060', - '#707070', - '#808080', - ] - const result = buildDashboardFlowData(cacheRows, 'quota', { - pathMode: 'model-channel', - colorPalette: palette, - }) - const nodeColors = result.flow.nodes.map((node) => node.color) - const userDry = result.flow.nodes.find((node) => node.id === 'user:1') - const tokenDry = result.flow.nodes.find((node) => node.id === 'token:11') - const modelGpt = result.flow.nodes.find( - (node) => node.id === 'model:gpt-4.1' - ) - const linkUserToken = result.flow.links.find( - (link) => link.source === 'user:1' && link.target === 'token:11' - ) - const linkTokenModel = result.flow.links.find( - (link) => link.source === 'token:11' && link.target === 'model:gpt-4.1' - ) - const linkModelChannel = result.flow.links.find( - (link) => link.source === 'model:gpt-4.1' && link.target === 'channel:101' - ) - - assert.ok(userDry) - assert.ok(tokenDry) - assert.ok(modelGpt) - assert.equal(new Set(nodeColors).size, nodeColors.length) - assert.equal(userDry.color, palette[0]) - assert.notEqual(userDry.color, tokenDry.color) - assert.notEqual(tokenDry.color, modelGpt.color) - assert.equal(linkUserToken?.color, userDry.color) - assert.equal(linkUserToken?.hoverColor, linkUserToken?.color) - assert.equal(linkTokenModel?.color, tokenDry.color) - assert.equal(linkModelChannel?.color, modelGpt.color) - }) - - test('builds an interactive Sankey spec with in-chart labels and nested datum metrics', () => { - const result = buildDashboardFlowData(cacheRows, 'quota', { - pathMode: 'model-channel', + test('builds Sankey spec with quota token request tooltips', () => { + const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', { + role: 'root', }) const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') const values = flowSpec.data[0].values[0] - const dryNode = values.nodes.find( + const aliceNode = values.nodes.find( (node: Record) => node.key === 'user:1' ) - const dryLink = values.links.find( + const userNodeLink = values.links.find( (link: Record) => - link.source === 'user:1' && link.target === 'token:11' + link.source === 'user:1' && link.target === 'node:node-a' ) assert.equal(flowSpec.type, 'sankey') assert.equal(flowSpec.title.text, 'Flow') - assert.deepEqual(flowSpec.legends, { visible: false }) - assert.equal(flowSpec.label.visible, true) - assert.equal(flowSpec.label.position, 'outside') - assert.equal(flowSpec.node.interactive, true) - assert.equal(flowSpec.link.interactive, true) - assert.equal(flowSpec.link.state.hover.fillOpacity, 0.9) - assert.equal(flowSpec.emphasis.enable, false) - assert.equal(flowSpec.emphasis.trigger, 'hover') - assert.equal(flowSpec.emphasis.effect, 'self') - assert.equal(flowSpec.tooltip.trigger, 'hover') - assert.equal(flowSpec.tooltip.activeType, 'mark') - assert.equal(flowSpec.tooltip.dimension.visible, false) - assert.equal(flowSpec.tooltip.group.visible, false) - assert.equal(flowSpec.tooltip.mark.checkOverlap, true) - assert.equal(flowSpec.tooltip.mark.visible({ datum: dryNode }), true) - assert.equal(flowSpec.tooltip.mark.visible({ datum: dryLink }), true) + assert.equal(flowSpec.tooltip.mark.visible({ datum: aliceNode }), true) + assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true) assert.equal(flowSpec.animation, false) - assert.equal(flowSpec.data[0].id, 'flow') - assert.equal(values.nodes.length, 8) - assert.equal(values.links.length, 6) - assert.ok(dryNode) - assert.ok(dryLink) - assert.equal(dryNode.name, 'dry') - assert.equal(dryNode.rawLabel, 'dry') - assert.equal( - values.nodes.some((node: Record) => - /^(User|Key|Model|Channel): /.test(String(node.name)) - ), - false - ) - assert.equal( - flowSpec.node.style.fill({ - datum: [dryNode], - depth: 0, - }), - dryNode.color - ) - assert.equal( - flowSpec.link.style.fill({ - datum: dryLink, - }), - dryLink.linkColor - ) - assert.equal(dryLink.color, dryNode.color) - assert.match(dryLink.linkColor, /^rgba\(/) - assert.notEqual(dryLink.linkColor, dryLink.color) - assert.equal( - flowSpec.link.state.hover.fill({ - datum: dryLink, - }), - dryLink.color - ) - assert.equal(flowSpec.label.style.fill, '#475569') - assert.equal(flowSpec.link.style.pickMode, 'accurate') - assert.equal(flowSpec.link.style.boundsMode, 'accurate') - assert.equal('pickStrokeBuffer' in flowSpec.link.style, false) - assert.equal(flowSpec.linkSortBy({ value: 1 }, { value: 5 }), 4) - assert.equal( - flowSpec.link.style.zIndex({ datum: { value: 30 } }) > - flowSpec.link.style.zIndex({ datum: { value: 150 } }), - true - ) + assert.equal(values.nodes.length, 6) + assert.equal(values.links.length, 5) + assert.equal(aliceNode.name, 'alice') + assert.match(userNodeLink.linkColor, /^rgba\(/) const tooltipRows = flowSpec.tooltip.mark.content assert.deepEqual( tooltipRows .filter((row: Record) => typeof row.visible === 'function' - ? row.visible({ datum: dryNode }) + ? row.visible({ datum: userNodeLink }) : true ) .map((row: Record) => [ row.key, typeof row.value === 'function' - ? row.value({ datum: dryNode }) + ? row.value({ datum: userNodeLink }) : row.value, ]), [ ['Quota', '100'], - ['Tokens', '90'], - ['Input Tokens', '70'], - ['Output Tokens', '20'], - ['Cache Read', '25'], - ['Cache Write', '5'], + ['Tokens', '40'], ['Requests', '2'], - ] - ) - assert.deepEqual( - tooltipRows - .filter((row: Record) => - typeof row.visible === 'function' - ? row.visible({ datum: dryLink }) - : true - ) - .map((row: Record) => [ - row.key, - typeof row.value === 'function' - ? row.value({ datum: dryLink }) - : row.value, - ]), - [ - ['Quota', '100'], - ['Tokens', '90'], - ['Input Tokens', '70'], - ['Output Tokens', '20'], - ['Cache Read', '25'], - ['Cache Write', '5'], - ['Requests', '2'], - ['Share', '55.6%'], + ['Share', '100.0%'], ] ) }) - - test('uses the shared dashboard VChart color palette', () => { - const result = buildDashboardFlowData(cacheRows, 'quota', { - pathMode: 'model-channel', - }) - const expectedPalette = getDashboardChartColors(result.flow.nodes.length) - const colors = Array.from( - new Set(result.flow.nodes.map((node) => node.color)) - ) - - assert.ok(colors.length > 1) - assert.deepEqual( - [...colors].sort(), - expectedPalette.slice(0, colors.length).sort() - ) - assert.equal(flowLabelFill(), '#475569') - }) - - test('orders Sankey links so thinner links are drawn after thicker links', () => { - const result = buildDashboardFlowData(rows, 'quota') - const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') - const values = flowSpec.data[0].values[0] - const linkValues = values.links.map( - (link: Record) => link.value - ) - - assert.deepEqual(linkValues, [150, 100, 70, 70, 50, 30, 30]) - assert.equal(values.links.at(-1).value, 30) - }) - - test('makes thinner branches from the same source darker than thicker branches', () => { - const branchRows: FlowQuotaDataItem[] = [ - { - user_id: 1, - username: 'source', - token_id: 11, - token_name: 'z-heavy', - channel_id: 101, - channel_name: 'east', - model_name: 'gpt-4.1', - quota: 100, - prompt_tokens: 60, - completion_tokens: 40, - count: 10, - }, - { - user_id: 1, - username: 'source', - token_id: 12, - token_name: 'a-light', - channel_id: 102, - channel_name: 'west', - model_name: 'gpt-4.1', - quota: 10, - prompt_tokens: 6, - completion_tokens: 4, - count: 1, - }, - ] - const result = buildDashboardFlowData(branchRows, 'quota') - const heavy = result.flow.links.find( - (link) => link.source === 'user:1' && link.target === 'token:11' - ) - const light = result.flow.links.find( - (link) => link.source === 'user:1' && link.target === 'token:12' - ) - - assert.ok(heavy) - assert.ok(light) - assert.equal(heavy.color, light.color) - assert.ok(alphaFromRgba(heavy.linkColor) < alphaFromRgba(light.linkColor)) - }) - - test('assigns deterministic z-indexes for equal-value links', () => { - const result = buildDashboardFlowData(rows, 'quota') - const flowSpec = buildFlowSankeySpec(result.flow, 'Flow') - const values = flowSpec.data[0].values[0] - const equalValueLinks = values.links.filter( - (link: Record) => link.value === 30 - ) - const zIndexes = equalValueLinks.map((link: Record) => - flowSpec.link.style.zIndex({ datum: link }) - ) - - assert.equal(equalValueLinks.length, 2) - assert.equal(new Set(zIndexes).size, equalValueLinks.length) - }) }) - -function alphaFromRgba(color: string): number { - const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec( - color - ) - assert.ok(match) - return Number(match[1]) -} - -function flowLabelFill(): string { - return buildFlowSankeySpec({ nodes: [], links: [] }, 'Flow').label.style.fill -} diff --git a/web/default/src/features/dashboard/lib/flow.ts b/web/default/src/features/dashboard/lib/flow.ts index 3a8a82690853..9e42e859f435 100644 --- a/web/default/src/features/dashboard/lib/flow.ts +++ b/web/default/src/features/dashboard/lib/flow.ts @@ -24,8 +24,8 @@ import type { FlowFilterOptions, FlowMetric, FlowNodeKind, - FlowPathMode, FlowQuotaDataItem, + FlowRole, FlowSummary, ProcessedFlowData, } from '@/features/dashboard/types' @@ -37,34 +37,27 @@ type VChartSpec = Record type FlowMetrics = { quota: number tokens: number - inputTokens: number - promptTokens: number - completionTokens: number - cacheTokens: number - cacheWriteTokens: number requests: number } type FlowSankeyLabels = { quota: string tokens: string - inputTokens: string - outputTokens: string - cacheRead: string - cacheWrite: string requests: string share: string } -const DEFAULT_FLOW_PATH_MODE: FlowPathMode = 'channel' +type FlowPathNode = { + id: string + label: string + kind: FlowNodeKind +} + +const DEFAULT_FLOW_ROLE: FlowRole = 'user' const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = { quota: 'Quota', tokens: 'Tokens', - inputTokens: 'Input Tokens', - outputTokens: 'Output Tokens', - cacheRead: 'Cache Read', - cacheWrite: 'Cache Write', requests: 'Requests', share: 'Share', } @@ -76,52 +69,11 @@ function numberValue(value: unknown): number { return Number.isFinite(n) ? n : 0 } -function requestCount(row: FlowQuotaDataItem): number { - return numberValue(row.count) -} - -function tokenCount(row: FlowQuotaDataItem): number { - const computed = inputTokenCount(row) + numberValue(row.completion_tokens) - if (computed > 0) return computed - return numberValue(row.token_used) -} - -function cacheReadTokenCount(row: FlowQuotaDataItem): number { - return numberValue(row.cache_tokens) -} - -function cacheWriteTokenCount(row: FlowQuotaDataItem): number { - return numberValue(row.cache_write_tokens) -} - -function inputTokenCount(row: FlowQuotaDataItem): number { - const explicitInputTokens = numberValue(row.input_tokens) - if (explicitInputTokens > 0) return explicitInputTokens - - const promptTokens = numberValue(row.prompt_tokens) - const cacheTokens = cacheReadTokenCount(row) - const cacheWriteTokens = cacheWriteTokenCount(row) - if (cacheTokens > 0 || cacheWriteTokens > 0) { - return Math.max(promptTokens - cacheTokens - cacheWriteTokens, 0) - } - return promptTokens -} - function rowMetrics(row: FlowQuotaDataItem): FlowMetrics { - const promptTokens = numberValue(row.prompt_tokens) - const completionTokens = numberValue(row.completion_tokens) - const inputTokens = inputTokenCount(row) - const cacheTokens = cacheReadTokenCount(row) - const cacheWriteTokens = cacheWriteTokenCount(row) return { quota: numberValue(row.quota), - tokens: tokenCount(row), - inputTokens, - promptTokens, - completionTokens, - cacheTokens, - cacheWriteTokens, - requests: requestCount(row), + tokens: numberValue(row.token_used), + requests: numberValue(row.count), } } @@ -131,51 +83,80 @@ function metricValue(metrics: FlowMetrics, metric: FlowMetric): number { return metrics.quota } -function userNodeId(row: FlowQuotaDataItem): string { - return `user:${numberValue(row.user_id)}` -} - -function tokenNodeId(row: FlowQuotaDataItem): string { - const tokenID = numberValue(row.token_id) - if (tokenID > 0) return `token:${tokenID}` - return `token:${row.token_name || 'unknown'}` -} - -function modelNodeId(row: FlowQuotaDataItem): string { - return `model:${row.model_name || 'unknown'}` +function userNode(row: FlowQuotaDataItem): FlowPathNode { + const userID = numberValue(row.user_id) + return { + id: userID > 0 ? `user:${userID}` : `user:${row.username || 'unknown'}`, + label: row.username || (userID > 0 ? `user-${userID}` : 'Unknown User'), + kind: 'user', + } } -function channelNodeId(row: FlowQuotaDataItem): string { - const channelID = numberValue(row.channel_id) - if (channelID > 0) return `channel:${channelID}` - return `channel:${row.channel_name || 'unknown'}` +function nodeNameNode(row: FlowQuotaDataItem): FlowPathNode { + const nodeName = row.node_name || 'default' + return { + id: `node:${nodeName}`, + label: nodeName, + kind: 'node', + } } -function userLabel(row: FlowQuotaDataItem): string { - const userID = numberValue(row.user_id) - return row.username || (userID > 0 ? `user-${userID}` : 'Unknown User') +function tokenNode(row: FlowQuotaDataItem): FlowPathNode { + const tokenID = numberValue(row.token_id) + return { + id: tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`, + label: + row.token_name || (tokenID > 0 ? `token-${tokenID}` : 'Unknown Token'), + kind: 'token', + } } -function tokenLabel(row: FlowQuotaDataItem): string { - const tokenID = numberValue(row.token_id) - return row.token_name || (tokenID > 0 ? `token-${tokenID}` : 'Unknown Token') +function groupNode(row: FlowQuotaDataItem): FlowPathNode { + const useGroup = row.use_group || 'unknown' + return { + id: `group:${useGroup}`, + label: useGroup, + kind: 'group', + } } -function modelLabel(row: FlowQuotaDataItem): string { - return row.model_name || 'Unknown Model' +function modelNode(row: FlowQuotaDataItem): FlowPathNode { + const model = row.model_name || 'unknown' + return { + id: `model:${model}`, + label: row.model_name || 'Unknown Model', + kind: 'model', + } } -function channelLabel(row: FlowQuotaDataItem): string { +function channelNode(row: FlowQuotaDataItem): FlowPathNode { const channelID = numberValue(row.channel_id) - return ( - row.channel_name || (channelID > 0 ? `channel-${channelID}` : 'Unknown') - ) + return { + id: + channelID > 0 + ? `channel:${channelID}` + : `channel:${row.channel_name || 'unknown'}`, + label: + row.channel_name || (channelID > 0 ? `channel-${channelID}` : 'Unknown'), + kind: 'channel', + } } -function formatNumber(value: number): string { - return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( - value - ) +function flowPath(row: FlowQuotaDataItem, role: FlowRole): FlowPathNode[] { + if (role === 'root') { + return [ + userNode(row), + nodeNameNode(row), + tokenNode(row), + groupNode(row), + modelNode(row), + channelNode(row), + ] + } + if (role === 'admin') { + return [userNode(row), groupNode(row), modelNode(row), channelNode(row)] + } + return [tokenNode(row), groupNode(row), modelNode(row)] } function colorAt(index: number, palette?: readonly string[]): string { @@ -227,33 +208,10 @@ function stableColorMap( return map } -function userColorMap( - rows: FlowQuotaDataItem[], - palette?: readonly string[] -): Map { - return stableColorMap(sortedUniqueNodeIds(rows, userNodeId), palette) -} - -function sortedUniqueNodeIds( - rows: FlowQuotaDataItem[], - idForRow: (row: FlowQuotaDataItem) => string -): string[] { - return Array.from(new Set(rows.map(idForRow))).sort((a, b) => - a.localeCompare(b) - ) -} - -function flowColorMap( - rows: FlowQuotaDataItem[], - palette?: readonly string[] -): Map { - const keys = [ - ...sortedUniqueNodeIds(rows, userNodeId), - ...sortedUniqueNodeIds(rows, tokenNodeId), - ...sortedUniqueNodeIds(rows, modelNodeId), - ...sortedUniqueNodeIds(rows, channelNodeId), - ] - return stableColorMap(keys, palette) +function rootColorKeys(rows: FlowQuotaDataItem[], role: FlowRole): string[] { + return Array.from( + new Set(rows.map((row) => flowPath(row, role)[0]?.id ?? 'unknown')) + ).sort((a, b) => a.localeCompare(b)) } function filterRows( @@ -261,44 +219,26 @@ function filterRows( options: FlowBuildOptions = {} ): FlowQuotaDataItem[] { const selectedUsers = new Set(options.selectedUsers ?? []) - const selectedTokensByUser = options.selectedTokensByUser ?? {} - - return rows.filter((row) => { - const userID = userNodeId(row) - const tokenID = tokenNodeId(row) - if (selectedUsers.size > 0 && !selectedUsers.has(userID)) return false - - const selectedTokens = selectedTokensByUser[userID] ?? [] - if (selectedTokens.length > 0 && !selectedTokens.includes(tokenID)) { - return false - } - return true - }) + if (selectedUsers.size === 0) return rows + return rows.filter((row) => selectedUsers.has(userNode(row).id)) } function addNode( map: Map, - id: string, - label: string, - kind: FlowNodeKind, + pathNode: FlowPathNode, metrics: FlowMetrics, metric: FlowMetric, color: string, colorKey: string ): void { - const previous = map.get(id) ?? { - id, - label, - kind, + const previous = map.get(pathNode.id) ?? { + id: pathNode.id, + label: pathNode.label, + kind: pathNode.kind, value: 0, requests: 0, quota: 0, tokens: 0, - inputTokens: 0, - promptTokens: 0, - completionTokens: 0, - cacheTokens: 0, - cacheWriteTokens: 0, color, colorKey, } @@ -306,40 +246,28 @@ function addNode( previous.requests += metrics.requests previous.quota += metrics.quota previous.tokens += metrics.tokens - previous.inputTokens += metrics.inputTokens - previous.promptTokens += metrics.promptTokens - previous.completionTokens += metrics.completionTokens - previous.cacheTokens += metrics.cacheTokens - previous.cacheWriteTokens += metrics.cacheWriteTokens - map.set(id, previous) + map.set(pathNode.id, previous) } function addLink( map: Map, - source: string, - target: string, - sourceLabel: string, - targetLabel: string, + source: FlowPathNode, + target: FlowPathNode, metrics: FlowMetrics, metric: FlowMetric, color: string, colorKey: string ): void { - const key = `${source}\u0000${target}` + const key = `${source.id}\u0000${target.id}` const previous = map.get(key) ?? { - source, - target, + source: source.id, + target: target.id, value: 0, requests: 0, quota: 0, tokens: 0, - inputTokens: 0, - promptTokens: 0, - completionTokens: 0, - cacheTokens: 0, - cacheWriteTokens: 0, - sourceLabel, - targetLabel, + sourceLabel: source.label, + targetLabel: target.label, color, linkColor: color, linkAlpha: 1, @@ -351,11 +279,6 @@ function addLink( previous.requests += metrics.requests previous.quota += metrics.quota previous.tokens += metrics.tokens - previous.inputTokens += metrics.inputTokens - previous.promptTokens += metrics.promptTokens - previous.completionTokens += metrics.completionTokens - previous.cacheTokens += metrics.cacheTokens - previous.cacheWriteTokens += metrics.cacheWriteTokens map.set(key, previous) } @@ -408,20 +331,12 @@ function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary { const metrics = rowMetrics(row) summary.quota += metrics.quota summary.tokens += metrics.tokens - summary.inputTokens += metrics.inputTokens - summary.completionTokens += metrics.completionTokens - summary.cacheTokens += metrics.cacheTokens - summary.cacheWriteTokens += metrics.cacheWriteTokens summary.requests += metrics.requests return summary }, { quota: 0, tokens: 0, - inputTokens: 0, - completionTokens: 0, - cacheTokens: 0, - cacheWriteTokens: 0, requests: 0, } ) @@ -430,193 +345,60 @@ function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary { function buildFlowGraph( rows: FlowQuotaDataItem[], metric: FlowMetric, - pathMode: FlowPathMode = DEFAULT_FLOW_PATH_MODE, - includeTokenLayer = true, + role: FlowRole, palette?: readonly string[] ): DashboardFlowGraph { - const userNodes = new Map() - const tokenNodes = new Map() - const modelNodes = new Map() - const channelNodes = new Map() - const userTokenLinks = new Map() - const userModelLinks = new Map() - const userChannelLinks = new Map() - const tokenModelLinks = new Map() - const tokenChannelLinks = new Map() - const modelChannelLinks = new Map() - const colors = flowColorMap(rows, palette) + const nodes = new Map() + const links = new Map() + const colors = stableColorMap(rootColorKeys(rows, role), palette) for (const row of rows) { + const path = flowPath(row, role) + const root = path[0] + if (!root) continue const metrics = rowMetrics(row) - const userID = userNodeId(row) - const tokenID = tokenNodeId(row) - const modelID = modelNodeId(row) - const channelID = channelNodeId(row) - const userColor = colors.get(userID) ?? colorAt(0, palette) - const tokenColor = colors.get(tokenID) ?? userColor - const modelColor = colors.get(modelID) ?? userColor - const channelColor = colors.get(channelID) ?? modelColor - - addNode( - userNodes, - userID, - userLabel(row), - 'user', - metrics, - metric, - userColor, - userID - ) - - if (includeTokenLayer) { - addNode( - tokenNodes, - tokenID, - tokenLabel(row), - 'token', - metrics, - metric, - tokenColor, - tokenID - ) - addLink( - userTokenLinks, - userID, - tokenID, - userLabel(row), - tokenLabel(row), - metrics, - metric, - userColor, - userID - ) - } - - if (pathMode === 'model' || pathMode === 'model-channel') { - addNode( - modelNodes, - modelID, - modelLabel(row), - 'model', - metrics, - metric, - modelColor, - modelID - ) - const modelSourceID = includeTokenLayer ? tokenID : userID - const modelSourceLabel = includeTokenLayer - ? tokenLabel(row) - : userLabel(row) - const modelSourceColor = includeTokenLayer ? tokenColor : userColor - const modelSourceColorKey = includeTokenLayer ? tokenID : userID - addLink( - includeTokenLayer ? tokenModelLinks : userModelLinks, - modelSourceID, - modelID, - modelSourceLabel, - modelLabel(row), - metrics, - metric, - modelSourceColor, - modelSourceColorKey - ) - } - - if (pathMode === 'channel' || pathMode === 'model-channel') { - addNode( - channelNodes, - channelID, - channelLabel(row), - 'channel', - metrics, - metric, - channelColor, - channelID - ) - } + const color = colors.get(root.id) ?? colorAt(0, palette) - if (pathMode === 'channel') { - const channelSourceID = includeTokenLayer ? tokenID : userID - const channelSourceLabel = includeTokenLayer - ? tokenLabel(row) - : userLabel(row) - const channelSourceColor = includeTokenLayer ? tokenColor : userColor - const channelSourceColorKey = includeTokenLayer ? tokenID : userID - addLink( - includeTokenLayer ? tokenChannelLinks : userChannelLinks, - channelSourceID, - channelID, - channelSourceLabel, - channelLabel(row), - metrics, - metric, - channelSourceColor, - channelSourceColorKey - ) + for (const node of path) { + addNode(nodes, node, metrics, metric, color, root.id) } - - if (pathMode === 'model-channel') { - addLink( - modelChannelLinks, - modelID, - channelID, - modelLabel(row), - channelLabel(row), - metrics, - metric, - modelColor, - modelID - ) + for (let i = 0; i < path.length - 1; i++) { + const source = path[i] + const target = path[i + 1] + if (!source || !target) continue + addLink(links, source, target, metrics, metric, color, root.id) } } - const links = [ - ...Array.from(userTokenLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ...Array.from(userModelLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ...Array.from(userChannelLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ...Array.from(tokenModelLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ...Array.from(tokenChannelLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ...Array.from(modelChannelLinks.values()).sort( - (a, b) => - a.source.localeCompare(b.source) || a.target.localeCompare(b.target) - ), - ] - const total = links - .filter((link) => link.source.startsWith('user:')) + const flowLinks = Array.from(links.values()).sort( + (a, b) => + a.source.localeCompare(b.source) || a.target.localeCompare(b.target) + ) + const firstStepSources = new Set( + rows + .map((row) => flowPath(row, role)[0]?.id) + .filter((id): id is string => Boolean(id)) + ) + const total = flowLinks + .filter((link) => firstStepSources.has(link.source)) .reduce((sum, link) => sum + link.value, 0) - for (const link of links) { + for (const link of flowLinks) { link.share = total > 0 ? link.value / total : 0 } - assignLinkDisplayColors(links) + assignLinkDisplayColors(flowLinks) return { - nodes: [ - ...Array.from(userNodes.values()).sort((a, b) => - a.label.localeCompare(b.label) - ), - ...Array.from(tokenNodes.values()).sort(byValueThenLabel), - ...Array.from(modelNodes.values()).sort(byValueThenLabel), - ...Array.from(channelNodes.values()).sort(byValueThenLabel), - ], - links, + nodes: Array.from(nodes.values()).sort(byValueThenLabel), + links: flowLinks, } } +function formatNumber(value: number): string { + return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( + value + ) +} + export function buildFlowFilterOptions( rows: FlowQuotaDataItem[], metric: FlowMetric = 'quota', @@ -628,30 +410,25 @@ export function buildFlowFilterOptions( label: string value: number color: string - tokens: Map } >() - const colors = userColorMap(rows, palette) + const colors = stableColorMap( + rows.map((row) => userNode(row).id).sort((a, b) => a.localeCompare(b)), + palette + ) for (const row of rows) { - const userID = userNodeId(row) - const tokenID = tokenNodeId(row) + const user = userNode(row) + if (!row.user_id && !row.username) continue const metrics = rowMetrics(row) const value = metricValue(metrics, metric) - const user = users.get(userID) ?? { - label: userLabel(row), + const current = users.get(user.id) ?? { + label: user.label, value: 0, - color: colors.get(userID) ?? colorAt(0, palette), - tokens: new Map(), + color: colors.get(user.id) ?? colorAt(0, palette), } - user.value += value - const token = user.tokens.get(tokenID) ?? { - label: tokenLabel(row), - value: 0, - } - token.value += value - user.tokens.set(tokenID, token) - users.set(userID, user) + current.value += value + users.set(user.id, current) } return { @@ -662,16 +439,6 @@ export function buildFlowFilterOptions( valueLabel: formatNumber(user.value), valueRaw: user.value, color: user.color, - tokens: Array.from(user.tokens.entries()) - .map(([tokenValue, token]) => ({ - value: tokenValue, - label: token.label, - valueLabel: formatNumber(token.value), - valueRaw: token.value, - })) - .sort( - (a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label) - ), })) .sort( (a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label) @@ -684,20 +451,13 @@ export function buildDashboardFlowData( metric: FlowMetric = 'quota', options: FlowBuildOptions = {} ): ProcessedFlowData { - const pathMode = options.pathMode ?? DEFAULT_FLOW_PATH_MODE - const includeTokenLayer = options.includeTokenLayer ?? true + const role = options.role ?? DEFAULT_FLOW_ROLE const filteredRows = filterRows(rows, options) const palette = options.colorPalette return { summary: buildSummary(filteredRows), - flow: buildFlowGraph( - filteredRows, - metric, - pathMode, - includeTokenLayer, - palette - ), + flow: buildFlowGraph(filteredRows, metric, role, palette), filterOptions: buildFlowFilterOptions(rows, metric, palette), } } @@ -756,30 +516,6 @@ function tooltipMetricLines( value: (datum: Record) => formattedNumber(datum, 'tokens'), }, - { - key: labels.inputTokens, - value: (datum: Record) => - formattedNumber(datum, 'inputTokens'), - }, - { - key: labels.outputTokens, - value: (datum: Record) => - formattedNumber(datum, 'completionTokens'), - }, - { - key: labels.cacheRead, - value: (datum: Record) => - formattedNumber(datum, 'cacheTokens'), - visible: (datum: Record) => - hasMetric(datum, 'cacheTokens'), - }, - { - key: labels.cacheWrite, - value: (datum: Record) => - formattedNumber(datum, 'cacheWriteTokens'), - visible: (datum: Record) => - hasMetric(datum, 'cacheWriteTokens'), - }, { key: labels.requests, value: (datum: Record) => @@ -816,11 +552,6 @@ export function buildFlowSankeySpec( requests: node.requests, quota: node.quota, tokens: node.tokens, - inputTokens: node.inputTokens, - promptTokens: node.promptTokens, - completionTokens: node.completionTokens, - cacheTokens: node.cacheTokens, - cacheWriteTokens: node.cacheWriteTokens, color: node.color, colorKey: node.colorKey, })), @@ -837,11 +568,6 @@ export function buildFlowSankeySpec( requests: link.requests, quota: link.quota, tokens: link.tokens, - inputTokens: link.inputTokens, - promptTokens: link.promptTokens, - completionTokens: link.completionTokens, - cacheTokens: link.cacheTokens, - cacheWriteTokens: link.cacheWriteTokens, color: link.color, linkColor: link.linkColor, linkAlpha: link.linkAlpha, diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index c267ac8eb053..dade561b91a7 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -36,7 +36,8 @@ export interface QuotaDataItem { export interface FlowQuotaDataItem { user_id?: number username?: string - user_group?: string + node_name?: string + use_group?: string token_id?: number token_name?: string channel_id?: number @@ -45,24 +46,23 @@ export interface FlowQuotaDataItem { token_used?: number count?: number quota?: number - input_tokens?: number - prompt_tokens?: number - completion_tokens?: number - cache_tokens?: number - cache_write_tokens?: number } export type FlowMetric = 'quota' | 'tokens' | 'requests' -export type FlowPathMode = 'model' | 'channel' | 'model-channel' +export type FlowRole = 'user' | 'admin' | 'root' -export type FlowNodeKind = 'user' | 'token' | 'model' | 'channel' +export type FlowNodeKind = + | 'user' + | 'node' + | 'token' + | 'group' + | 'model' + | 'channel' export interface FlowBuildOptions { - pathMode?: FlowPathMode - includeTokenLayer?: boolean + role?: FlowRole selectedUsers?: string[] - selectedTokensByUser?: Record colorPalette?: readonly string[] } @@ -74,11 +74,6 @@ export interface DashboardFlowNode { requests: number quota: number tokens: number - inputTokens: number - promptTokens: number - completionTokens: number - cacheTokens: number - cacheWriteTokens: number color: string colorKey: string } @@ -90,11 +85,6 @@ export interface DashboardFlowLink { requests: number quota: number tokens: number - inputTokens: number - promptTokens: number - completionTokens: number - cacheTokens: number - cacheWriteTokens: number sourceLabel: string targetLabel: string color: string @@ -110,20 +100,12 @@ export interface DashboardFlowGraph { links: DashboardFlowLink[] } -export interface FlowTokenFilterOption { - value: string - label: string - valueLabel: string - valueRaw: number -} - export interface FlowUserFilterOption { value: string label: string valueLabel: string valueRaw: number color: string - tokens: FlowTokenFilterOption[] } export interface FlowFilterOptions { @@ -133,10 +115,6 @@ export interface FlowFilterOptions { export interface FlowSummary { quota: number tokens: number - inputTokens: number - completionTokens: number - cacheTokens: number - cacheWriteTokens: number requests: number }